From 01e40c1d54b15e5cff5b010a15063d4f80494762 Mon Sep 17 00:00:00 2001 From: Philip Peterson <1326208+philip-peterson@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:58:17 -0800 Subject: [PATCH] Fix cut/copy/paste shortcuts via NSEvent monitor --- desktop/src-tauri/.gitignore | 1 + desktop/src-tauri/Cargo.lock | 24 +++++ desktop/src-tauri/Cargo.toml | 3 + desktop/src-tauri/build.rs | 15 ++- desktop/src-tauri/capabilities/default.json | 12 ++- desktop/src-tauri/src/lib.rs | 111 +++++++++++++------- 6 files changed, 126 insertions(+), 40 deletions(-) diff --git a/desktop/src-tauri/.gitignore b/desktop/src-tauri/.gitignore index 07564a75..c7e61fae 100644 --- a/desktop/src-tauri/.gitignore +++ b/desktop/src-tauri/.gitignore @@ -2,3 +2,4 @@ binaries/* !binaries/.gitkeep target/ gen/ +permissions/ diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 2fd41ad0..a920e037 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2310,10 +2310,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2333,6 +2340,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -2393,6 +2401,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2877,7 +2898,10 @@ dependencies = [ name = "quintodrome" version = "0.1.0" dependencies = [ + "block2", "libc", + "objc2", + "objc2-app-kit", "objc2-web-kit", "serde_json", "tauri", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 510e35b1..2889e86a 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -24,7 +24,10 @@ thiserror = "1" libc = "0.2" [target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSEvent"] } objc2-web-kit = { version = "0.3", features = ["WKWebView"] } +block2 = "0.6" [features] # This feature is used for production builds or when `devPath` points to the diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index d860e1e6..f9662d5d 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,3 +1,16 @@ +const APP_COMMANDS: &[&str] = &[ + "back", + "forward", + "reload", + "navigate", + "read_clipboard", + "write_clipboard", +]; + fn main() { - tauri_build::build() + tauri_build::try_build( + tauri_build::Attributes::new() + .app_manifest(tauri_build::AppManifest::new().commands(APP_COMMANDS)), + ) + .expect("failed to run tauri-build"); } diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index c7932927..919db71e 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -3,7 +3,17 @@ "identifier": "default", "description": "Default capability for the Quintodrome desktop window", "windows": ["main"], - "permissions": ["core:default", "core:event:default", "shell:allow-open"], + "permissions": [ + "core:default", + "core:event:default", + "shell:allow-open", + "allow-back", + "allow-forward", + "allow-reload", + "allow-navigate", + "allow-read-clipboard", + "allow-write-clipboard" + ], "remote": { "urls": ["http://127.0.0.1:4533", "http://localhost:4533"] } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 85e68cdd..4ed891df 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -11,35 +11,6 @@ use tauri_plugin_opener::OpenerExt; const TOOLBAR_HEIGHT: f64 = 60.0; const DEFAULT_PUBLIC_URL: &str = "http://quintodrome"; -/// Injected into the content webview to make Cmd+C/X/V/A/Z work. WKWebView -/// doesn't reliably route these through the app menu's key equivalents, so we -/// handle them at the DOM level (and read the clipboard natively for paste). -const EDIT_SHORTCUTS_SCRIPT: &str = r#" -(function () { - if (window.__quintodromeShortcuts) return; - window.__quintodromeShortcuts = true; - document.addEventListener('keydown', function (e) { - if (!e.metaKey || e.ctrlKey || e.altKey) return; - var k = (e.key || '').toLowerCase(); - var cmd = null; - if (k === 'x') cmd = 'cut'; - else if (k === 'c') cmd = 'copy'; - else if (k === 'a') cmd = 'selectAll'; - else if (k === 'z') cmd = e.shiftKey ? 'redo' : 'undo'; - else if (k === 'v') { - e.preventDefault(); - window.__TAURI_INTERNALS__.invoke('read_clipboard').then(function (text) { - if (text) document.execCommand('insertText', false, text); - }).catch(function () {}); - return; - } - if (!cmd) return; - e.preventDefault(); - try { document.execCommand(cmd); } catch (err) {} - }, true); -})(); -"#; - /// Maps the internal Navidrome origin (e.g. `http://127.0.0.1:4533`) to a /// friendlier, user-facing origin shown in the address bar. #[derive(Clone)] @@ -56,14 +27,12 @@ impl UrlMask { Self { real, public } } - /// `http://127.0.0.1:4533/...` -> `http://quintodrome/...` fn mask(&self, url: &str) -> String { url.strip_prefix(&self.real) .map(|rest| format!("{}{}", self.public, rest)) .unwrap_or_else(|| url.to_string()) } - /// `http://quintodrome/...` -> `http://127.0.0.1:4533/...` fn unmask(&self, url: &str) -> String { url.strip_prefix(&self.public) .map(|rest| format!("{}{}", self.real, rest)) @@ -124,10 +93,6 @@ fn enable_swipe_navigation(app: &tauri::AppHandle) { } /// Keeps the address bar in sync with the content webview's real URL. -/// -/// `on_navigation` only fires for actual document loads, so history traversal -/// (back/forward) and SPA `pushState` navigation would otherwise leave the -/// address bar stale. Polling `Webview::url()` covers all of those. fn start_url_poller(app: &tauri::AppHandle, mask: UrlMask) { let handle = app.clone(); std::thread::spawn(move || { @@ -181,14 +146,82 @@ fn layout(app: &tauri::AppHandle) { } } +/// Installs a local NSEvent monitor that intercepts Cmd+X/C/V/A/Z and +/// dispatches the matching native editing selector to the first responder. +/// +/// Menu key equivalents don't reach the webviews (content or devtools) in +/// Tauri on macOS, so we intercept the key events at the app level instead — +/// this is the only reliable way to make cut/copy/paste work in the devtools +/// console. +#[cfg(target_os = "macos")] +fn install_edit_shortcut_monitor() { + use objc2::runtime::{AnyObject, Sel}; + use objc2::MainThreadMarker; + use objc2_app_kit::{NSApplication, NSEvent, NSEventMask, NSEventModifierFlags}; + use std::ptr::NonNull; + + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let app = NSApplication::sharedApplication(mtm); + + let block = block2::RcBlock::new(move |event: NonNull| -> *mut NSEvent { + let event = unsafe { event.as_ref() }; + let flags = event.modifierFlags(); + if !flags.contains(NSEventModifierFlags::Command) { + return event as *const NSEvent as *mut NSEvent; + } + + let key = event + .charactersIgnoringModifiers() + .map(|s| s.to_string().to_lowercase()) + .unwrap_or_default(); + + let selector: Option<&'static std::ffi::CStr> = match key.as_str() { + "x" => Some(c"cut:"), + "c" => Some(c"copy:"), + "v" => Some(c"paste:"), + "a" => Some(c"selectAll:"), + "z" => { + if flags.contains(NSEventModifierFlags::Shift) { + Some(c"redo:") + } else { + Some(c"undo:") + } + } + _ => None, + }; + + let Some(selector) = selector else { + return event as *const NSEvent as *mut NSEvent; + }; + + unsafe { + let _: bool = app.sendAction_to_from( + Sel::register(selector), + None::<&AnyObject>, + None::<&AnyObject>, + ); + } + std::ptr::null_mut() + }); + + unsafe { + let monitor = + NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask::KeyDown, &block); + if let Some(monitor) = monitor { + std::mem::forget(monitor); + } + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_clipboard_manager::init()) - // The default menu wires up the Edit items (undo/cut/copy/paste/select - // all), so Cmd+X/C/V/A/Z work in the webview's text fields. + // Native menu so Edit > Cut/Copy/Paste work via clicks. .menu(|handle| tauri::menu::Menu::default(handle)) .manage(ServerState::default()) .invoke_handler(tauri::generate_handler![ @@ -218,7 +251,6 @@ pub fn run() { // The primary webview ("main") is the toolbar; the Navidrome content // lives in a child webview below it. let content = WebviewBuilder::new("content", WebviewUrl::App("index.html".into())) - .initialization_script(EDIT_SHORTCUTS_SCRIPT) .on_new_window({ let handle = handle.clone(); move |url, _features| { @@ -250,6 +282,9 @@ pub fn run() { #[cfg(target_os = "macos")] enable_swipe_navigation(app.handle()); + #[cfg(target_os = "macos")] + install_edit_shortcut_monitor(); + if let Some(toolbar) = app.get_webview("main") { let _ = toolbar.emit("url-changed", mask.mask(&navidrome_url)); }