diff --git a/openless-all/README.md b/openless-all/README.md index 6bfd160c..23851ad8 100644 --- a/openless-all/README.md +++ b/openless-all/README.md @@ -89,6 +89,18 @@ Generated GNU artifacts: - `%TEMP%\openless-windows-gnu\src-tauri\target\x86_64-pc-windows-gnu\release\bundle\msi\OpenLess_*_x64_en-US.msi` - `%TEMP%\openless-windows-gnu\src-tauri\target\x86_64-pc-windows-gnu\release\bundle\nsis\OpenLess_*_x64-setup.exe` +### Hotkey Injection Gate + +Use this gate before/after Windows hotkey changes when a physical keyboard +regression is unavailable. It injects a dev/test-only hotkey click through the +coordinator `handle_pressed` / `handle_released` path, asserts the log contains +`[coord] hotkey pressed`, and cancels the dry-run session automatically. + +```powershell +cd app +npm run check:hotkey-injection +``` + ### Windows Runtime Notes - Windows does not need the macOS Accessibility permission. Use Settings -> diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 4d107ce3..c360e05a 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "check:hotkey-injection": "node scripts/check-hotkey-injection.mjs" }, "dependencies": { "@tauri-apps/api": "^2.1.1", diff --git a/openless-all/app/scripts/check-hotkey-injection.mjs b/openless-all/app/scripts/check-hotkey-injection.mjs new file mode 100644 index 00000000..430c40db --- /dev/null +++ b/openless-all/app/scripts/check-hotkey-injection.mjs @@ -0,0 +1,25 @@ +import { spawnSync } from 'node:child_process'; + +const result = spawnSync( + 'cargo', + ['test', '--manifest-path', 'src-tauri/Cargo.toml', 'hotkey_injection_gate_logs_pressed_and_cancels', '--', '--nocapture'], + { + env: { ...process.env, OPENLESS_HOTKEY_INJECTION_DRY_RUN: '1' }, + encoding: 'utf8', + }, +); + +const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; +process.stdout.write(result.stdout ?? ''); +process.stderr.write(result.stderr ?? ''); + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +if (!output.includes('[coord] hotkey pressed')) { + console.error("Hotkey injection gate did not emit '[coord] hotkey pressed'."); + process.exit(1); +} + +console.log('Hotkey injection gate passed.'); diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 12089f90..81511a6e 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -163,6 +163,12 @@ pub fn cancel_dictation(coord: CoordinatorState<'_>) { coord.cancel_dictation(); } +#[cfg(debug_assertions)] +#[tauri::command] +pub async fn inject_hotkey_click_for_dev(coord: CoordinatorState<'_>) -> Result<(), String> { + coord.inject_hotkey_click_for_dev().await +} + #[tauri::command] pub async fn repolish( coord: CoordinatorState<'_>, diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 70d1902f..24b68adc 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -154,6 +154,15 @@ impl Coordinator { cancel_session(&self.inner); } + #[cfg(any(debug_assertions, test))] + pub async fn inject_hotkey_click_for_dev(&self) -> Result<(), String> { + log::info!("[coord] dev hotkey injection started"); + handle_pressed(&self.inner).await; + handle_released(&self.inner).await; + cancel_session(&self.inner); + Ok(()) + } + pub async fn repolish(&self, raw_text: String, mode: PolishMode) -> Result { let hotwords = enabled_phrases(&self.inner); polish_text(&raw_text, mode, &hotwords) @@ -241,6 +250,7 @@ fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { async fn handle_pressed(inner: &Arc) { let mode = inner.prefs.get().hotkey.mode; let phase = inner.state.lock().phase; + log::info!("[coord] hotkey pressed (mode={mode:?}, phase={phase:?})"); match (mode, phase) { (HotkeyMode::Toggle, SessionPhase::Idle) => { let _ = begin_session(inner).await; @@ -257,8 +267,9 @@ async fn handle_pressed(inner: &Arc) { async fn handle_released(inner: &Arc) { let mode = inner.prefs.get().hotkey.mode; + let phase = inner.state.lock().phase; + log::info!("[coord] hotkey released (mode={mode:?}, phase={phase:?})"); if mode == HotkeyMode::Hold { - let phase = inner.state.lock().phase; if phase == SessionPhase::Listening { let _ = end_session(inner).await; } @@ -277,9 +288,24 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { state.started_at = Instant::now(); } + #[cfg(any(debug_assertions, test))] + if hotkey_injection_dry_run_enabled() { + emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); + inner.state.lock().phase = SessionPhase::Listening; + log::info!("[coord] session started (hotkey-injection dry-run)"); + return Ok(()); + } + if let Err(message) = ensure_microphone_permission(inner) { log::warn!("[coord] microphone permission gate failed: {message}"); - emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(message.clone()), None); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(message.clone()), + None, + ); inner.state.lock().phase = SessionPhase::Idle; return Err(message); } @@ -311,8 +337,9 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { inner.state.lock().phase = SessionPhase::Idle; return Err(e.to_string()); } - let c: Arc = - Arc::new(AsrBridge { asr: Arc::clone(&asr) }); + let c: Arc = Arc::new(AsrBridge { + asr: Arc::clone(&asr), + }); *inner.asr.lock() = Some(ActiveAsr::Volcengine(asr)); c }; @@ -511,6 +538,11 @@ fn cancel_session(inner: &Arc) { // ─────────────────────────── helpers ─────────────────────────── +#[cfg(any(debug_assertions, test))] +fn hotkey_injection_dry_run_enabled() -> bool { + std::env::var_os("OPENLESS_HOTKEY_INJECTION_DRY_RUN").is_some() +} + fn ensure_microphone_permission(inner: &Arc) -> Result<(), String> { use crate::permissions::{self, PermissionStatus}; @@ -626,6 +658,26 @@ fn enabled_hotwords(inner: &Arc) -> Vec { .collect() } +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn hotkey_injection_gate_logs_pressed_and_cancels() { + let _ = env_logger::builder() + .filter_level(log::LevelFilter::Info) + .is_test(false) + .try_init(); + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + coordinator.inject_hotkey_click_for_dev().await.unwrap(); + + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } +} + fn enabled_phrases(inner: &Arc) -> Vec { inner .vocab diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index a97d6ec1..915050d8 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -139,6 +139,8 @@ pub fn run() { commands::start_dictation, commands::stop_dictation, commands::cancel_dictation, + #[cfg(debug_assertions)] + commands::inject_hotkey_click_for_dev, commands::repolish, commands::set_default_polish_mode, commands::set_style_enabled,