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
This commit is contained in:
H-Chris233 committed 2026-04-30 18:16:59 +08:00
1 parent 1efc20b2b2
commit 67bb14ec40
5 files changed
+186 -3

No files matched your search

@@ -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> {
+123 -2
View File
@@ -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<Option<Recorder>>,
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
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<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
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<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
}
}
async fn handle_pressed_edge(inner: &Arc<Inner>) {
let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst);
if !was_held {
handle_pressed(inner).await;
}
}
async fn handle_pressed(inner: &Arc<Inner>) {
let mode = inner.prefs.get().hotkey.mode;
let phase = inner.state.lock().phase;
@@ -284,6 +304,13 @@ async fn handle_pressed(inner: &Arc<Inner>) {
}
}
async fn handle_released_edge(inner: &Arc<Inner>) {
let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst);
if was_held {
handle_released(inner).await;
}
}
async fn handle_released(inner: &Arc<Inner>) {
let mode = inner.prefs.get().hotkey.mode;
let phase = inner.state.lock().phase;
@@ -303,6 +330,67 @@ async fn handle_released(inner: &Arc<Inner>) {
}
}
async fn handle_window_hotkey_event(
inner: &Arc<Inner>,
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<Inner>) -> Result<(), String> {
@@ -880,6 +968,7 @@ fn enabled_hotwords(inner: &Arc<Inner>) -> Vec<DictionaryHotword> {
#[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<Inner>) -> Vec<String> {
+1
View File
@@ -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,
+40 -1
View File
@@ -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 <StartupShell />;
}
@@ -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 (
<div
+9
View File
@@ -178,6 +178,15 @@ export function cancelDictation(): Promise<void> {
return invokeOrMock('cancel_dictation', undefined, () => undefined);
}
export function handleWindowHotkeyEvent(
eventType: 'keydown' | 'keyup',
key: string,
code: string,
repeat: boolean,
): Promise<void> {
return invokeOrMock('handle_window_hotkey_event', { eventType, key, code, repeat }, () => undefined);
}
// ── Polish ─────────────────────────────────────────────────────────────
export function repolish(rawText: string, mode: PolishMode): Promise<string> {
return invokeOrMock('repolish', { rawText, mode }, () => rawText);