Fix cut/copy/paste shortcuts via NSEvent monitor
This commit is contained in:
parent
5aff975993
commit
01e40c1d54
6 changed files with 126 additions and 40 deletions
1
desktop/src-tauri/.gitignore
vendored
1
desktop/src-tauri/.gitignore
vendored
|
|
@ -2,3 +2,4 @@ binaries/*
|
||||||
!binaries/.gitkeep
|
!binaries/.gitkeep
|
||||||
target/
|
target/
|
||||||
gen/
|
gen/
|
||||||
|
permissions/
|
||||||
|
|
|
||||||
24
desktop/src-tauri/Cargo.lock
generated
24
desktop/src-tauri/Cargo.lock
generated
|
|
@ -2310,10 +2310,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
|
"objc2-cloud-kit",
|
||||||
|
"objc2-core-data",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
|
"objc2-core-image",
|
||||||
|
"objc2-core-text",
|
||||||
|
"objc2-core-video",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
|
"objc2-quartz-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2333,6 +2340,7 @@ version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
|
|
@ -2393,6 +2401,19 @@ dependencies = [
|
||||||
"objc2-core-graphics",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-encode"
|
name = "objc2-encode"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
|
|
@ -2877,7 +2898,10 @@ dependencies = [
|
||||||
name = "quintodrome"
|
name = "quintodrome"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"block2",
|
||||||
"libc",
|
"libc",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,10 @@ thiserror = "1"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[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"] }
|
objc2-web-kit = { version = "0.3", features = ["WKWebView"] }
|
||||||
|
block2 = "0.6"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# This feature is used for production builds or when `devPath` points to the
|
# This feature is used for production builds or when `devPath` points to the
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,16 @@
|
||||||
|
const APP_COMMANDS: &[&str] = &[
|
||||||
|
"back",
|
||||||
|
"forward",
|
||||||
|
"reload",
|
||||||
|
"navigate",
|
||||||
|
"read_clipboard",
|
||||||
|
"write_clipboard",
|
||||||
|
];
|
||||||
|
|
||||||
fn main() {
|
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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,17 @@
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default capability for the Quintodrome desktop window",
|
"description": "Default capability for the Quintodrome desktop window",
|
||||||
"windows": ["main"],
|
"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": {
|
"remote": {
|
||||||
"urls": ["http://127.0.0.1:4533", "http://localhost:4533"]
|
"urls": ["http://127.0.0.1:4533", "http://localhost:4533"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,35 +11,6 @@ use tauri_plugin_opener::OpenerExt;
|
||||||
const TOOLBAR_HEIGHT: f64 = 60.0;
|
const TOOLBAR_HEIGHT: f64 = 60.0;
|
||||||
const DEFAULT_PUBLIC_URL: &str = "http://quintodrome";
|
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
|
/// Maps the internal Navidrome origin (e.g. `http://127.0.0.1:4533`) to a
|
||||||
/// friendlier, user-facing origin shown in the address bar.
|
/// friendlier, user-facing origin shown in the address bar.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -56,14 +27,12 @@ impl UrlMask {
|
||||||
Self { real, public }
|
Self { real, public }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `http://127.0.0.1:4533/...` -> `http://quintodrome/...`
|
|
||||||
fn mask(&self, url: &str) -> String {
|
fn mask(&self, url: &str) -> String {
|
||||||
url.strip_prefix(&self.real)
|
url.strip_prefix(&self.real)
|
||||||
.map(|rest| format!("{}{}", self.public, rest))
|
.map(|rest| format!("{}{}", self.public, rest))
|
||||||
.unwrap_or_else(|| url.to_string())
|
.unwrap_or_else(|| url.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `http://quintodrome/...` -> `http://127.0.0.1:4533/...`
|
|
||||||
fn unmask(&self, url: &str) -> String {
|
fn unmask(&self, url: &str) -> String {
|
||||||
url.strip_prefix(&self.public)
|
url.strip_prefix(&self.public)
|
||||||
.map(|rest| format!("{}{}", self.real, rest))
|
.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.
|
/// 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) {
|
fn start_url_poller(app: &tauri::AppHandle, mask: UrlMask) {
|
||||||
let handle = app.clone();
|
let handle = app.clone();
|
||||||
std::thread::spawn(move || {
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
// The default menu wires up the Edit items (undo/cut/copy/paste/select
|
// Native menu so Edit > Cut/Copy/Paste work via clicks.
|
||||||
// all), so Cmd+X/C/V/A/Z work in the webview's text fields.
|
|
||||||
.menu(|handle| tauri::menu::Menu::default(handle))
|
.menu(|handle| tauri::menu::Menu::default(handle))
|
||||||
.manage(ServerState::default())
|
.manage(ServerState::default())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
|
@ -218,7 +251,6 @@ pub fn run() {
|
||||||
// The primary webview ("main") is the toolbar; the Navidrome content
|
// The primary webview ("main") is the toolbar; the Navidrome content
|
||||||
// lives in a child webview below it.
|
// lives in a child webview below it.
|
||||||
let content = WebviewBuilder::new("content", WebviewUrl::App("index.html".into()))
|
let content = WebviewBuilder::new("content", WebviewUrl::App("index.html".into()))
|
||||||
.initialization_script(EDIT_SHORTCUTS_SCRIPT)
|
|
||||||
.on_new_window({
|
.on_new_window({
|
||||||
let handle = handle.clone();
|
let handle = handle.clone();
|
||||||
move |url, _features| {
|
move |url, _features| {
|
||||||
|
|
@ -250,6 +282,9 @@ pub fn run() {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
enable_swipe_navigation(app.handle());
|
enable_swipe_navigation(app.handle());
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
install_edit_shortcut_monitor();
|
||||||
|
|
||||||
if let Some(toolbar) = app.get_webview("main") {
|
if let Some(toolbar) = app.get_webview("main") {
|
||||||
let _ = toolbar.emit("url-changed", mask.mask(&navidrome_url));
|
let _ = toolbar.emit("url-changed", mask.mask(&navidrome_url));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue