From 67bb14ec40d0a4607c274d52f1520403aca4ebe2 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Thu, 30 Apr 2026 18:16:59 +0800 Subject: [PATCH] Restore foreground Windows hotkey edges Windows can deliver modifier-only key events to the focused WebView without the low-level hook seeing the same edge, so the main window now forwards only hotkey-relevant DOM key events to the coordinator. Both native hook and window-forwarded events share the same coordinator edge gate so a working hook does not double-trigger toggle sessions. Constraint: Issue #85 requires OpenLess foreground Right Control to reuse existing pressed/released/cancel semantics without changing global hook behavior Rejected: Start dictation directly in the frontend | would duplicate coordinator state-machine semantics Rejected: Keep a separate window-held flag | review found it can double-trigger toggle mode when both DOM and low-level hook report the same physical press Confidence: medium Scope-risk: moderate Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml window_key_matcher_mirrors_windows_trigger_aliases Tested: npm run build Tested: git diff --check Not-tested: Physical Windows foreground Right Control on this Linux host --- openless-all/app/src-tauri/src/commands.rs | 13 ++ openless-all/app/src-tauri/src/coordinator.rs | 125 +++++++++++++++++- openless-all/app/src-tauri/src/lib.rs | 1 + openless-all/app/src/App.tsx | 41 +++++- openless-all/app/src/lib/ipc.ts | 9 ++ 5 files changed, 186 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 81511a6e..47b93162 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -163,6 +163,19 @@ pub fn cancel_dictation(coord: CoordinatorState<'_>) { coord.cancel_dictation(); } +#[tauri::command] +pub async fn handle_window_hotkey_event( + coord: CoordinatorState<'_>, + event_type: String, + key: String, + code: String, + repeat: bool, +) -> Result<(), String> { + coord + .handle_window_hotkey_event(event_type, key, code, repeat) + .await +} + #[cfg(debug_assertions)] #[tauri::command] pub async fn inject_hotkey_click_for_dev(coord: CoordinatorState<'_>) -> Result<(), String> { diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c2d8c9b2..45a386c0 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -5,6 +5,7 @@ //! insertion, persists history, emits `capsule:state` events to the capsule //! window. +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::Arc; use std::time::Instant; @@ -86,6 +87,7 @@ struct Inner { recorder: Mutex>, hotkey: Mutex>, hotkey_status: Mutex, + hotkey_trigger_held: AtomicBool, } impl Coordinator { @@ -109,6 +111,7 @@ impl Coordinator { recorder: Mutex::new(None), hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), + hotkey_trigger_held: AtomicBool::new(false), }), } } @@ -167,6 +170,16 @@ impl Coordinator { cancel_session(&self.inner); } + pub async fn handle_window_hotkey_event( + &self, + event_type: String, + key: String, + code: String, + repeat: bool, + ) -> Result<(), String> { + handle_window_hotkey_event(&self.inner, event_type, key, code, repeat).await + } + #[cfg(any(debug_assertions, test))] pub async fn inject_hotkey_click_for_dev(&self) -> Result<(), String> { log::info!("[coord] dev hotkey injection started"); @@ -248,10 +261,10 @@ fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { let inner_cloned = Arc::clone(&inner); match evt { HotkeyEvent::Pressed => { - async_runtime::spawn(async move { handle_pressed(&inner_cloned).await }); + async_runtime::spawn(async move { handle_pressed_edge(&inner_cloned).await }); } HotkeyEvent::Released => { - async_runtime::spawn(async move { handle_released(&inner_cloned).await }); + async_runtime::spawn(async move { handle_released_edge(&inner_cloned).await }); } HotkeyEvent::Cancelled => { cancel_session(&inner_cloned); @@ -260,6 +273,13 @@ fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { } } +async fn handle_pressed_edge(inner: &Arc) { + let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); + if !was_held { + handle_pressed(inner).await; + } +} + async fn handle_pressed(inner: &Arc) { let mode = inner.prefs.get().hotkey.mode; let phase = inner.state.lock().phase; @@ -284,6 +304,13 @@ async fn handle_pressed(inner: &Arc) { } } +async fn handle_released_edge(inner: &Arc) { + let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst); + if was_held { + handle_released(inner).await; + } +} + async fn handle_released(inner: &Arc) { let mode = inner.prefs.get().hotkey.mode; let phase = inner.state.lock().phase; @@ -303,6 +330,67 @@ async fn handle_released(inner: &Arc) { } } +async fn handle_window_hotkey_event( + inner: &Arc, + event_type: String, + key: String, + code: String, + repeat: bool, +) -> Result<(), String> { + if event_type == "keydown" && key == "Escape" { + cancel_session(inner); + return Ok(()); + } + + #[cfg(not(target_os = "windows"))] + { + let _ = (inner, event_type, key, code, repeat); + return Ok(()); + } + + #[cfg(target_os = "windows")] + { + let trigger = inner.prefs.get().hotkey.trigger; + if !window_key_matches_trigger(trigger, &key, &code) { + return Ok(()); + } + + match event_type.as_str() { + "keydown" => { + if repeat { + return Ok(()); + } + log::info!( + "[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}" + ); + handle_pressed_edge(inner).await; + } + "keyup" => { + log::info!("[window-hotkey] released trigger={trigger:?} code={code}"); + handle_released_edge(inner).await; + } + _ => {} + } + Ok(()) + } +} + +#[cfg(any(target_os = "windows", test))] +fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, code: &str) -> bool { + use crate::types::HotkeyTrigger; + + match trigger { + HotkeyTrigger::RightControl => key == "Control" && code == "ControlRight", + HotkeyTrigger::LeftControl => key == "Control" && code == "ControlLeft", + HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => { + (key == "Alt" || key == "AltGraph") && code == "AltRight" + } + HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltRight", + HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight", + HotkeyTrigger::Fn => key == "Control" && code == "ControlRight", + } +} + // ─────────────────────────── session lifecycle ─────────────────────────── async fn begin_session(inner: &Arc) -> Result<(), String> { @@ -880,6 +968,7 @@ fn enabled_hotwords(inner: &Arc) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::types::HotkeyTrigger; #[tokio::test] async fn hotkey_injection_gate_logs_pressed_and_cancels() { @@ -895,6 +984,38 @@ mod tests { assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); } + + #[test] + fn window_key_matcher_mirrors_windows_trigger_aliases() { + let cases = [ + (HotkeyTrigger::RightControl, "Control", "ControlRight"), + (HotkeyTrigger::LeftControl, "Control", "ControlLeft"), + (HotkeyTrigger::RightOption, "Alt", "AltRight"), + (HotkeyTrigger::RightAlt, "AltGraph", "AltRight"), + (HotkeyTrigger::RightCommand, "Meta", "MetaRight"), + // Mirrors Windows trigger_to_vk_code aliases. + (HotkeyTrigger::LeftOption, "Alt", "AltRight"), + (HotkeyTrigger::Fn, "Control", "ControlRight"), + ]; + for (trigger, key, code) in cases { + assert!( + window_key_matches_trigger(trigger, key, code), + "{trigger:?} should match {key}/{code}" + ); + } + + assert!(!window_key_matches_trigger( + HotkeyTrigger::RightControl, + "Control", + "ControlLeft" + )); + assert!(!window_key_matches_trigger( + HotkeyTrigger::LeftOption, + "Alt", + "AltLeft" + )); + assert!(!window_key_matches_trigger(HotkeyTrigger::Fn, "Fn", "Fn")); + } } fn enabled_phrases(inner: &Arc) -> Vec { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 01b33e2b..4e93e444 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -152,6 +152,7 @@ pub fn run() { commands::start_dictation, commands::stop_dictation, commands::cancel_dictation, + commands::handle_window_hotkey_event, #[cfg(debug_assertions)] commands::inject_hotkey_click_for_dev, commands::repolish, diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index f0a22e59..a03507cb 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -3,7 +3,12 @@ import { Capsule } from './components/Capsule'; import { FloatingShell } from './components/FloatingShell'; import { Onboarding } from './components/Onboarding'; import { detectOS } from './components/WindowChrome'; -import { checkAccessibilityPermission, checkMicrophonePermission, isTauri } from './lib/ipc'; +import { + checkAccessibilityPermission, + checkMicrophonePermission, + handleWindowHotkeyEvent, + isTauri, +} from './lib/ipc'; import { HotkeySettingsProvider } from './state/HotkeySettingsContext'; interface AppProps { @@ -53,6 +58,28 @@ export function App({ isCapsule }: AppProps) { }; }, [os]); + useEffect(() => { + if (!isTauri || os !== 'win') return; + const forwardKey = (event: KeyboardEvent) => { + if (!isWindowHotkeyCandidate(event)) return; + console.debug( + `[ui-key] type=${event.type} key=${event.key} code=${event.code} repeat=${event.repeat}`, + ); + void handleWindowHotkeyEvent( + event.type as 'keydown' | 'keyup', + event.key, + event.code, + event.repeat, + ).catch(error => console.warn('[window-hotkey] forward failed', error)); + }; + window.addEventListener('keydown', forwardKey, true); + window.addEventListener('keyup', forwardKey, true); + return () => { + window.removeEventListener('keydown', forwardKey, true); + window.removeEventListener('keyup', forwardKey, true); + }; + }, [os]); + if (gate === 'checking') { return ; } @@ -63,6 +90,18 @@ export function App({ isCapsule }: AppProps) { ); } +function isWindowHotkeyCandidate(event: KeyboardEvent): boolean { + return ( + event.key === 'Escape' || + event.code === 'ControlRight' || + event.code === 'ControlLeft' || + event.code === 'AltRight' || + event.code === 'AltLeft' || + event.code === 'MetaRight' || + event.code === 'Fn' + ); +} + function StartupShell() { return (
{ return invokeOrMock('cancel_dictation', undefined, () => undefined); } +export function handleWindowHotkeyEvent( + eventType: 'keydown' | 'keyup', + key: string, + code: string, + repeat: boolean, +): Promise { + return invokeOrMock('handle_window_hotkey_event', { eventType, key, code, repeat }, () => undefined); +} + // ── Polish ───────────────────────────────────────────────────────────── export function repolish(rawText: string, mode: PolishMode): Promise { return invokeOrMock('repolish', { rawText, mode }, () => rawText);