diff --git a/CHANGELOG.md b/CHANGELOG.md index da846552..e54113b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,6 +122,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Development — parallel `tauri dev` alongside release + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#866](https://github.com/Psychotoxical/psysonic/pull/866)** + +* Debug builds skip `tauri-plugin-single-instance` so `./dev.sh` can run next to an installed release while sharing the same app data directory. +* Debug-only chrome: window title `Psysonic (Dev)`, red sidebar brand, monochrome custom titlebar buttons, mobile `DEV` badge, horizontally flipped tray icon. +* Debug builds do not register OS global shortcuts, MPRIS/media keys, or Windows taskbar media controls — release keeps system-wide input when both are open. + + + ### Interface Scale — covers the whole window **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#781](https://github.com/Psychotoxical/psysonic/pull/781)** diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 399b0e2b..62768a16 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,6 +38,28 @@ const MAX_DL_CONCURRENCY: usize = 4; /// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux). type MprisControls = Mutex>; +/// Release builds only: focus or CLI-hand off when a second instance is launched. +#[cfg(not(debug_assertions))] +fn on_second_instance( + app: &tauri::AppHandle, + argv: Vec, + _cwd: String, +) { + if !crate::cli::handle_cli_on_primary_instance(app, &argv) { + let window = app.get_webview_window("main").expect("no main window"); + // The window may have been hidden via the close-to-tray path, + // which injects PAUSE_RENDERING_JS (sets `__psyHidden=true`, + // pauses CSS animations). Tray-icon restore mirrors this with + // RESUME_RENDERING_JS — second-launch restore must do the same, + // otherwise the webview comes back with rendering still paused + // and navigation looks blank (issue #497). + let _ = window.eval(RESUME_RENDERING_JS); + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + pub fn run() { // Linux: second `psysonic --player …` forwards over D-Bus before heavy startup. #[cfg(target_os = "linux")] @@ -57,7 +79,7 @@ pub fn run() { let (audio_engine, _audio_thread) = audio::create_engine(); - tauri::Builder::default() + let builder = tauri::Builder::default() .manage(audio_engine) .manage(ShortcutMap::default()) .manage(discord::DiscordState::new()) @@ -78,24 +100,18 @@ pub fn run() { .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_fs::init()) - .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { - if !crate::cli::handle_cli_on_primary_instance(app, &argv) { - let window = app.get_webview_window("main").expect("no main window"); - // The window may have been hidden via the close-to-tray path, - // which injects PAUSE_RENDERING_JS (sets `__psyHidden=true`, - // pauses CSS animations). Tray-icon restore mirrors this with - // RESUME_RENDERING_JS — second-launch restore must do the same, - // otherwise the webview comes back with rendering still paused - // and navigation looks blank (issue #497). - let _ = window.eval(RESUME_RENDERING_JS); - let _ = window.show(); - let _ = window.unminimize(); - let _ = window.set_focus(); - } - })) + .plugin(tauri_plugin_fs::init()); + #[cfg(not(debug_assertions))] + let builder = builder.plugin(tauri_plugin_single_instance::init(on_second_instance)); + + builder .setup(|app| { + #[cfg(debug_assertions)] + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_title("Psysonic (Dev)"); + } + // ── Analysis cache (SQLite) ─────────────────────────────────── { let cache = analysis_cache::AnalysisCache::init(app.handle()) @@ -386,6 +402,8 @@ pub fn run() { } // ── MPRIS2 / OS media controls via souvlaki ────────────────── + // Release only: debug builds share the D-Bus name / SMTC slot with prod. + #[cfg(not(debug_assertions))] { use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig}; @@ -474,9 +492,13 @@ pub fn run() { app.manage(MprisControls::new(maybe_controls)); } + #[cfg(debug_assertions)] + { + app.manage(MprisControls::new(None)); + } // ── Windows Taskbar Thumbnail Toolbar ──────────────────────── - #[cfg(target_os = "windows")] + #[cfg(all(target_os = "windows", not(debug_assertions)))] { use tauri::Manager; if let Some(w) = app.get_webview_window("main") { diff --git a/src-tauri/src/lib_commands/app_api/integration.rs b/src-tauri/src/lib_commands/app_api/integration.rs index 204265e8..3009ff05 100644 --- a/src-tauri/src/lib_commands/app_api/integration.rs +++ b/src-tauri/src/lib_commands/app_api/integration.rs @@ -1,3 +1,4 @@ +#[cfg(not(debug_assertions))] use tauri::Emitter; use crate::{MprisControls, ShortcutMap}; @@ -9,32 +10,42 @@ pub(crate) fn register_global_shortcut( shortcut: String, action: String, ) -> Result<(), String> { - use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; - - let mut map = shortcut_map.lock().unwrap(); - - // Idempotent: if this exact shortcut+action is already registered, skip. - // This prevents on_shortcut() from accumulating duplicate handlers when - // registerAll() is called again after a JS HMR reload or StrictMode double-effect. - if map.get(&shortcut).map(|a| a == &action).unwrap_or(false) { - return Ok(()); + // Debug builds run alongside release with shared settings — do not grab OS shortcuts. + #[cfg(debug_assertions)] + { + let _ = (app, shortcut_map, shortcut, action); + Ok(()) } - // Unregister any existing OS grab for this shortcut before re-registering. - if let Ok(s) = shortcut.parse::() { - let _ = app.global_shortcut().unregister(s); - } - map.insert(shortcut.clone(), action.clone()); - drop(map); // release lock before the blocking OS call + #[cfg(not(debug_assertions))] + { + use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; - let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?; - app.global_shortcut() - .on_shortcut(parsed, move |app, _shortcut, event| { - if event.state == ShortcutState::Pressed { - let _ = app.emit("shortcut:global-action", action.clone()); - } - }) - .map_err(|e| e.to_string()) + let mut map = shortcut_map.lock().unwrap(); + + // Idempotent: if this exact shortcut+action is already registered, skip. + // This prevents on_shortcut() from accumulating duplicate handlers when + // registerAll() is called again after a JS HMR reload or StrictMode double-effect. + if map.get(&shortcut).map(|a| a == &action).unwrap_or(false) { + return Ok(()); + } + + // Unregister any existing OS grab for this shortcut before re-registering. + if let Ok(s) = shortcut.parse::() { + let _ = app.global_shortcut().unregister(s); + } + map.insert(shortcut.clone(), action.clone()); + drop(map); // release lock before the blocking OS call + + let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?; + app.global_shortcut() + .on_shortcut(parsed, move |app, _shortcut, event| { + if event.state == ShortcutState::Pressed { + let _ = app.emit("shortcut:global-action", action.clone()); + } + }) + .map_err(|e| e.to_string()) + } } #[tauri::command] @@ -43,10 +54,19 @@ pub(crate) fn unregister_global_shortcut( shortcut_map: tauri::State, shortcut: String, ) -> Result<(), String> { - use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut}; - shortcut_map.lock().unwrap().remove(&shortcut); - let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?; - app.global_shortcut().unregister(parsed).map_err(|e| e.to_string()) + #[cfg(debug_assertions)] + { + let _ = (app, shortcut_map, shortcut); + Ok(()) + } + + #[cfg(not(debug_assertions))] + { + use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut}; + shortcut_map.lock().unwrap().remove(&shortcut); + let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?; + app.global_shortcut().unregister(parsed).map_err(|e| e.to_string()) + } } #[tauri::command] diff --git a/src-tauri/src/lib_commands/sync/tray.rs b/src-tauri/src/lib_commands/sync/tray.rs index 33fd3d57..e97a124a 100644 --- a/src-tauri/src/lib_commands/sync/tray.rs +++ b/src-tauri/src/lib_commands/sync/tray.rs @@ -10,6 +10,59 @@ use crate::tray_runtime::{ }; use super::super::ui::{PAUSE_RENDERING_JS, RESUME_RENDERING_JS}; +use tauri::image::Image; + +/// Debug builds: mirror the default app icon horizontally so the tray differs from release. +fn app_tray_icon(app: &tauri::AppHandle) -> Image<'static> { + let icon = app.default_window_icon().expect("default window icon"); + #[cfg(debug_assertions)] + { + flip_image_horizontal(icon) + } + #[cfg(not(debug_assertions))] + { + icon.clone().to_owned() + } +} + +#[cfg(debug_assertions)] +fn flip_image_horizontal(icon: &Image<'_>) -> Image<'static> { + let width = icon.width(); + let height = icon.height(); + let mut rgba = icon.rgba().to_vec(); + flip_rgba_horizontal(&mut rgba, width, height); + Image::new_owned(rgba, width, height) +} + +#[cfg(debug_assertions)] +fn flip_rgba_horizontal(rgba: &mut [u8], width: u32, height: u32) { + let w = width as usize; + let h = height as usize; + if w == 0 || h == 0 || rgba.len() < w * h * 4 { + return; + } + for y in 0..h { + let row = y * w * 4; + for x in 0..w / 2 { + let l = row + x * 4; + let r = row + (w - 1 - x) * 4; + let left = [ + rgba[l], + rgba[l + 1], + rgba[l + 2], + rgba[l + 3], + ]; + let right = [ + rgba[r], + rgba[r + 1], + rgba[r + 2], + rgba[r + 3], + ]; + rgba[l..l + 4].copy_from_slice(&right); + rgba[r..r + 4].copy_from_slice(&left); + } + } +} pub(crate) fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result { let labels = app @@ -92,7 +145,7 @@ pub(crate) fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result #[cfg(target_os = "windows")] let tray_builder = TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) + .icon(app_tray_icon(app)) .menu(&menu) .tooltip(&tooltip_with_icon) // tray-icon defaults to opening the context menu on every WM_LBUTTONUP when this is true. @@ -102,7 +155,7 @@ pub(crate) fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result .show_menu_on_left_click(false); #[cfg(not(target_os = "windows"))] let tray_builder = TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) + .icon(app_tray_icon(app)) .menu(&menu) .tooltip(&cached_tooltip); @@ -423,3 +476,22 @@ pub(crate) fn is_tiling_wm() -> bool { pub(crate) fn is_tiling_wm_cmd() -> bool { is_tiling_wm() } + +#[cfg(all(test, debug_assertions))] +mod tests { + use super::*; + + #[test] + fn flip_rgba_horizontal_mirrors_pixels() { + // 3×1: A B C → C B A + let mut rgba = vec![ + 1, 0, 0, 255, // A + 2, 0, 0, 255, // B + 3, 0, 0, 255, // C + ]; + flip_rgba_horizontal(&mut rgba, 3, 1); + assert_eq!(rgba[0], 3); + assert_eq!(rgba[4], 2); + assert_eq!(rgba[8], 1); + } +} diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 09d7c174..85738289 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -189,6 +189,9 @@ export function AppShell() { onContextMenu={e => e.preventDefault()} > {IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && } + {import.meta.env.DEV && isMobile && ( + DEV + )} {!isMobile && ( ()( setShortcut: async (action, shortcut) => { const prev = get().shortcuts[action]; - if (prev) { + if (GLOBAL_SHORTCUTS_OS_ENABLED && prev) { try { await invoke('unregister_global_shortcut', { shortcut: prev }); } catch {} } if (shortcut) { - try { - await invoke('register_global_shortcut', { shortcut, action }); + if (GLOBAL_SHORTCUTS_OS_ENABLED) { + try { + await invoke('register_global_shortcut', { shortcut, action }); + set(s => ({ shortcuts: { ...s.shortcuts, [action]: shortcut } })); + } catch (e) { + console.warn('[GlobalShortcuts] Failed to register:', shortcut, e); + } + } else { set(s => ({ shortcuts: { ...s.shortcuts, [action]: shortcut } })); - } catch (e) { - console.warn('[GlobalShortcuts] Failed to register:', shortcut, e); } } else { set(s => { @@ -62,6 +69,7 @@ export const useGlobalShortcutsStore = create()( }, registerAll: async () => { + if (!GLOBAL_SHORTCUTS_OS_ENABLED) return; if (_registerAllCalled) return; _registerAllCalled = true; const { shortcuts } = get(); @@ -79,9 +87,11 @@ export const useGlobalShortcutsStore = create()( resetAll: async () => { const { shortcuts } = get(); - for (const shortcut of Object.values(shortcuts)) { - if (shortcut) { - try { await invoke('unregister_global_shortcut', { shortcut }); } catch {} + if (GLOBAL_SHORTCUTS_OS_ENABLED) { + for (const shortcut of Object.values(shortcuts)) { + if (shortcut) { + try { await invoke('unregister_global_shortcut', { shortcut }); } catch {} + } } } set({ shortcuts: { ...DEFAULT_GLOBAL_SHORTCUTS } }); diff --git a/src/styles/layout/dev-build-chrome.css b/src/styles/layout/dev-build-chrome.css new file mode 100644 index 00000000..09d7f9a1 --- /dev/null +++ b/src/styles/layout/dev-build-chrome.css @@ -0,0 +1,63 @@ +/* Dev-only chrome: shared data dir with prod, but visually unmistakable. */ +html[data-dev-build] .sidebar-brand { + background: #b91c1c; + border-bottom-color: rgba(0, 0, 0, 0.22); +} + +html[data-dev-build] .sidebar-brand svg { + --logo-color-start: #fff; + --logo-color-end: #fecaca; +} + +html[data-dev-build] .titlebar-btn-close, +html[data-dev-build] .titlebar-btn-minimize, +html[data-dev-build] .titlebar-btn-maximize { + background: #8b8b8b; +} + +html[data-dev-build] .titlebar-btn-close:hover, +html[data-dev-build] .titlebar-btn-minimize:hover, +html[data-dev-build] .titlebar-btn-maximize:hover { + background: #a3a3a3; + box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18); +} + +html[data-dev-build] .titlebar-btn-close:hover, +html[data-dev-build] .titlebar-btn-minimize:hover, +html[data-dev-build] .titlebar-btn-maximize:hover { + filter: none; +} + +html[data-dev-build] .titlebar-btn:focus-visible { + box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18), 0 0 0 2px rgba(255, 255, 255, 0.35); +} + +/* Narrow/mobile: sidebar logo is hidden — fixed red DEV square top-left. */ +html[data-dev-build] .dev-build-badge { + display: none; +} + +html[data-dev-build] .app-shell[data-mobile] .dev-build-badge { + display: flex; + position: fixed; + top: 0; + left: 0; + z-index: 10050; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + background: #b91c1c; + color: #fff; + font-family: var(--font-ui, system-ui, sans-serif); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.05em; + line-height: 1; + pointer-events: none; + user-select: none; +} + +html[data-dev-build] .app-shell[data-mobile][data-titlebar] .dev-build-badge { + top: var(--titlebar-height); +} diff --git a/src/styles/layout/index.css b/src/styles/layout/index.css index 61303c3a..e3bb1449 100644 --- a/src/styles/layout/index.css +++ b/src/styles/layout/index.css @@ -1,4 +1,5 @@ @import './_intro.css'; +@import './dev-build-chrome.css'; @import './custom-title-bar-linux-only-decorations-false.css'; @import './resizer-handles.css'; @import './sidebar.css';