Merge pull request #41 from H-Chris233/main

test(hotkey): 补齐热键自动注入门禁
This commit is contained in:
Chris233 authored and GitHub committed 2026-04-30 11:37:15 +08:00
commit ac611d5151
6 files changed
+103 -5

No files matched your search

+12
View File
@@ -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 ->
+2 -1
View File
@@ -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",
@@ -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.');
@@ -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<'_>,
+56 -4
View File
@@ -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<String, String> {
let hotwords = enabled_phrases(&self.inner);
polish_text(&raw_text, mode, &hotwords)
@@ -241,6 +250,7 @@ fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
async fn handle_pressed(inner: &Arc<Inner>) {
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<Inner>) {
async fn handle_released(inner: &Arc<Inner>) {
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<Inner>) -> 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<Inner>) -> Result<(), String> {
inner.state.lock().phase = SessionPhase::Idle;
return Err(e.to_string());
}
let c: Arc<dyn crate::recorder::AudioConsumer> =
Arc::new(AsrBridge { asr: Arc::clone(&asr) });
let c: Arc<dyn crate::recorder::AudioConsumer> = Arc::new(AsrBridge {
asr: Arc::clone(&asr),
});
*inner.asr.lock() = Some(ActiveAsr::Volcengine(asr));
c
};
@@ -511,6 +538,11 @@ fn cancel_session(inner: &Arc<Inner>) {
// ─────────────────────────── 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<Inner>) -> Result<(), String> {
use crate::permissions::{self, PermissionStatus};
@@ -626,6 +658,26 @@ fn enabled_hotwords(inner: &Arc<Inner>) -> Vec<DictionaryHotword> {
.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<Inner>) -> Vec<String> {
inner
.vocab
+2
View File
@@ -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,