Fix cut/copy/paste shortcuts via NSEvent monitor

This commit is contained in:
Philip Peterson 2026-08-25 04:58:17 -08:00
parent 5aff975993
commit 01e40c1d54
6 changed files with 126 additions and 40 deletions

View file

@ -2,3 +2,4 @@ binaries/*
!binaries/.gitkeep
target/
gen/
permissions/

View file

@ -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",

View file

@ -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

View file

@ -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");
}

View file

@ -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"]
}

View file

@ -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<NSEvent>| -> *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));
}