From 4c1fae55a79f367803b1cc27080d9ae3b7b6f759 Mon Sep 17 00:00:00 2001 From: cooper Date: Mon, 18 May 2026 19:15:08 +0800 Subject: [PATCH 1/5] fix: harden windows handoff core flows --- openless-all/app/src-tauri/build.rs | 15 + openless-all/app/src-tauri/src/commands.rs | 303 ++++++++++++++++-- openless-all/app/src-tauri/src/coordinator.rs | 73 ++++- .../src-tauri/src/coordinator/dictation.rs | 69 +++- .../app/src-tauri/src/coordinator_state.rs | 39 ++- .../windows/common-controls-v6.manifest | 14 + .../app/src/components/ShortcutRecorder.tsx | 40 ++- .../src/pages/settings/ShortcutsSection.tsx | 2 + 8 files changed, 513 insertions(+), 42 deletions(-) create mode 100644 openless-all/app/src-tauri/windows/common-controls-v6.manifest diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index 2c97b041..b2cf9e8e 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -2,9 +2,24 @@ fn main() { #[cfg(target_os = "macos")] build_qwen_asr_macos(); + #[cfg(target_os = "windows")] + embed_common_controls_v6_manifest_for_tests(); + tauri_build::build(); } +#[cfg(target_os = "windows")] +fn embed_common_controls_v6_manifest_for_tests() { + // Unit test harness 不走 Tauri app manifest;没有 Common Controls v6 时会在 + // comctl32!TaskDialogIndirect 的 loader 阶段直接 0xc0000139。 + let manifest = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("windows") + .join("common-controls-v6.manifest"); + println!("cargo:rerun-if-changed={}", manifest.display()); + println!("cargo:rustc-link-arg=/MANIFEST:EMBED"); + println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest.display()); +} + /// 编译 vendored Open-Less/qwen-asr 的 C 源(仅 macOS)。 /// /// 上游 Makefile `make blas` 等价配置:BLAS 加速通过 Accelerate framework, diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index a689074d..aee96306 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -140,6 +140,7 @@ fn persist_settings( coord: &T, mut prefs: UserPreferences, ) -> Result<(), String> { + normalize_action_shortcuts(&mut prefs); sync_dictation_hotkey_legacy_fields(&mut prefs); reject_hotkey_collisions(&prefs)?; coord.write_settings(prefs)?; @@ -161,6 +162,7 @@ pub fn set_settings( ) -> Result<(), String> { let packs = coord.style_packs().list().map_err(|e| e.to_string())?; sync_style_pack_preferences(&mut prefs, &packs); + normalize_action_shortcuts(&mut prefs); // 广播给所有 webview。issue #205:QaPanel 跑在独立 webview, // 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时 // 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。 @@ -424,7 +426,9 @@ pub async fn app_check_update_with_channel( builder = builder.timeout(std::time::Duration::from_millis(ms)); } if matches!(channel, UpdateChannel::Beta) { - let urls = resolve_beta_manifest_endpoints().await?; + let Some(urls) = resolve_beta_manifest_endpoints().await? else { + return Ok(None); + }; builder = builder .endpoints(urls) .map_err(|e| format!("set beta endpoints: {e}"))?; @@ -454,26 +458,127 @@ pub async fn app_check_update_with_channel( /// 把 fetch_latest_beta_release 找到的最新 prerelease tag 拼成 -beta manifest URL 对。 /// 顺序:先镜像(fastgit.cc 代理 GitHub),后直连 —— 跟 tauri.conf 现有 Stable /// endpoints 一致,让国内访问优先打到 CDN。 -async fn resolve_beta_manifest_endpoints() -> Result, String> { +async fn resolve_beta_manifest_endpoints() -> Result>, String> { let Some(latest) = fetch_latest_beta_release().await? else { return Err("尚未发布过 Beta 版本".to_string()); }; let tag = latest.tag_name; - // {{target}} / {{arch}} 占位符由 plugin 在 check 时替换。Rust raw string 用 r#""# - // 不需要转义双花括号,比 format! 干净。 + let templated = beta_manifest_endpoints_for(&tag, "{{target}}", "{{arch}}")?; + let (target, arch) = current_updater_target_arch(); + let concrete = beta_manifest_endpoints_for(&tag, target, arch)?; + + // GitHub exposes a release/tag before every matrix job has uploaded its + // platform-specific updater manifest. In that short window the Atom feed is + // already visible, but latest---beta.json still returns 404. + // Probe the concrete URLs first so background checks do not surface noisy + // updater errors while the release is still being assembled. + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .user_agent(concat!("OpenLess/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| format!("build beta manifest probe client: {e}"))?; + let probes = probe_beta_manifest_endpoints(&client, &concrete).await; + let ready_indexes = select_ready_beta_manifest_endpoints(&probes)?; + if ready_indexes.is_empty() { + log::info!( + "[updater] beta manifest for {tag} is not published for {target}-{arch} yet; skipping this check" + ); + return Ok(None); + } + + let urls = ready_indexes + .into_iter() + .filter_map(|i| templated.get(i).cloned()) + .collect(); + Ok(Some(urls)) +} + +fn beta_manifest_endpoints_for( + tag: &str, + target: &str, + arch: &str, +) -> Result, String> { let mirror = format!( - "https://fastgit.cc/https://github.com/appergb/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta-mirror.json" + "https://fastgit.cc/https://github.com/appergb/openless/releases/download/{tag}/latest-{target}-{arch}-beta-mirror.json" ); let direct = format!( - "https://github.com/appergb/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta.json" + "https://github.com/appergb/openless/releases/download/{tag}/latest-{target}-{arch}-beta.json" ); - let mirror_url = - url::Url::parse(&mirror).map_err(|e| format!("parse beta mirror url: {e}"))?; - let direct_url = - url::Url::parse(&direct).map_err(|e| format!("parse beta direct url: {e}"))?; + let mirror_url = url::Url::parse(&mirror).map_err(|e| format!("parse beta mirror url: {e}"))?; + let direct_url = url::Url::parse(&direct).map_err(|e| format!("parse beta direct url: {e}"))?; Ok(vec![mirror_url, direct_url]) } +fn current_updater_target_arch() -> (&'static str, &'static str) { + let target = if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "darwin" + } else if cfg!(target_os = "linux") { + "linux" + } else { + std::env::consts::OS + }; + (target, std::env::consts::ARCH) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum BetaManifestProbe { + Available, + PendingStatus(u16), + Failed(String), +} + +async fn probe_beta_manifest_endpoints( + client: &reqwest::Client, + endpoints: &[url::Url], +) -> Vec { + let mut probes = Vec::with_capacity(endpoints.len()); + for endpoint in endpoints { + let probe = match client.get(endpoint.clone()).send().await { + Ok(resp) if resp.status().is_success() => BetaManifestProbe::Available, + Ok(resp) if resp.status().as_u16() == 404 => { + BetaManifestProbe::PendingStatus(resp.status().as_u16()) + } + Ok(resp) => BetaManifestProbe::Failed(format!("{endpoint} returned {}", resp.status())), + Err(error) => BetaManifestProbe::Failed(format!("{endpoint}: {error}")), + }; + probes.push(probe); + } + probes +} + +fn select_ready_beta_manifest_endpoints( + probes: &[BetaManifestProbe], +) -> Result, String> { + let ready: Vec = probes + .iter() + .enumerate() + .filter_map(|(i, probe)| matches!(probe, BetaManifestProbe::Available).then_some(i)) + .collect(); + if !ready.is_empty() { + return Ok(ready); + } + + if probes + .iter() + .all(|probe| matches!(probe, BetaManifestProbe::PendingStatus(404))) + { + return Ok(Vec::new()); + } + + let details = probes + .iter() + .map(|probe| match probe { + BetaManifestProbe::Available => "available".to_string(), + BetaManifestProbe::PendingStatus(status) => format!("pending status {status}"), + BetaManifestProbe::Failed(error) => error.clone(), + }) + .collect::>() + .join("; "); + Err(format!("beta manifest endpoints unavailable: {details}")) +} + #[tauri::command] pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus { coord.hotkey_status() @@ -1261,7 +1366,8 @@ fn is_valid_local_pack_id(s: &str) -> bool { if s.is_empty() || s.len() > 128 { return false; } - s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'_') + s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'_') } // ─────────────────────────── vocab ─────────────────────────── @@ -1812,6 +1918,14 @@ pub fn set_switch_style_hotkey( coord: CoordinatorState<'_>, binding: ShortcutBinding, ) -> Result<(), String> { + let binding = normalize_action_shortcut(binding); + if is_unconfigured_shortcut(&binding) { + let mut prefs = coord.prefs().get(); + prefs.switch_style_hotkey = binding; + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.update_switch_style_hotkey_binding(); + return Ok(()); + } crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; reject_modifier_only_action_shortcut(&binding)?; let mut prefs = coord.prefs().get(); @@ -1832,6 +1946,14 @@ pub fn set_open_app_hotkey( coord: CoordinatorState<'_>, binding: ShortcutBinding, ) -> Result<(), String> { + let binding = normalize_action_shortcut(binding); + if is_unconfigured_shortcut(&binding) { + let mut prefs = coord.prefs().get(); + prefs.open_app_hotkey = binding; + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.update_open_app_hotkey_binding(); + return Ok(()); + } crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; reject_modifier_only_action_shortcut(&binding)?; let mut prefs = coord.prefs().get(); @@ -1848,6 +1970,9 @@ pub fn set_open_app_hotkey( } fn reject_modifier_only_action_shortcut(binding: &ShortcutBinding) -> Result<(), String> { + if is_unconfigured_shortcut(binding) { + return Ok(()); + } if binding.modifiers.is_empty() && (binding.primary.eq_ignore_ascii_case("shift") || crate::shortcut_binding::legacy_modifier_trigger(binding).is_some()) @@ -1857,6 +1982,23 @@ fn reject_modifier_only_action_shortcut(binding: &ShortcutBinding) -> Result<(), Ok(()) } +fn is_unconfigured_shortcut(binding: &ShortcutBinding) -> bool { + binding.primary.trim().is_empty() +} + +fn normalize_action_shortcut(mut binding: ShortcutBinding) -> ShortcutBinding { + if is_unconfigured_shortcut(&binding) { + binding.primary.clear(); + binding.modifiers.clear(); + } + binding +} + +fn normalize_action_shortcuts(prefs: &mut UserPreferences) { + prefs.switch_style_hotkey = normalize_action_shortcut(prefs.switch_style_hotkey.clone()); + prefs.open_app_hotkey = normalize_action_shortcut(prefs.open_app_hotkey.clone()); +} + #[tauri::command] pub fn validate_combo_hotkey(binding: ComboBinding) -> Result<(), String> { let shortcut = ShortcutBinding { @@ -1932,6 +2074,9 @@ fn reject_hotkey_overlap( right: &ShortcutBinding, message: &'static str, ) -> Result<(), String> { + if is_unconfigured_shortcut(left) || is_unconfigured_shortcut(right) { + return Ok(()); + } if shortcut_bindings_overlap(left, right) { return Err(message.into()); } @@ -2472,8 +2617,10 @@ pub async fn marketplace_list( let body = resp.text().await.unwrap_or_default(); return Err(format!("marketplace HTTP {status}: {body}")); } - let items: Vec = - resp.json().await.map_err(|e| format!("parse failed: {e}"))?; + let items: Vec = resp + .json() + .await + .map_err(|e| format!("parse failed: {e}"))?; Ok(items) } @@ -2792,11 +2939,13 @@ fn get_github_oauth_client_id() -> Result { if !GITHUB_OAUTH_CLIENT_ID.is_empty() { return Ok(GITHUB_OAUTH_CLIENT_ID.to_string()); } - Err("GitHub OAuth 未配置。请去 https://github.com/settings/applications/new 注册一个 OAuth App\ + Err( + "GitHub OAuth 未配置。请去 https://github.com/settings/applications/new 注册一个 OAuth App\ (必须勾 Enable Device Flow),把 client_id 填到 \ openless-all/app/src-tauri/src/commands.rs 的 GITHUB_OAUTH_CLIENT_ID 常量,\ 或在启动前设置环境变量 GITHUB_OAUTH_CLIENT_ID=。" - .to_string()) + .to_string(), + ) } #[derive(Debug, serde::Serialize)] @@ -2913,12 +3062,13 @@ pub async fn github_device_flow_poll( mod tests { use super::{ active_asr_is_keyless_for_validation, active_foundry_model_from_prefs, - asr_configured_for_provider, asr_transcriptions_url, fetch_provider_models, - is_gemini_base_url, is_valid_local_pack_id, is_valid_session_id, + asr_configured_for_provider, asr_transcriptions_url, beta_manifest_endpoints_for, + fetch_provider_models, is_gemini_base_url, is_valid_local_pack_id, is_valid_session_id, llm_configured_for_provider, local_asr_release_plan_for_provider, models_url, normalize_foundry_language_hint, parse_gemini_model_ids, parse_latest_beta_from_atom, parse_model_ids, persist_settings, release_foundry_runtime_if_inactive, - validate_foundry_model_alias, ProviderConfig, SettingsWriter, + select_ready_beta_manifest_endpoints, validate_foundry_model_alias, BetaManifestProbe, + ProviderConfig, SettingsWriter, }; use crate::persistence::CredentialsSnapshot; use crate::types::{ @@ -3388,6 +3538,33 @@ mod tests { assert!(result.is_err()); } + #[test] + fn action_shortcut_normalizes_empty_primary_to_disabled() { + let binding = super::normalize_action_shortcut(ShortcutBinding { + primary: " ".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }); + + assert_eq!(binding.primary, ""); + assert!(binding.modifiers.is_empty()); + assert!(super::reject_modifier_only_action_shortcut(&binding).is_ok()); + } + + #[test] + fn action_shortcut_allows_disabled_bindings_to_overlap() { + let disabled = ShortcutBinding { + primary: "".into(), + modifiers: vec![], + }; + let active = ShortcutBinding { + primary: "O".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + + assert!(super::reject_switch_style_open_app_hotkey_overlap(&disabled, &active).is_ok()); + assert!(super::reject_dictation_open_app_hotkey_overlap(&active, &disabled).is_ok()); + } + #[test] fn combo_hotkey_bare_shift_rejection_matches_dictation_setter() { let binding = ShortcutBinding { @@ -3551,6 +3728,30 @@ mod tests { assert!(writer.saved.lock().unwrap().is_none()); } + #[test] + fn persist_settings_normalizes_disabled_action_hotkeys() { + let writer = FakeSettingsWriter::default(); + let prefs = UserPreferences { + switch_style_hotkey: ShortcutBinding { + primary: " ".into(), + modifiers: vec!["ctrl".into()], + }, + open_app_hotkey: ShortcutBinding { + primary: "".into(), + modifiers: vec!["shift".into()], + }, + ..Default::default() + }; + + persist_settings(&writer, prefs).unwrap(); + + let saved = writer.saved.lock().unwrap().clone().unwrap(); + assert_eq!(saved.switch_style_hotkey.primary, ""); + assert!(saved.switch_style_hotkey.modifiers.is_empty()); + assert_eq!(saved.open_app_hotkey.primary, ""); + assert!(saved.open_app_hotkey.modifiers.is_empty()); + } + #[test] fn parse_latest_beta_from_atom_picks_first_beta_tagged_entry() { // Fixture trimmed from real `releases.atom`:包含一条 stable + 一条 Beta。 @@ -3590,6 +3791,58 @@ mod tests { assert!(parse_latest_beta_from_atom(body).is_none()); } + #[test] + fn beta_manifest_endpoints_include_beta_suffix_and_mirror_pair() { + let urls = beta_manifest_endpoints_for("v1.3.4-4-beta-tauri", "windows", "x86_64").unwrap(); + + assert_eq!( + urls[0].as_str(), + "https://fastgit.cc/https://github.com/appergb/openless/releases/download/v1.3.4-4-beta-tauri/latest-windows-x86_64-beta-mirror.json" + ); + assert_eq!( + urls[1].as_str(), + "https://github.com/appergb/openless/releases/download/v1.3.4-4-beta-tauri/latest-windows-x86_64-beta.json" + ); + } + + #[test] + fn beta_manifest_probe_treats_all_404_as_release_pending() { + let probes = vec![ + BetaManifestProbe::PendingStatus(404), + BetaManifestProbe::PendingStatus(404), + ]; + + let ready = select_ready_beta_manifest_endpoints(&probes).unwrap(); + + assert!(ready.is_empty()); + } + + #[test] + fn beta_manifest_probe_keeps_only_reachable_endpoints() { + let probes = vec![ + BetaManifestProbe::PendingStatus(404), + BetaManifestProbe::Available, + ]; + + let ready = select_ready_beta_manifest_endpoints(&probes).unwrap(); + + assert_eq!(ready, vec![1]); + } + + #[test] + fn beta_manifest_probe_reports_real_network_failures() { + let probes = vec![ + BetaManifestProbe::Failed("mirror timeout".into()), + BetaManifestProbe::Failed("direct dns failure".into()), + ]; + + let err = select_ready_beta_manifest_endpoints(&probes).unwrap_err(); + + assert!(err.contains("beta manifest endpoints unavailable")); + assert!(err.contains("mirror timeout")); + assert!(err.contains("direct dns failure")); + } + #[tokio::test] async fn fetch_provider_models_omits_authorization_when_api_key_is_empty() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -3648,13 +3901,17 @@ mod tests { // 长度对但含 `/`:dash 位置错或非 hex 字符都不通过 assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544/000")); assert!(!is_valid_session_id("550e8400_e29b_41d4_a716_446655440000")); // 用 _ 代 - - // 非 hex 字符 + // 非 hex 字符 assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544000g")); // 长度不对(35 / 37) assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544000")); - assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-4466554400000")); + assert!(!is_valid_session_id( + "550e8400-e29b-41d4-a716-4466554400000" + )); // NUL 字节 - assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544\x00000")); + assert!(!is_valid_session_id( + "550e8400-e29b-41d4-a716-44665544\x00000" + )); // 百分号编码与绝对路径 assert!(!is_valid_session_id("%2e%2e/recordings/x")); assert!(!is_valid_session_id("/Users/attacker/secret.wav")); @@ -3665,7 +3922,9 @@ mod tests { assert!(is_valid_local_pack_id("builtin.light")); assert!(is_valid_local_pack_id("builtin.structured")); assert!(is_valid_local_pack_id("custom.meeting")); - assert!(is_valid_local_pack_id("550e8400-e29b-41d4-a716-446655440000")); + assert!(is_valid_local_pack_id( + "550e8400-e29b-41d4-a716-446655440000" + )); assert!(is_valid_local_pack_id("my_pack_v2")); assert!(is_valid_local_pack_id("Pack-2026.05")); } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index da3da4f8..bdaac160 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -667,6 +667,11 @@ impl Coordinator { fn update_action_hotkey_binding(&self, kind: ActionHotkeyKind) { let binding = action_hotkey_binding(&self.inner, kind); + if is_unconfigured_shortcut(&binding) { + take_action_hotkey_on_main_thread(&self.inner, kind); + log::info!("[coord] action hotkey {kind:?} disabled"); + return; + } if is_modifier_only_shortcut(&binding) { take_action_hotkey_on_main_thread(&self.inner, kind); log::warn!("[coord] action hotkey {kind:?} 使用了不支持的 modifier-only 绑定,已关闭"); @@ -1424,6 +1429,11 @@ fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotkeyKind) { return; } let binding = action_hotkey_binding(&inner, kind); + if is_unconfigured_shortcut(&binding) { + take_action_hotkey_on_main_thread(&inner, kind); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } if is_modifier_only_shortcut(&binding) { take_action_hotkey_on_main_thread(&inner, kind); std::thread::sleep(std::time::Duration::from_secs(5)); @@ -1656,7 +1666,10 @@ fn sync_custom_dictation_to_plugin(inner: &Arc) { return; } match crate::linux_fcitx::set_custom_dictation_trigger(&key_string) { - Ok(()) => log::info!("[fcitx] Synced custom dictation trigger '{}' to plugin", key_string), + Ok(()) => log::info!( + "[fcitx] Synced custom dictation trigger '{}' to plugin", + key_string + ), Err(e) => log::warn!("[fcitx] Failed to sync custom dictation trigger: {e}"), } } @@ -1746,14 +1759,18 @@ fn reset_shortcut_held_state(inner: &Arc) { } } } - if !is_modifier_only_shortcut(&prefs.switch_style_hotkey) { + if !is_unconfigured_shortcut(&prefs.switch_style_hotkey) + && !is_modifier_only_shortcut(&prefs.switch_style_hotkey) + { if let Some(monitor) = inner.switch_style_hotkey.lock().as_ref() { if let Err(e) = monitor.update_binding(prefs.switch_style_hotkey.clone()) { log::warn!("[coord] reset switch-style hotkey latch failed: {e}"); } } } - if !is_modifier_only_shortcut(&prefs.open_app_hotkey) { + if !is_unconfigured_shortcut(&prefs.open_app_hotkey) + && !is_modifier_only_shortcut(&prefs.open_app_hotkey) + { if let Some(monitor) = inner.open_app_hotkey.lock().as_ref() { if let Err(e) = monitor.update_binding(prefs.open_app_hotkey.clone()) { log::warn!("[coord] reset open-app hotkey latch failed: {e}"); @@ -3233,6 +3250,17 @@ mod tests { Uuid::from_u128(n) } + #[test] + fn empty_action_shortcut_is_unconfigured() { + let binding = crate::types::ShortcutBinding { + primary: " ".into(), + modifiers: vec!["ctrl".into()], + }; + + assert!(is_unconfigured_shortcut(&binding)); + assert!(!is_modifier_only_shortcut(&binding)); + } + #[tokio::test] async fn hotkey_injection_gate_logs_pressed_and_cancels() { let _ = env_logger::builder() @@ -3509,6 +3537,24 @@ mod tests { assert!(state.pending_stop); } + #[tokio::test] + async fn repeated_manual_stop_during_starting_cancels() { + let coordinator = Coordinator::new(); + { + let mut state = coordinator.inner.state.lock(); + state.phase = SessionPhase::Starting; + state.pending_stop = true; + state.cancelled = false; + state.session_id = session_id(123); + } + + coordinator.stop_dictation().await.unwrap(); + + let state = coordinator.inner.state.lock(); + assert_eq!(state.phase, SessionPhase::Idle); + assert!(state.cancelled); + } + #[tokio::test] async fn stop_dictation_from_listening_without_asr_returns_idle() { let coordinator = Coordinator::new(); @@ -3635,6 +3681,27 @@ mod tests { assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); } + #[tokio::test] + async fn qa_hotkey_during_recording_submits_without_closing_panel() { + let coordinator = Coordinator::new(); + { + let mut state = coordinator.inner.qa_state.lock(); + state.panel_visible = true; + state.phase = QaPhase::Recording; + state.cancelled = false; + } + + handle_qa_hotkey_pressed(&coordinator.inner).await; + + let state = coordinator.inner.qa_state.lock(); + assert!( + state.panel_visible, + "QA hotkey should submit the active recording, not toggle the panel closed" + ); + assert_ne!(state.phase, QaPhase::Recording); + assert!(!state.cancelled); + } + #[test] fn enabling_shortcut_recording_clears_dictation_hold_latch() { let coordinator = Coordinator::new(); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 56e661b1..fb8346d4 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1,7 +1,7 @@ use std::sync::atomic::Ordering; use std::sync::Arc; -use crate::coordinator_state::request_stop_during_starting_state; +use crate::coordinator_state::{request_stop_during_starting_state, StopDuringStartingOutcome}; use crate::correction::apply_correction_rules; use crate::types::HotkeyMode; @@ -330,9 +330,7 @@ fn streaming_insert_eligible( mode: PolishMode, raw_uses_llm: bool, ) -> bool { - streaming_insert_enabled - && !translation_active - && (mode != PolishMode::Raw || raw_uses_llm) + streaming_insert_enabled && !translation_active && (mode != PolishMode::Raw || raw_uses_llm) } fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option { @@ -450,14 +448,23 @@ pub(super) async fn handle_released(inner: &Arc) { } pub(super) fn request_stop_during_starting(inner: &Arc, reason: &str) { - { + let outcome = { let mut state = inner.state.lock(); - if !request_stop_during_starting_state(&mut state) { - return; + request_stop_during_starting_state(&mut state) + }; + match outcome { + Some(StopDuringStartingOutcome::Queued) => { + log::info!("[coord] {reason} during Starting — queued"); + stop_recorder_if_pending_start_stop(inner); + } + Some(StopDuringStartingOutcome::AlreadyPending) => { + log::info!( + "[coord] {reason} during Starting — pending_stop already queued; cancelling" + ); + cancel_session(inner); } + None => {} } - log::info!("[coord] {reason} during Starting — queued"); - stop_recorder_if_pending_start_stop(inner); } pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { @@ -601,6 +608,26 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { ActiveAsr::Bailian(Arc::clone(&asr)), ); start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::ActiveStarting => {} + StartupRaceStatus::CancelRaced => { + log::info!("[coord] cancel raced before Bailian ASR open_session — aborting begin"); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale Bailian startup before open_session from session {current_session_id} — ignoring" + ); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + } if let Err(e) = asr.open_session().await { log::error!("[coord] open Bailian ASR session failed: {e}"); @@ -700,6 +727,26 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { ActiveAsr::Volcengine(Arc::clone(&asr)), ); start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::ActiveStarting => {} + StartupRaceStatus::CancelRaced => { + log::info!("[coord] cancel raced before ASR open_session — aborting begin"); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale startup before ASR open_session from session {current_session_id} — ignoring" + ); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + } if let Err(e) = asr.open_session().await { log::error!("[coord] open ASR session failed: {e}"); @@ -1696,8 +1743,8 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> #[cfg(test)] mod tests { use super::{ - append_typed_prefix, default_done_message, dictation_error_code, - finalize_polished_text, streaming_insert_eligible, + append_typed_prefix, default_done_message, dictation_error_code, finalize_polished_text, + streaming_insert_eligible, }; use crate::types::{ChineseScriptPreference, CorrectionRule, InsertStatus, PolishMode}; diff --git a/openless-all/app/src-tauri/src/coordinator_state.rs b/openless-all/app/src-tauri/src/coordinator_state.rs index 82ce5672..3c2a7de9 100644 --- a/openless-all/app/src-tauri/src/coordinator_state.rs +++ b/openless-all/app/src-tauri/src/coordinator_state.rs @@ -84,12 +84,23 @@ pub(crate) fn begin_session_state( } /// stop_dictation / hold release 在 Starting 阶段只记录 pending_stop,等待启动完成后处理。 -pub(crate) fn request_stop_during_starting_state(state: &mut SessionState) -> bool { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StopDuringStartingOutcome { + Queued, + AlreadyPending, +} + +pub(crate) fn request_stop_during_starting_state( + state: &mut SessionState, +) -> Option { if state.phase != SessionPhase::Starting { - return false; + return None; + } + if state.pending_stop { + return Some(StopDuringStartingOutcome::AlreadyPending); } state.pending_stop = true; - true + Some(StopDuringStartingOutcome::Queued) } /// begin_session 中各 await 之间的 cancel race 检查结果。 @@ -270,15 +281,33 @@ mod tests { ..Default::default() }; - assert!(request_stop_during_starting_state(&mut state)); + assert_eq!( + request_stop_during_starting_state(&mut state), + Some(StopDuringStartingOutcome::Queued) + ); assert!(state.pending_stop); state.phase = SessionPhase::Listening; state.pending_stop = false; - assert!(!request_stop_during_starting_state(&mut state)); + assert_eq!(request_stop_during_starting_state(&mut state), None); assert!(!state.pending_stop); } + #[test] + fn repeated_stop_during_starting_reports_already_pending() { + let mut state = SessionState { + phase: SessionPhase::Starting, + pending_stop: true, + ..Default::default() + }; + + assert_eq!( + request_stop_during_starting_state(&mut state), + Some(StopDuringStartingOutcome::AlreadyPending) + ); + assert!(state.pending_stop); + } + #[test] fn finish_starting_is_table_driven_for_pending_cancel_and_stale_edges() { let cases = [ diff --git a/openless-all/app/src-tauri/windows/common-controls-v6.manifest b/openless-all/app/src-tauri/windows/common-controls-v6.manifest new file mode 100644 index 00000000..123fcc8d --- /dev/null +++ b/openless-all/app/src-tauri/windows/common-controls-v6.manifest @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index 60d337bc..42ab655c 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -9,11 +9,13 @@ export function ShortcutRecorder({ onSave, alignRecordButton = false, disabled = false, + allowUnset = false, }: { value: ShortcutBinding; onSave: (binding: ShortcutBinding) => Promise; alignRecordButton?: boolean; disabled?: boolean; + allowUnset?: boolean; }) { const { t } = useTranslation(); const [recording, setRecording] = useState(false); @@ -59,6 +61,18 @@ export function ShortcutRecorder({ } }; + const clearShortcut = async () => { + if (disabled) return; + try { + await onSave({ primary: '', modifiers: [] }); + clearPendingModifier(); + setRecording(false); + setError(null); + } catch { + setError(t('common.operationFailed')); + } + }; + const onKeyDown = (e: KeyboardEvent) => { if (!recording || disabled) return; e.preventDefault(); @@ -125,12 +139,27 @@ export function ShortcutRecorder({ opacity: disabled ? 0.68 : 1, marginLeft: alignRecordButton ? 'auto' : undefined, }; + const clearButtonStyle: CSSProperties = { + fontSize: 12, + padding: '5px 10px', + background: 'transparent', + color: 'var(--ol-ink-3)', + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 6, + fontFamily: 'inherit', + fontWeight: 500, + cursor: disabled ? 'default' : 'pointer', + opacity: disabled || !value.primary.trim() ? 0.55 : 1, + }; + const displayLabel = value.primary.trim() + ? formatComboLabel(value) + : t('hotkey.unset', 'Unset'); return (
- {formatComboLabel(value)} + {displayLabel} + {allowUnset && Boolean(value.primary.trim()) && ( + + )}
{recording && (
{ await setSwitchStyleHotkey(binding); await savePrefs({ ...prefs, switchStyleHotkey: binding }); @@ -99,6 +100,7 @@ export function ShortcutsSection() { { await setOpenAppHotkey(binding); await savePrefs({ ...prefs, openAppHotkey: binding }); From bf5f9304a6dad16a8dd3d2054e9567694b802bb4 Mon Sep 17 00:00:00 2001 From: cooper Date: Mon, 18 May 2026 19:25:08 +0800 Subject: [PATCH 2/5] test: remove out-of-scope qa hotkey expectation --- openless-all/app/src-tauri/src/coordinator.rs | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index bdaac160..aec279c3 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -3681,27 +3681,6 @@ mod tests { assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); } - #[tokio::test] - async fn qa_hotkey_during_recording_submits_without_closing_panel() { - let coordinator = Coordinator::new(); - { - let mut state = coordinator.inner.qa_state.lock(); - state.panel_visible = true; - state.phase = QaPhase::Recording; - state.cancelled = false; - } - - handle_qa_hotkey_pressed(&coordinator.inner).await; - - let state = coordinator.inner.qa_state.lock(); - assert!( - state.panel_visible, - "QA hotkey should submit the active recording, not toggle the panel closed" - ); - assert_ne!(state.phase, QaPhase::Recording); - assert!(!state.cancelled); - } - #[test] fn enabling_shortcut_recording_clears_dictation_hold_latch() { let coordinator = Coordinator::new(); From af79cf1b184d03610620a0b05ef0fb5bff3f659e Mon Sep 17 00:00:00 2001 From: cooper Date: Mon, 18 May 2026 19:38:26 +0800 Subject: [PATCH 3/5] fix: stop disabled action hotkey supervisor --- openless-all/app/src-tauri/src/coordinator.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index aec279c3..8c358735 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1431,8 +1431,7 @@ fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotkeyKind) { let binding = action_hotkey_binding(&inner, kind); if is_unconfigured_shortcut(&binding) { take_action_hotkey_on_main_thread(&inner, kind); - std::thread::sleep(std::time::Duration::from_secs(5)); - continue; + return; } if is_modifier_only_shortcut(&binding) { take_action_hotkey_on_main_thread(&inner, kind); From 2fb43ee3f45f81d81e9e9dcb6f3415b495159e59 Mon Sep 17 00:00:00 2001 From: cooper Date: Mon, 18 May 2026 19:51:36 +0800 Subject: [PATCH 4/5] fix: quiet beta manifest publishing gap --- openless-all/app/src-tauri/src/commands.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index aee96306..678f8528 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -560,9 +560,12 @@ fn select_ready_beta_manifest_endpoints( return Ok(ready); } + // A mirror/CDN can fail while the canonical asset is still being uploaded. + // As long as any probed endpoint proves the platform manifest is still + // missing with 404, treat this check as a publishing window and stay quiet. if probes .iter() - .all(|probe| matches!(probe, BetaManifestProbe::PendingStatus(404))) + .any(|probe| matches!(probe, BetaManifestProbe::PendingStatus(404))) { return Ok(Vec::new()); } @@ -3817,6 +3820,18 @@ mod tests { assert!(ready.is_empty()); } + #[test] + fn beta_manifest_probe_treats_404_with_transient_failure_as_release_pending() { + let probes = vec![ + BetaManifestProbe::PendingStatus(404), + BetaManifestProbe::Failed("mirror timeout".into()), + ]; + + let ready = select_ready_beta_manifest_endpoints(&probes).unwrap(); + + assert!(ready.is_empty()); + } + #[test] fn beta_manifest_probe_keeps_only_reachable_endpoints() { let probes = vec![ From ca7528a5614f8c1f1a712faa712f822dbd7bd25b Mon Sep 17 00:00:00 2001 From: cooper Date: Mon, 18 May 2026 20:10:19 +0800 Subject: [PATCH 5/5] fix: gate windows manifest link args to msvc --- openless-all/app/src-tauri/build.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index b2cf9e8e..33c2875a 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -2,13 +2,18 @@ fn main() { #[cfg(target_os = "macos")] build_qwen_asr_macos(); - #[cfg(target_os = "windows")] - embed_common_controls_v6_manifest_for_tests(); + if target_is_windows_msvc() { + embed_common_controls_v6_manifest_for_tests(); + } tauri_build::build(); } -#[cfg(target_os = "windows")] +fn target_is_windows_msvc() -> bool { + std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") +} + fn embed_common_controls_v6_manifest_for_tests() { // Unit test harness 不走 Tauri app manifest;没有 Common Controls v6 时会在 // comctl32!TaskDialogIndirect 的 loader 阶段直接 0xc0000139。