Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions openless-all/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
3 changes: 2 additions & 1 deletion openless-all/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions openless-all/app/scripts/check-hotkey-injection.mjs
Original file line number Diff line number Diff line change
@@ -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.');
6 changes: 6 additions & 0 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<'_>,
Expand Down
60 changes: 56 additions & 4 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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
};
Expand Down Expand Up @@ -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};

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading