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:
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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user