diff --git a/tauri/src-tauri/src/keyboard_layout.rs b/tauri/src-tauri/src/keyboard_layout.rs new file mode 100644 index 00000000..cb13ffbb --- /dev/null +++ b/tauri/src-tauri/src/keyboard_layout.rs @@ -0,0 +1,183 @@ +//! Layout-aware resolution of the keycode whose current-layout translation +//! is `'v'`. Drives [`crate::synthetic_keys::send_paste`] so the synthetic +//! Cmd+V it posts is interpreted as Paste by the focused app regardless of +//! the user's active keyboard layout (Dvorak, Colemak, AZERTY, …). +//! +//! macOS apps process Cmd+V via NSMenu key equivalents, which match against +//! `[NSEvent charactersIgnoringModifiers]` — i.e. the layout-translated +//! character, not the raw keycode. Posting `kVK_ANSI_V` (= 9, the QWERTY V +//! position) on Dvorak therefore produces Cmd+. and never triggers Paste. +//! +//! All TIS calls happen on the main thread: once at startup via [`init`] +//! from Tauri's setup hook, and again from the +//! `kTISNotifySelectedKeyboardInputSourceChanged` distributed notification +//! (delivered to the main runloop). The hot path ([`paste_keycode_v`]) +//! only reads an [`AtomicU16`], so paste latency is unchanged. +//! +//! Windows is intentionally not covered here. `SendInput` with +//! `wVk = VK_V` delivers `WM_KEYDOWN` to the target with `wParam = VK_V` +//! regardless of the active layout — most Windows apps treat that as +//! Ctrl+V. AutoHotkey relies on the same behaviour. + +use std::sync::atomic::{AtomicU16, Ordering}; + +/// `kVK_ANSI_V` — the keycode for the physical V key on a US QWERTY +/// layout. Used as the fallback whenever live resolution can't produce a +/// better answer (no Unicode key layout data, lookup failure, non-macOS). +const FALLBACK_V_KEYCODE: u16 = 9; + +static V_KEYCODE: AtomicU16 = AtomicU16::new(FALLBACK_V_KEYCODE); + +/// Returns the keycode whose current-layout translation is `'v'`. Falls +/// back to `kVK_ANSI_V` when resolution hasn't run, the active input +/// source carries no Unicode key layout data, or no keycode in the layout +/// produces `v`. +pub fn paste_keycode_v() -> u16 { + V_KEYCODE.load(Ordering::Relaxed) +} + +#[cfg(target_os = "macos")] +pub fn init() { + macos::init(); +} + +#[cfg(not(target_os = "macos"))] +pub fn init() {} + +#[cfg(target_os = "macos")] +mod macos { + use super::{FALLBACK_V_KEYCODE, V_KEYCODE}; + use core_foundation_sys::base::CFRelease; + use core_foundation_sys::data::{CFDataGetBytePtr, CFDataRef}; + use core_foundation_sys::dictionary::CFDictionaryRef; + use core_foundation_sys::notification_center::{ + CFNotificationCenterAddObserver, CFNotificationCenterGetDistributedCenter, + CFNotificationCenterRef, CFNotificationName, + CFNotificationSuspensionBehaviorDeliverImmediately, + }; + use core_foundation_sys::string::CFStringRef; + use std::ffi::c_void; + use std::ptr; + use std::sync::atomic::Ordering; + + type TISInputSourceRef = *mut c_void; + + /// `kUCKeyActionDown`. + const K_UC_KEY_ACTION_DOWN: u16 = 0; + /// `kUCKeyTranslateNoDeadKeysMask` — collapse dead-key state machine so + /// a single call gives us the bare character. V is never a dead key on + /// any layout we care about, but the flag costs nothing and removes + /// any chance of ambiguous output. + const K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_MASK: u32 = 1; + /// Standard US-style virtual keycodes occupy 0..0x7F. We iterate the + /// full range so non-US-extended layouts (ISO, JIS) can still be + /// resolved if their `v` lives outside the ANSI range. + const MAX_KEYCODE: u16 = 127; + const TARGET_CHAR: u16 = b'v' as u16; + + #[link(name = "Carbon", kind = "framework")] + extern "C" { + fn TISCopyCurrentKeyboardLayoutInputSource() -> TISInputSourceRef; + fn TISGetInputSourceProperty( + source: TISInputSourceRef, + key: CFStringRef, + ) -> *mut c_void; + fn LMGetKbdType() -> u8; + fn UCKeyTranslate( + keyboard_layout: *const u8, + virtual_key_code: u16, + key_action: u16, + modifier_key_state: u32, + keyboard_type: u32, + key_translate_options: u32, + dead_key_state: *mut u32, + max_string_length: usize, + actual_string_length: *mut usize, + unicode_string: *mut u16, + ) -> i32; + + static kTISPropertyUnicodeKeyLayoutData: CFStringRef; + static kTISNotifySelectedKeyboardInputSourceChanged: CFStringRef; + } + + pub fn init() { + resolve_into_cache(); + register_layout_change_observer(); + } + + fn resolve_into_cache() { + let kc = resolve_v_keycode().unwrap_or(FALLBACK_V_KEYCODE); + V_KEYCODE.store(kc, Ordering::Relaxed); + } + + fn resolve_v_keycode() -> Option { + unsafe { + let source = TISCopyCurrentKeyboardLayoutInputSource(); + if source.is_null() { + return None; + } + let _src_guard = scopeguard::guard(source, |s| CFRelease(s as *const c_void)); + + let layout_data_ptr = + TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData); + if layout_data_ptr.is_null() { + return None; + } + let layout_bytes = CFDataGetBytePtr(layout_data_ptr as CFDataRef); + if layout_bytes.is_null() { + return None; + } + + let kbd_type = LMGetKbdType() as u32; + + for keycode in 0..=MAX_KEYCODE { + let mut dead_key_state: u32 = 0; + let mut chars: [u16; 4] = [0; 4]; + let mut actual_len: usize = 0; + let status = UCKeyTranslate( + layout_bytes, + keycode, + K_UC_KEY_ACTION_DOWN, + 0, // no modifiers + kbd_type, + K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_MASK, + &mut dead_key_state, + chars.len(), + &mut actual_len, + chars.as_mut_ptr(), + ); + if status == 0 && actual_len == 1 && chars[0] == TARGET_CHAR { + return Some(keycode); + } + } + None + } + } + + extern "C" fn layout_changed( + _center: CFNotificationCenterRef, + _observer: *mut c_void, + _name: CFNotificationName, + _object: *const c_void, + _user_info: CFDictionaryRef, + ) { + resolve_into_cache(); + } + + fn register_layout_change_observer() { + unsafe { + let center = CFNotificationCenterGetDistributedCenter(); + if center.is_null() { + return; + } + CFNotificationCenterAddObserver( + center, + ptr::null(), + layout_changed, + kTISNotifySelectedKeyboardInputSourceChanged, + ptr::null(), + CFNotificationSuspensionBehaviorDeliverImmediately, + ); + } + } +} diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index 9423f65a..50855ab0 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -11,6 +11,7 @@ mod hotkey_monitor; mod input_monitoring; #[cfg(desktop)] mod key_codes; +mod keyboard_layout; mod speak_monitor; mod synthetic_keys; @@ -1220,6 +1221,14 @@ pub fn run() { app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; app.handle().plugin(tauri_plugin_process::init())?; + // Resolve the active keyboard layout's V keycode now, on + // the main thread, and register an observer for layout + // changes. The synthetic-paste hot path then only reads an + // atomic. See keyboard_layout.rs for why this matters + // (Cmd+V is matched by translated character, not keycode, + // so QWERTY keycode 9 produces Cmd+. on Dvorak). + keyboard_layout::init(); + // HotkeyMonitor is spawned lazily via the `enable_hotkey` // command — see HotkeyState. The dictate pill webview is // built in the same lazy path so we don't pay setup cost diff --git a/tauri/src-tauri/src/synthetic_keys.rs b/tauri/src-tauri/src/synthetic_keys.rs index f65ea30d..9c9fbe97 100644 --- a/tauri/src-tauri/src/synthetic_keys.rs +++ b/tauri/src-tauri/src/synthetic_keys.rs @@ -13,12 +13,17 @@ //! windows when we run non-elevated — nothing we can do short of also //! running elevated. //! -//! The virtual keycode used for V is `kVK_ANSI_V` (9) on macOS and `VK_V` -//! (0x56) on Windows. Both are layout-dependent — they mean "the physical -//! key in the QWERTY V position" — so on Dvorak / Colemak this would fire -//! the wrong shortcut. A later pass will resolve the current layout's V -//! keycode per-platform (`TISCopyCurrentKeyboardInputSource` + -//! `UCKeyTranslate` on macOS; `VkKeyScanExW` on Windows). +//! On macOS the V keycode is resolved per-layout by +//! [`crate::keyboard_layout`] — Cmd+V is matched against the layout- +//! translated character via NSMenu key equivalents, so hardcoding +//! `kVK_ANSI_V` (the QWERTY V position) would fire Cmd+. on Dvorak. The +//! resolved keycode is read once per paste from an atomic; the cache is +//! primed at startup and refreshed on layout change. +//! +//! Windows hardcodes `VK_V`. `SendInput` with `wVk = VK_V` makes the +//! target receive `WM_KEYDOWN` with `wParam = VK_V` regardless of the +//! active layout, and most Windows apps treat that as Ctrl+V (the same +//! reason `Send "^v"` works in AutoHotkey on Dvorak Windows). #[cfg(target_os = "macos")] use std::ffi::c_void; @@ -56,8 +61,6 @@ mod ffi { /// `kCGEventFlagMaskCommand` — the Cmd modifier bit inside `CGEventFlags`. pub const K_CG_EVENT_FLAG_MASK_COMMAND: CGEventFlags = 0x00100000; - /// `kVK_ANSI_V`. - pub const KEYCODE_V: CGKeyCode = 9; /// `kVK_Command` (left Cmd). pub const KEYCODE_LEFT_CMD: CGKeyCode = 0x37; @@ -88,6 +91,8 @@ mod ffi { pub fn send_paste() -> Result<(), String> { use ffi::*; + let v_keycode = crate::keyboard_layout::paste_keycode_v(); + unsafe { let source = CGEventSourceCreate(K_CG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE); if source.is_null() { @@ -97,8 +102,8 @@ pub fn send_paste() -> Result<(), String> { let events = [ (KEYCODE_LEFT_CMD, true, 0), - (KEYCODE_V, true, K_CG_EVENT_FLAG_MASK_COMMAND), - (KEYCODE_V, false, K_CG_EVENT_FLAG_MASK_COMMAND), + (v_keycode, true, K_CG_EVENT_FLAG_MASK_COMMAND), + (v_keycode, false, K_CG_EVENT_FLAG_MASK_COMMAND), (KEYCODE_LEFT_CMD, false, 0), ];