From 2002890a3997790b9948384c2a76a4a2fb0c3614 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 30 Apr 2026 21:23:52 +0800 Subject: [PATCH 1/2] fix: stabilize Windows recording startup --- openless-all/app/src-tauri/Cargo.lock | 79 ++++++++++++++++++- openless-all/app/src-tauri/Cargo.toml | 2 + openless-all/app/src-tauri/src/commands.rs | 39 +++++++-- openless-all/app/src-tauri/src/coordinator.rs | 43 +++++++++- openless-all/app/src-tauri/src/lib.rs | 4 +- openless-all/app/src-tauri/src/permissions.rs | 58 +++++++++++--- openless-all/app/src-tauri/src/recorder.rs | 20 ----- openless-all/app/src/App.tsx | 24 ------ 8 files changed, 203 insertions(+), 66 deletions(-) diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 211d8232..f5378c3c 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -1225,7 +1225,7 @@ dependencies = [ "rustc_version", "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -3287,6 +3287,7 @@ dependencies = [ "uuid", "window-vibrancy 0.7.1", "windows 0.58.0", + "winreg 0.52.0", ] [[package]] @@ -6595,6 +6596,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -6646,6 +6656,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -6703,6 +6728,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6721,6 +6752,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6739,6 +6776,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6769,6 +6812,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6787,6 +6836,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6805,6 +6860,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6823,6 +6884,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6859,6 +6926,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index aed16a8d..647c8ffb 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -54,10 +54,12 @@ objc2-app-kit = "0.2" [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.58", features = [ "Win32_Foundation", + "Win32_UI_Shell", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", "Win32_System_Threading", ] } +winreg = "0.52" # 跨平台磨砂层(macOS NSVisualEffectView / Windows Mica)。 [target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 47b93162..ef74c7a7 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -262,19 +262,46 @@ pub fn open_system_settings(pane: String) -> Result<(), String> { .map(|_| ()) .map_err(|e| e.to_string()) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "windows")] { + use windows::core::PCWSTR; + use windows::Win32::UI::Shell::ShellExecuteW; + use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + fn wide_null(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() + } + let uri = match pane.as_str() { "microphone" => "ms-settings:privacy-microphone", "sound" => "ms-settings:sound", "accessibility" => "ms-settings:easeofaccess", _ => "ms-settings:", }; - std::process::Command::new("cmd") - .args(["/C", "start", "", uri]) - .spawn() - .map(|_| ()) - .map_err(|e| e.to_string()) + + let operation = wide_null("open"); + let target = wide_null(uri); + let result = unsafe { + ShellExecuteW( + None, + PCWSTR(operation.as_ptr()), + PCWSTR(target.as_ptr()), + PCWSTR::null(), + PCWSTR::null(), + SW_SHOWNORMAL, + ) + }; + + if result.0 as isize <= 32 { + Err(format!("ShellExecuteW failed: {}", result.0 as isize)) + } else { + Ok(()) + } + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + let _ = pane; + Err("open_system_settings is only supported on macOS and Windows".to_string()) } } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 94f99d31..53b1d613 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -794,7 +794,10 @@ async fn end_session(inner: &Arc) -> Result<(), String> { } }; if !proceed_to_insert { - log::info!("[coord] cancel detected before insert — discarding output (chars={})", polished.chars().count()); + log::info!( + "[coord] cancel detected before insert — discarding output (chars={})", + polished.chars().count() + ); return Ok(()); } @@ -916,6 +919,15 @@ fn hotkey_injection_dry_run_enabled() -> bool { fn ensure_microphone_permission(inner: &Arc) -> Result<(), String> { use crate::permissions::{self, PermissionStatus}; + #[cfg(target_os = "windows")] + { + let _ = inner; + if permissions::windows_microphone_access_explicitly_denied() { + return Err("需要麦克风权限,当前状态: Denied".to_string()); + } + return Ok(()); + } + let status = permissions::check_microphone(); if matches!( status, @@ -1144,6 +1156,35 @@ mod tests { assert_eq!(state.phase, SessionPhase::Starting); assert!(state.pending_stop); } + + #[tokio::test] + async fn repeated_pressed_edge_during_hold_session_does_not_restart() { + let coordinator = Coordinator::new(); + coordinator + .inner + .prefs + .set(crate::types::UserPreferences { + hotkey: crate::types::HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Hold, + }, + ..Default::default() + }) + .unwrap(); + coordinator.inner.state.lock().phase = SessionPhase::Listening; + coordinator + .inner + .hotkey_trigger_held + .store(true, Ordering::SeqCst); + + handle_pressed_edge(&coordinator.inner).await; + + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + } } fn enabled_phrases(inner: &Arc) -> Vec { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 4e93e444..5f45e571 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -42,7 +42,9 @@ pub fn run() { // 否则两份 OpenLess(如 /Applications/ + dev build)会各自抓全局热键, // 导致按一次键、两个进程同时跑流水线、文本被插入两遍。见 issue #50。 .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { - log::info!("[single-instance] another instance launched, focusing existing main window"); + log::info!( + "[single-instance] another instance launched, focusing existing main window" + ); show_main_window(app); })) .plugin(tauri_plugin_shell::init()) diff --git a/openless-all/app/src-tauri/src/permissions.rs b/openless-all/app/src-tauri/src/permissions.rs index 723b594e..d85702c0 100644 --- a/openless-all/app/src-tauri/src/permissions.rs +++ b/openless-all/app/src-tauri/src/permissions.rs @@ -251,6 +251,10 @@ mod platform { use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{SampleFormat, StreamConfig}; use std::time::Duration; + #[cfg(target_os = "windows")] + use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; + #[cfg(target_os = "windows")] + use winreg::RegKey; /// Windows / Linux 不存在 macOS 那种 Accessibility 概念;rdev 直接监听键盘。 pub fn check_accessibility() -> PermissionStatus { @@ -290,6 +294,18 @@ mod platform { check_microphone() } + pub fn windows_microphone_access_explicitly_denied() -> bool { + #[cfg(target_os = "windows")] + { + windows_microphone_registry_denied() + } + + #[cfg(not(target_os = "windows"))] + { + false + } + } + fn classify_audio_probe_error(message: String) -> PermissionStatus { let lower = message.to_lowercase(); log::warn!("[mic] input probe failed: {message}"); @@ -365,24 +381,32 @@ mod platform { } fn registry_value_is_deny(path: &str) -> bool { - let output = match std::process::Command::new("reg") - .args(["query", path, "/v", "Value"]) - .output() + #[cfg(target_os = "windows")] { - Ok(output) => output, - Err(err) => { - log::warn!("[mic] reg query failed for {path}: {err}"); + let Some((root, subkey)) = path.split_once('\\') else { return false; - } - }; + }; + + let hive = match root { + "HKCU" => RegKey::predef(HKEY_CURRENT_USER), + "HKLM" => RegKey::predef(HKEY_LOCAL_MACHINE), + _ => return false, + }; - if !output.status.success() { - return false; + match hive.open_subkey(subkey) { + Ok(key) => match key.get_value::("Value") { + Ok(value) => value.eq_ignore_ascii_case("Deny"), + Err(_) => false, + }, + Err(_) => false, + } } - String::from_utf8_lossy(&output.stdout) - .lines() - .any(|line| line.contains("REG_SZ") && line.split_whitespace().any(|part| part == "Deny")) + #[cfg(not(target_os = "windows"))] + { + let _ = path; + false + } } } @@ -390,6 +414,14 @@ pub use platform::{ check_accessibility, check_microphone, request_accessibility, request_microphone, }; +#[cfg(target_os = "windows")] +pub use platform::windows_microphone_access_explicitly_denied; + +#[cfg(not(target_os = "windows"))] +pub fn windows_microphone_access_explicitly_denied() -> bool { + false +} + /// 兼容老调用:startup 时主动弹 Accessibility 框。 pub fn request_accessibility_with_prompt(_prompt: bool) -> bool { matches!(request_accessibility(), PermissionStatus::Granted) diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index a18c4ef4..e7fa96b3 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -20,8 +20,6 @@ use cpal::{SampleFormat, StreamConfig}; use parking_lot::Mutex; use thiserror::Error; -use crate::permissions::{self, PermissionStatus}; - /// 目标采样率(与 Swift 端常量一致;不要改)。 const TARGET_SAMPLE_RATE: u32 = 16_000; /// 每多少个回调打一次诊断日志。 @@ -60,24 +58,6 @@ impl Recorder { consumer: Arc, level_handler: Arc, ) -> Result { - let status = permissions::check_microphone(); - if !matches!( - status, - PermissionStatus::Granted | PermissionStatus::NotApplicable - ) { - let requested = permissions::request_microphone(); - if !matches!( - requested, - PermissionStatus::Granted | PermissionStatus::NotApplicable - ) { - log::warn!( - "[recorder] microphone permission not granted: {:?}", - requested - ); - return Err(RecorderError::PermissionDenied); - } - } - // 启动信号:子线程构造 Stream 完成后通过 startup_tx 报告结果。 let (startup_tx, startup_rx) = channel::>(); let stop_flag = Arc::new(AtomicBool::new(false)); diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index 3d1a376c..eeb9d697 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -60,14 +60,8 @@ export function App({ isCapsule }: AppProps) { useEffect(() => { if (!isTauri || os !== 'win') return; - const pressedKeys = new Map(); const forwardKey = (event: KeyboardEvent) => { if (!isWindowHotkeyCandidate(event)) return; - if (event.type === 'keydown' && event.key !== 'Escape') { - pressedKeys.set(event.code, event.key); - } else if (event.type === 'keyup') { - pressedKeys.delete(event.code); - } void handleWindowHotkeyEvent( event.type as 'keydown' | 'keyup', event.key, @@ -75,29 +69,11 @@ export function App({ isCapsule }: AppProps) { event.repeat, ).catch(error => console.warn('[window-hotkey] forward failed', error)); }; - const releasePressedKeys = () => { - if (pressedKeys.size === 0) return; - const pending = Array.from(pressedKeys.entries()); - pressedKeys.clear(); - for (const [code, key] of pending) { - void handleWindowHotkeyEvent('keyup', key, code, false).catch(error => - console.warn('[window-hotkey] release fallback failed', error), - ); - } - }; - const releaseOnHidden = () => { - if (document.visibilityState === 'hidden') releasePressedKeys(); - }; window.addEventListener('keydown', forwardKey, true); window.addEventListener('keyup', forwardKey, true); - window.addEventListener('blur', releasePressedKeys); - document.addEventListener('visibilitychange', releaseOnHidden); return () => { window.removeEventListener('keydown', forwardKey, true); window.removeEventListener('keyup', forwardKey, true); - window.removeEventListener('blur', releasePressedKeys); - document.removeEventListener('visibilitychange', releaseOnHidden); - releasePressedKeys(); }; }, [os]); From f937f586ee919064c24d273735d32a76276b3cce Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 30 Apr 2026 21:33:28 +0800 Subject: [PATCH 2/2] chore: bump version to 1.2.6 --- openless-all/app/package-lock.json | 4 ++-- openless-all/app/package.json | 2 +- openless-all/app/src-tauri/Cargo.lock | 2 +- openless-all/app/src-tauri/Cargo.toml | 2 +- openless-all/app/src-tauri/tauri.conf.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index f7106e2d..5e8078ed 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "1.2.5", + "version": "1.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.2.5", + "version": "1.2.6", "dependencies": { "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-shell": "^2.0.1", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index ff811f10..52998543 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -1,7 +1,7 @@ { "name": "openless-app", "private": true, - "version": "1.2.5", + "version": "1.2.6", "type": "module", "scripts": { "dev": "vite", diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index f5378c3c..1faae85a 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -3250,7 +3250,7 @@ dependencies = [ [[package]] name = "openless" -version = "1.2.5" +version = "1.2.6" dependencies = [ "anyhow", "arboard", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 647c8ffb..057d444e 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openless" -version = "1.2.5" +version = "1.2.6" description = "OpenLess — local voice input that types where your cursor is" authors = ["OpenLess"] edition = "2021" diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index c22da608..5d8118d4 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenLess", - "version": "1.2.5", + "version": "1.2.6", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev",