From 820f71c4216df8340ccb373a9812792adfe54612 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Sun, 24 May 2026 23:39:57 +0300 Subject: [PATCH] feat(dev): run dev alongside release with shared app data (#866) * feat(dev): run dev alongside release with shared app data Skip tauri-plugin-single-instance in debug builds so `tauri dev` can run while an installed release instance is open. Keep the same bundle identifier and data directory; label the dev window "Psysonic (Dev)". * fix(dev): gate on_second_instance behind release cfg Avoid dead_code warning in debug builds where single-instance is skipped. * feat(dev): red sidebar brand and monochrome titlebar chrome Tag the document in Vite dev and style the logo header with a red background plus gray window controls so dev is obvious at a glance. * feat(dev): skip OS hotkeys and add mobile DEV markers Debug builds no longer register global shortcuts, MPRIS, or Windows taskbar media controls so release keeps system input when both run. Flip the tray icon horizontally in dev and show a fixed DEV badge on narrow layouts. * docs(changelog): note PR #866 parallel dev alongside release * fix(dev): satisfy clippy needless_return in debug-only paths --- CHANGELOG.md | 10 +++ src-tauri/src/lib.rs | 58 +++++++++----- .../src/lib_commands/app_api/integration.rs | 74 +++++++++++------- src-tauri/src/lib_commands/sync/tray.rs | 76 ++++++++++++++++++- src/app/AppShell.tsx | 3 + src/app/bootstrap.ts | 8 ++ src/store/globalShortcutsStore.ts | 26 +++++-- src/styles/layout/dev-build-chrome.css | 63 +++++++++++++++ src/styles/layout/index.css | 1 + 9 files changed, 264 insertions(+), 55 deletions(-) create mode 100644 src/styles/layout/dev-build-chrome.css 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';