From 7fc82167e5fbd5e784b3c86be6c428e640fd40a8 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 18 May 2026 09:34:48 +0800 Subject: [PATCH 01/10] =?UTF-8?q?fix(windows-ime):=20=E5=88=87=E5=9B=9E?= =?UTF-8?q?=E5=8E=9F=E8=BE=93=E5=85=A5=E6=B3=95=E6=97=B6=E5=AF=B9=E7=A7=B0?= =?UTF-8?q?=E8=B0=83=20legacy=20TSF=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restore_profile 之前只调 ITfInputProcessorProfileMgr::ActivateProfile (现代 API),但 activate_openless_profile 走的是「现代 + legacy」组合: ChangeCurrentLanguage + ActivateLanguageProfile + ActivateProfile。 结果是 OS 的 legacy current language / active profile 状态被 OpenLess 激活路径动过,restore 路径只翻新现代 API → OS 仍认 OpenLess 是当前 输入法,用户的 WeChat 输入法切不回去。 修法是 restore_profile 对称补齐 legacy API 调用。TextService 分支 加 ChangeCurrentLanguage + ActivateLanguageProfile;KeyboardLayout 分支加 ChangeCurrentLanguage(legacy 没有专门的 keyboard layout 接 口,HKL 由现代 ActivateProfile 处理)。 回归点:c3b59329 (5/1) "fix: avoid clipboard fallback for Windows voice insertion" 把激活路径升级为「现代 + legacy」混合,没同步升级 恢复路径。 closes #469 --- .../app/src-tauri/src/windows_ime_profile.rs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/windows_ime_profile.rs b/openless-all/app/src-tauri/src/windows_ime_profile.rs index beb3c9f0..6b376a01 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -319,16 +319,27 @@ mod windows_impl { } pub fn restore_profile(snapshot: &ImeProfileSnapshot) -> WindowsImeProfileResult<()> { + // 必须与 activate_openless_profile 路径对称:激活同时调了 legacy + // ITfInputProcessorProfiles 的 ChangeCurrentLanguage + ActivateLanguageProfile, + // 单独调现代 ITfInputProcessorProfileMgr::ActivateProfile 不会更新 legacy + // current language / active profile 状态,OS 仍认 OpenLess 是当前输入法 → + // 用户的输入法切不回去。issue #469。 match snapshot.kind() { ImeProfileKind::TextService => { let clsid = parse_required_guid("text service CLSID", snapshot.clsid())?; let profile_guid = parse_required_guid("text service profile GUID", snapshot.profile_guid())?; + let lang_id = snapshot.lang_id(); + + with_input_processor_profiles(|profiles| unsafe { + profiles.ChangeCurrentLanguage(lang_id)?; + profiles.ActivateLanguageProfile(&clsid, lang_id, &profile_guid) + })?; with_profile_manager(|manager| unsafe { manager.ActivateProfile( TF_PROFILETYPE_INPUTPROCESSOR, - snapshot.lang_id(), + lang_id, &clsid, &profile_guid, null_hkl(), @@ -339,11 +350,16 @@ mod windows_impl { ImeProfileKind::KeyboardLayout => { let hkl = HKL(snapshot.hkl().unwrap_or_default() as *mut c_void); let zero_guid = GUID::zeroed(); + let lang_id = snapshot.lang_id(); + + with_input_processor_profiles(|profiles| unsafe { + profiles.ChangeCurrentLanguage(lang_id) + })?; with_profile_manager(|manager| unsafe { manager.ActivateProfile( TF_PROFILETYPE_KEYBOARDLAYOUT, - snapshot.lang_id(), + lang_id, &zero_guid, &zero_guid, hkl, From 19da9367ceb649cf93b537a738c9c8fc25815719 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 18 May 2026 09:35:14 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix(windows):=20single-instance=20?= =?UTF-8?q?=E4=BA=8C=E6=AC=A1=E5=90=AF=E5=8A=A8=E5=B0=8A=E9=87=8D=20start?= =?UTF-8?q?=5Fminimized?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Win11 启动时静默运行(start_minimized=true)被绕过的根因不在初次启动 的 setup() 分支(那里读 prefs 正常),而在 tauri_plugin_single_instance 的回调里: show_main_window(app) // 无条件抢出主窗口 Win11 常见触发链: - Registry Run 触发首次启动 → setup() 尊重 start_minimized,window 隐藏 ✓ - Win11 "登录时重新打开应用" 功能再触发一次 → single-instance 截获 → show_main_window → 窗口弹出 ✗ - 或者 autostart 自身被 OS 触发两次 修法:single-instance 回调里先读 prefs,start_minimized=true 就直接 return(不走 CLI intent 也不 show)。托盘左键、托盘菜单 "显示主窗口" 两条路径仍然显式调 show_main_window,是用户的主动操作,不受影响。 closes #468 --- openless-all/app/src-tauri/src/lib.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index adea1faf..c72a25e7 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -83,6 +83,20 @@ pub fn run() { dispatch_cli_intent(app, intent); return; } + // 静默启动模式下:第二次启动(Win11 的「登录时重新打开应用」、autostart 双触发、 + // 或用户手动再点图标)也不弹主窗口,否则 start_minimized=true 在 Win11 上整体失效。 + // 用户想看主窗口走托盘菜单 / 托盘左键。issue #468。 + if let Some(coordinator) = app + .try_state::>() + .map(|s| Arc::clone(&*s)) + { + if coordinator.prefs().get().start_minimized { + log::info!( + "[single-instance] start_minimized=true → skipping show on relaunch" + ); + return; + } + } log::info!( "[single-instance] another instance launched, focusing existing main window" ); From 702aca00c2283a9f3cb2badad4d0e675749adc30 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 18 May 2026 09:35:24 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(windows):=20QA=20=E6=B5=AE=E7=AA=97?= =?UTF-8?q?=20show=20=E6=97=B6=E4=B8=BB=E5=8A=A8=E6=8A=93=E7=84=A6?= =?UTF-8?q?=E7=82=B9=EF=BC=8C=E8=AE=A9=20ESC=20/=20X=20=E8=83=BD=E5=85=B3?= =?UTF-8?q?=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit show_qa_window_no_activate 用 SW_SHOWNOACTIVATE 显示窗口 + tauri.conf.json 里 \"focus: false\" 双管齐下,QA webview 从来拿不到键盘焦点: - ESC keydown 永远不会到 React 监听器 → ESC 关不掉 - X 按钮 first-click 被 OS 当作激活点击吃掉 → 用户感知是"按了没反应" 修法是 show 时调 ShowWindow(SW_SHOW) + SetForegroundWindow + SetFocus, 明确把焦点交给 QA webview。前者只是创建提示,后者控制每次 show 时的 实际焦点。 权衡:这破坏了 issue #164 的 "QA 浮窗不抢前台 app 焦点" 语义。后续 如需恢复"选区抓取不被打断",在 begin_qa_session 期间临时把焦点还给 saved front app HWND,capture_selection 跑完再收回 QA 即可。issue #466 的 "X / ESC 完全不能关闭" 比这个权衡严重得多,先修主路径。 closes #466 --- openless-all/app/src-tauri/src/lib.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index c72a25e7..1ec221ed 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -1142,7 +1142,8 @@ pub(crate) fn hide_qa_window(app: &AppHandle) { fn show_qa_window_no_activate(window: &tauri::WebviewWindow) -> bool { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use windows::Win32::Foundation::HWND; - use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOWNOACTIVATE}; + use windows::Win32::UI::Input::KeyboardAndMouse::SetFocus; + use windows::Win32::UI::WindowsAndMessaging::{SetForegroundWindow, ShowWindow, SW_SHOW}; let Ok(handle) = window.window_handle() else { return false; @@ -1155,7 +1156,20 @@ fn show_qa_window_no_activate(window: &tauri::WebviewWindow Date: Mon, 18 May 2026 09:35:31 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix(windows):=20capsule=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=8A=A0=E4=B8=80=E6=AC=A1=E6=80=A7=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #470 / #470-like 报告"看不到录音胶囊"时,目前 log 没法定位是: (a) prefs.show_capsule 被关 (b) emit_capsule 根本没被调(state 一直 Idle) (c) show_capsule_window_for_recording 调了但 Win32 路径失败 加两条一次性 info log(per-process): - 首次 show_capsule=true && visible=true:明确"胶囊路径有跑、开关是开的" - 首次 show_capsule=false && visible=true:明确"用户开关把胶囊关掉了" 下次用户报"胶囊没显示"时,看一行 log 就能定根因,不再让用户自己反 复确认设置。逻辑不动,纯加 log。 refs #470 --- openless-all/app/src-tauri/src/coordinator.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 41702dfe..42f12743 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -91,6 +91,8 @@ fn capsule_show_strategy_for_platform() -> CapsuleShowStrategy { } static CAPSULE_NO_ACTIVATE_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false); +static CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED: AtomicBool = AtomicBool::new(false); +static CAPSULE_FIRST_SHOW_LOGGED: AtomicBool = AtomicBool::new(false); fn show_capsule_window_for_recording( app: &AppHandle, @@ -4217,12 +4219,31 @@ fn emit_capsule( // 处理,不依赖把 Done/Cancelled/Error 打成 invisible。详见 PR #140 评论。 maybe_position_capsule_bottom_center(&inner_for_main, &window, translation); if show_capsule && visible { + // 用户报"看不到胶囊"时第一时间能在 log 里确认:胶囊路径有跑、show_capsule + // 开关是 true、当前进入 visible 帧 —— 排除 prefs 没存住 / emit_capsule 没触 + // 发 / state 一直 Idle 这几类常见 root cause。issue #470。 + if !CAPSULE_FIRST_SHOW_LOGGED.swap(true, Ordering::SeqCst) { + log::info!( + "[capsule] first show this session: show_capsule=true visible=true state={state:?}" + ); + } show_capsule_window_for_recording(&app_for_main, &window); // macOS/Windows 优先走 no-activate show,避免录音胶囊抢走当前工作 app 焦点。 // 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。 #[cfg(target_os = "macos")] crate::restore_main_window_key_if_active(&app_for_main); } else { + // show_capsule 开关被用户关掉但本次确实想显示(visible=true)的情况: + // 一次性 info log,让用户报"胶囊没显示"时能在日志里一眼看到根因 —— 维护者 + // 不必再让用户"去打开设置确认"。issue #470。 + if !show_capsule + && visible + && !CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED.swap(true, Ordering::SeqCst) + { + log::info!( + "[capsule] suppressed by user toggle: show_capsule=false visible=true state={state:?}" + ); + } hide_capsule_window_if_present(); let _ = window.hide(); } From 9c6c42da534a1e3b623fc19a2146d0fb87082e64 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 18 May 2026 09:44:02 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix(windows):=20QA=20=E6=B5=AE=E7=AA=97?= =?UTF-8?q?=E6=8A=93=E9=80=89=E5=8C=BA=E6=97=B6=E5=81=9A=20focus-dance=20?= =?UTF-8?q?=E8=BF=98=E5=8E=9F=20#164=20=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr_agent 指出 #466 修复(QA 浮窗主动抓前台)会破坏 begin_qa_session 里 capture_selection 的 simulate_copy 路径 —— 此时前台是 QA 自己的 webview,Ctrl+C 抓不到用户原 app 的选区。 补 focus-dance: - open_qa_panel: 在 show_qa_window 抢前台之前调 capture_focus_target(), 把用户原 app 的 HWND 存进 QaSessionState.qa_focus_target。 - begin_qa_session: capture_selection 之前 restore_focus_target_if_possible 把焦点临时还给 saved HWND;capture_selection 跑完再 refocus_qa_window 把焦点交还 QA,保证 ESC/X 等交互继续可用。 - close_qa_panel: 清掉 qa_focus_target。 多轮场景下,用户在两轮之间主动切回原 app 时 saved == current 前台 → restore_focus_target_if_possible 是 no-op,capture_selection 走原路径。 非 Windows 平台所有 focus-dance 分支都 #[cfg] 掉,macOS / Linux 不受影响。 refs #466 --- openless-all/app/src-tauri/src/coordinator.rs | 17 ++++++++++++++++- .../app/src-tauri/src/coordinator/qa.rs | 12 +++++++++++- openless-all/app/src-tauri/src/lib.rs | 12 ++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 42f12743..eda74ddd 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2552,8 +2552,23 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { inner.qa_stream_cancelled.store(false, Ordering::SeqCst); // 抓选区。每轮按 Option 都重新抓一次:用户多轮提问中可以重新选别处文字。 - // 浮窗 focus:false,原 app 仍是 frontmost,AX/Cmd+C fallback 都能拿到。 + // + // - macOS:浮窗走 orderFrontRegardless,不成为 key window,原 app 仍是 frontmost, + // AX/Cmd+C fallback 都能拿到。 + // - Windows:#466 修复后 show_qa_window_no_activate 主动抓焦点,QA 此刻已是前台, + // simulate_copy 会跑在 QA 自己 webview 上 → 抓不到。focus-dance 上半场:把焦点临时 + // 还给 open_qa_panel 时记下的 saved HWND,抓完选区后下半场再把焦点交还 QA,让 ESC/X + // 继续可用。多轮场景下用户自己切回原 app 时 saved == current,restore 是 no-op。 + #[cfg(target_os = "windows")] + { + let saved_target = inner.qa_state.lock().qa_focus_target; + let _ = restore_focus_target_if_possible(saved_target); + } let selection = capture_selection(); + #[cfg(target_os = "windows")] + if let Some(app) = inner.app.lock().clone() { + crate::refocus_qa_window(&app); + } let selection_preview_text = selection.as_ref().map(|s| s.text.clone()); inner.qa_state.lock().selection = selection.clone(); diff --git a/openless-all/app/src-tauri/src/coordinator/qa.rs b/openless-all/app/src-tauri/src/coordinator/qa.rs index 149756ca..69614742 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa.rs @@ -7,7 +7,8 @@ use crate::selection::SelectionContext; use crate::types::CapsuleState; use super::{ - begin_qa_session, cancel_qa_session, capture_frontmost_app, emit_capsule, end_qa_session, Inner, + begin_qa_session, cancel_qa_session, capture_focus_target, capture_frontmost_app, emit_capsule, + end_qa_session, Inner, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -22,6 +23,10 @@ pub(super) struct QaSessionState { pub(super) cancelled: bool, pub(super) selection: Option, pub(super) front_app: Option, + /// open_qa_panel 时用户原 app 的 HWND(Windows 专用,存 usize 跨线程安全)。 + /// begin_qa_session 抓选区前临时把焦点还给它,避开 #466 修复后 QA 自己抢前台导致 + /// simulate_copy 在 QA webview 上跑空。非 Windows / macOS 平台为 None 不参与。 + pub(super) qa_focus_target: Option, /// 用于忽略迟到的 RMS / runtime error。 pub(super) session_id: SessionId, /// QA 浮窗是否被用户钉住(pinned)。pinned=true 时不自动隐藏。 @@ -41,6 +46,7 @@ impl Default for QaSessionState { cancelled: false, selection: None, front_app: None, + qa_focus_target: None, session_id: initial_session_id(), pinned: false, panel_visible: false, @@ -85,6 +91,9 @@ pub(super) fn open_qa_panel(inner: &Arc) { state.messages.clear(); state.selection = None; state.front_app = capture_frontmost_app(); + // 在 show_qa_window 抢前台之前抓一下:每次 begin_qa_session 抓选区时拿这个 HWND + // 临时把焦点还回去,让 simulate_copy 跑在用户原 app 上。issue #466 focus-dance。 + state.qa_focus_target = capture_focus_target(); } // 主听写 phase 是 Idle 才需要 sweep capsule —— 这里的语义是清掉「上一次 dictation // Done 状态残留」的 message / insertedChars,让 QA 自己的 capsule 状态从干净起跑 @@ -119,6 +128,7 @@ pub(super) fn close_qa_panel(inner: &Arc) { state.messages.clear(); state.selection = None; state.front_app = None; + state.qa_focus_target = None; state.phase = QaPhase::Idle; state.cancelled = false; } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 1ec221ed..3ee62bcf 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -1138,6 +1138,18 @@ pub(crate) fn hide_qa_window(app: &AppHandle) { } } +/// 抓完选区后把焦点重新交回 QA 浮窗(Windows focus-dance 下半场)。begin_qa_session +/// 在 capture_selection 跑完时调;非 Windows 平台是 no-op。issue #466。 +#[cfg(target_os = "windows")] +pub(crate) fn refocus_qa_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("qa") { + let _ = show_qa_window_no_activate(&window); + } +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn refocus_qa_window(_app: &AppHandle) {} + #[cfg(target_os = "windows")] fn show_qa_window_no_activate(window: &tauri::WebviewWindow) -> bool { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; From ed5f4601e89aee02e47b4294f2dbbe873f53aded Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 18 May 2026 09:56:10 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(capsule-log):=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=9F=AD=E5=90=8D=E5=8F=AA=E8=BE=93=E5=87=BA=E5=8F=98=E4=BD=93?= =?UTF-8?q?=E5=90=8D=EF=BC=8C=E4=B8=8D=E7=94=A8=20Debug=20fallthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr_agent 提的 forward-looking 隐患:诊断日志现在用 `state:?`,CapsuleState 本身是纯枚举不含字段,但哪天加上 `Error(String)` 之类的话 `:?` 会把 ASR / polish / error 文本意外塞进 log 文件。 加 capsule_state_log_name(state) → &'static str,显式枚举到一个固定短名 ("idle" / "recording" / ... / "error")。两处 #470 诊断 log 都改成 这个 helper。逻辑零变化,纯收紧日志输出语义。 refs #470 --- openless-all/app/src-tauri/src/coordinator.rs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index eda74ddd..c245dcfb 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -94,6 +94,21 @@ static CAPSULE_NO_ACTIVATE_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false); static CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED: AtomicBool = AtomicBool::new(false); static CAPSULE_FIRST_SHOW_LOGGED: AtomicBool = AtomicBool::new(false); +/// 给 #470 诊断日志用的 capsule 状态短名。显式枚举每个变体到 &'static str, +/// 不走 `Debug` —— 哪天 CapsuleState 加了 `String` 字段,`:?` 会把 ASR / polish +/// 内容意外灌进日志(pr_agent 提的 forward-looking 隐患);这里只输出状态名。 +fn capsule_state_log_name(state: CapsuleState) -> &'static str { + match state { + CapsuleState::Idle => "idle", + CapsuleState::Recording => "recording", + CapsuleState::Transcribing => "transcribing", + CapsuleState::Polishing => "polishing", + CapsuleState::Done => "done", + CapsuleState::Cancelled => "cancelled", + CapsuleState::Error => "error", + } +} + fn show_capsule_window_for_recording( app: &AppHandle, window: &tauri::WebviewWindow, @@ -4239,7 +4254,8 @@ fn emit_capsule( // 发 / state 一直 Idle 这几类常见 root cause。issue #470。 if !CAPSULE_FIRST_SHOW_LOGGED.swap(true, Ordering::SeqCst) { log::info!( - "[capsule] first show this session: show_capsule=true visible=true state={state:?}" + "[capsule] first show this session: show_capsule=true visible=true state={}", + capsule_state_log_name(state) ); } show_capsule_window_for_recording(&app_for_main, &window); @@ -4256,7 +4272,8 @@ fn emit_capsule( && !CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED.swap(true, Ordering::SeqCst) { log::info!( - "[capsule] suppressed by user toggle: show_capsule=false visible=true state={state:?}" + "[capsule] suppressed by user toggle: show_capsule=false visible=true state={}", + capsule_state_log_name(state) ); } hide_capsule_window_if_present(); From e2cf2ab2fa403056dbc6750ecc5aaffe8b0de758 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 18 May 2026 10:12:32 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(windows-qa):=20=E7=94=A8=20Tauri=20?= =?UTF-8?q?=E7=9A=84=20show=20+=20set=5Ffocus=20=E6=8A=BD=E8=B1=A1?= =?UTF-8?q?=E4=BB=A3=E6=9B=BF=E7=9B=B4=E8=BF=9E=20Win32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr_agent 二轮关注点:直接 SetFocus(host_hwnd) + SetForegroundWindow 在 Windows 上有两个潜在隐患: 1. WebView2 子窗口有独立 focus 模型,host HWND 拿到焦点不保证 webview 收到键盘事件 → ESC 仍可能收不到。 2. Win11 focus-stealing prevention 可能拒绝 SetForegroundWindow, 导致整个抢前台序列被 OS 静默吞掉。 切换到 Tauri 的 window.show() + window.set_focus(): - Tauri 内部专门处理 WebView2 child window 的 focus 路由 - 内部对 SetForegroundWindow 失败做了 SPI / AttachThreadInput 兜底 - 还顺便去掉了 raw_window_handle / windows-rs 的直连依赖噪音 行为等价,但走的是 Tauri 跨平台抽象 → 更稳,且后续 Tauri 升级时跟着 获益。注释里把决策理由写明,下次有人想恢复 Win32 直连前再想想。 refs #466 --- openless-all/app/src-tauri/src/lib.rs | 44 ++++++++++----------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 3ee62bcf..5bffa9c4 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -1152,36 +1152,24 @@ pub(crate) fn refocus_qa_window(_app: &AppHandle) {} #[cfg(target_os = "windows")] fn show_qa_window_no_activate(window: &tauri::WebviewWindow) -> bool { - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - use windows::Win32::Foundation::HWND; - use windows::Win32::UI::Input::KeyboardAndMouse::SetFocus; - use windows::Win32::UI::WindowsAndMessaging::{SetForegroundWindow, ShowWindow, SW_SHOW}; - - let Ok(handle) = window.window_handle() else { - return false; - }; - let RawWindowHandle::Win32(raw) = handle.as_raw() else { - return false; - }; - let hwnd = HWND(raw.hwnd.get() as *mut _); - if hwnd.0.is_null() { + // 函数名沿用历史命名,实际行为已切到「show + focus」—— 让 QA webview 真正拿到键盘 + // 焦点,ESC 才能到 React 监听、X 按钮 first-click 才不会被 OS 当作激活点击吃掉。 + // + // 走 Tauri 的 show() / set_focus() 而不是 Win32 SetForegroundWindow + SetFocus + // 的原因(pr_agent 关注点二轮回应): + // - 直接 SetFocus(host_hwnd) 不保证 WebView2 child 收键盘事件,WebView2 子窗口 + // 有自己的 focus 模型。Tauri 内部走 webview 专用路径,能把焦点真正送到 webview。 + // - SetForegroundWindow 在 Win11 focus-stealing prevention 下可能被拒。Tauri + // 2.x 在跨平台 abstraction 里做了兜底(按 SPI 临时调整 / attach input queue)。 + // + // 对 issue #164 "QA 浮窗不抢前台 app 焦点"的取舍:浮窗出现时会短暂成为前台, + // 但 begin_qa_session 抓选区前 focus-dance 会把焦点临时还给用户原 app(见 + // coordinator.rs 同 issue 注释),抓完再 refocus_qa_window 收回 —— 选区路径 + // 仍能正常工作,issue #164 在「QA 出现的那一帧」短暂被违背是 #466 修复的代价。 + if window.show().is_err() { return false; } - - // 必须让 QA 浮窗拿到键盘焦点:否则 ESC 收不到、X 按钮 first-click 会被 OS 当作激活 - // 事件吃掉 → 用户体感是"关不掉"。tauri.conf.json 里 `focus: false` 控制的是创建时 - // 不激活,这里 show 时主动 SetForegroundWindow + SetFocus 把焦点交给 webview。 - // - // 对 issue #164 "QA 浮窗不抢前台 app 焦点"的取舍说明: - // 浮窗出现时确实会成为前台,但 begin_qa_session 走 Ctrl+C 路径抓选区那一刻, - // 用户原 app 仍是 simulate_copy 目标(前台是 QA,但 SendInput 是把全局键盘事件 - // 发到当前焦点窗口 —— 即 QA 自己 → 抓不到)。当前实现存在已知局限,但相比 - // "X / ESC 完全无法关闭浮窗"的回归更轻。后续如需恢复"选区抓取不被打断"语义, - // 在 begin_qa_session 期间临时把焦点还给 saved front app HWND 再回收即可。 - // issue #466。 - let _ = unsafe { ShowWindow(hwnd, SW_SHOW) }; - let _ = unsafe { SetForegroundWindow(hwnd) }; - let _ = unsafe { SetFocus(hwnd) }; + let _ = window.set_focus(); true } From 2c03c17d7d9a94651b6d1e793b8cb5d12159283d Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 18 May 2026 10:24:50 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(windows-ime):=20=E7=8E=B0=E4=BB=A3=20?= =?UTF-8?q?ActivateProfile=20=E5=A4=B1=E8=B4=A5=E9=99=8D=E4=B8=BA=20warn?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E9=98=BB=E7=A2=8D=20legacy=20=E5=B7=B2?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=9A=84=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr_agent 关注点:restore_profile 的"先 legacy 再 modern"调用序列里, 如果 modern ActivateProfile 失败,前两步 ChangeCurrentLanguage + ActivateLanguageProfile 已经成功 —— OS 视觉层(语言指示器、键盘事件 路由)走 legacy 视图,用户已经看到 IME 切回去了。现代 API 失败只是 内部 bookkeeping 跟不上,不会让用户感知"还停在 OpenLess"。 旧实现把 modern 失败当整体失败 propagate 出去,caller restore_session 会打一条 "restore saved profile failed" 的 warn,误导排查方向。 改成:现代 ActivateProfile 失败时在本函数内打 warn(信息更具体,明 确说明 legacy 已 OK),return Ok。caller 看到 Ok = "用户感知已切回"。 两个 ImeProfileKind 分支都同步改。 21 个 windows_ime 单元测试全过(测的是 restore_decision / 状态机, 不直接 mock Win32 调用,行为不受影响)。 refs #469 --- .../app/src-tauri/src/windows_ime_profile.rs | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/windows_ime_profile.rs b/openless-all/app/src-tauri/src/windows_ime_profile.rs index 6b376a01..f7f08bf2 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -324,6 +324,12 @@ mod windows_impl { // 单独调现代 ITfInputProcessorProfileMgr::ActivateProfile 不会更新 legacy // current language / active profile 状态,OS 仍认 OpenLess 是当前输入法 → // 用户的输入法切不回去。issue #469。 + // + // 现代 ActivateProfile 失败降级为 warn:legacy 两步成功后,OS 视觉层已经把用户 + // 原 IME 切回(语言指示器、键盘事件路由都走 legacy 视图);现代 API 失败只是内部 + // bookkeeping 不同步,不会让用户看到"还停在 OpenLess"。所以这一步降级为 warn, + // 不让 caller 把"已经切回了但 bookkeeping 慢"误判成"切回完全失败"。pr_agent + // partial-restore 关注点回应。 match snapshot.kind() { ImeProfileKind::TextService => { let clsid = parse_required_guid("text service CLSID", snapshot.clsid())?; @@ -336,7 +342,7 @@ mod windows_impl { profiles.ActivateLanguageProfile(&clsid, lang_id, &profile_guid) })?; - with_profile_manager(|manager| unsafe { + let modern_result = with_profile_manager(|manager| unsafe { manager.ActivateProfile( TF_PROFILETYPE_INPUTPROCESSOR, lang_id, @@ -345,7 +351,13 @@ mod windows_impl { null_hkl(), PROFILE_RESTORE_FLAGS, ) - }) + }); + if let Err(err) = modern_result { + log::warn!( + "[windows-ime] legacy restore OK but modern ActivateProfile failed: {err}" + ); + } + Ok(()) } ImeProfileKind::KeyboardLayout => { let hkl = HKL(snapshot.hkl().unwrap_or_default() as *mut c_void); @@ -356,7 +368,7 @@ mod windows_impl { profiles.ChangeCurrentLanguage(lang_id) })?; - with_profile_manager(|manager| unsafe { + let modern_result = with_profile_manager(|manager| unsafe { manager.ActivateProfile( TF_PROFILETYPE_KEYBOARDLAYOUT, lang_id, @@ -365,7 +377,13 @@ mod windows_impl { hkl, PROFILE_RESTORE_FLAGS, ) - }) + }); + if let Err(err) = modern_result { + log::warn!( + "[windows-ime] legacy restore OK but modern ActivateProfile (keyboard) failed: {err}" + ); + } + Ok(()) } } } From 205b219ec8980fb305e7acb0bcb699f22eff623a Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 18 May 2026 10:38:04 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(windows-qa):=20=E5=A4=9A=E8=BD=AE=20Q?= =?UTF-8?q?A=20=E6=AF=8F=E8=BD=AE=E5=88=B7=E6=96=B0=20qa=5Ffocus=5Ftarget?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=20stale=20HWND?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr_agent 关注点:qa_focus_target 只在 open_qa_panel 抓一次,多轮 QA 里用户开 QA 后切到别的 app(App B)再按 Option,focus-dance 会把焦点 抢回 App A,capture_selection 抓的是 A 的选区而不是 B 的,多轮新选区 直接丢。 策略:每轮 begin_qa_session 开头看「当前前台是不是本进程的窗口」: - 是 OpenLess 自家窗口(QA / capsule / main)→ 用户没切走,沿用 saved。 - 是别的 app → 用户主动切到了真正的外部 app,刷新 saved 为这个 HWND。 判断走 GetWindowThreadProcessId + GetCurrentProcessId 对比 pid,比维护 QA 窗口 HWND list 简单且安全(capsule / 未来其他自家窗口都自动覆盖)。 新增 helper capture_external_focus_target() —— 与 capture_focus_target() 对称,专门跳过本进程窗口。非 Windows 平台返回 None。 refs #466 --- openless-all/app/src-tauri/src/coordinator.rs | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c245dcfb..c2a131fe 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2572,10 +2572,18 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { // AX/Cmd+C fallback 都能拿到。 // - Windows:#466 修复后 show_qa_window_no_activate 主动抓焦点,QA 此刻已是前台, // simulate_copy 会跑在 QA 自己 webview 上 → 抓不到。focus-dance 上半场:把焦点临时 - // 还给 open_qa_panel 时记下的 saved HWND,抓完选区后下半场再把焦点交还 QA,让 ESC/X - // 继续可用。多轮场景下用户自己切回原 app 时 saved == current,restore 是 no-op。 + // 还给"用户原 app 的 HWND"。 + // + // 多轮场景的目标刷新:用户开 QA 后可能 Alt+Tab 切到别的 app 选新文字。如果还死认 + // open_qa_panel 时记下的初始 HWND,会把焦点抢回错的 app(pr_agent stale-focus 关注点)。 + // 策略:每轮先看当前前台是不是本进程的窗口(QA / capsule / main)—— 是 → 用户没切 + // 走,沿用 saved;不是 → 用户切到了真正的外部 app,刷新 saved 为当前 HWND。 + // 抓完选区后下半场再把焦点交还 QA,让 ESC/X 继续可用。 #[cfg(target_os = "windows")] { + if let Some(current_external) = capture_external_focus_target() { + inner.qa_state.lock().qa_focus_target = Some(current_external); + } let saved_target = inner.qa_state.lock().qa_focus_target; let _ = restore_focus_target_if_possible(saved_target); } @@ -3891,6 +3899,33 @@ fn schedule_capsule_idle(inner: &Arc, delay_ms: u64) { }); } +/// 与 capture_focus_target 类似,但前台窗口属于本进程(即用户停在 QA / capsule / main +/// 等自家窗口)时返回 None,让 caller 区分"用户没切到别处" vs "用户切到了另一个真正的 +/// 外部 app"。issue #466 多轮场景下用来刷新 qa_focus_target。 +#[cfg(target_os = "windows")] +fn capture_external_focus_target() -> Option { + use windows::Win32::System::Threading::GetCurrentProcessId; + use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId}; + + unsafe { + let hwnd = GetForegroundWindow(); + if hwnd.0.is_null() { + return None; + } + let mut pid: u32 = 0; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + if pid == GetCurrentProcessId() { + return None; + } + Some(hwnd.0 as usize) + } +} + +#[cfg(not(target_os = "windows"))] +fn capture_external_focus_target() -> Option { + None +} + #[cfg(target_os = "windows")] fn capture_focus_target() -> Option { use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; From 6c06b2fa4a2c416cd068e73bac2ee15941887d42 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 18 May 2026 11:19:27 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix(windows-qa):=20begin=5Fqa=5Fsession?= =?UTF-8?q?=20=E5=90=88=E5=B9=B6=20qa=5Fstate=20=E4=B8=A4=E6=AC=A1=20lock?= =?UTF-8?q?=20=E6=B6=88=20TOCTOU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud 评审指出:focus-dance 多轮刷新里原来分两段: lock #1 写 qa_focus_target ← capture_external_focus_target() lock #2 读 qa_focus_target → restore_focus_target_if_possible 两次 lock 中间,close_qa_panel 在别的线程把 qa_focus_target 清成 None 时会被覆盖回旧 HWND。虽然"按关闭"和"按 Option"同时触发的几率极低, 但属于可消除的竞态,1 行改动就能根治。 合一个 scope:在持锁期间既写最新外部前台、又读出来交给 restore。 capture_external_focus_target() 只调 GetForegroundWindow / GetCurrentProcessId 等纯查询,不会反向取 qa_state 锁,持锁期间调用安全。 refs #466 --- openless-all/app/src-tauri/src/coordinator.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c2a131fe..65e23f7c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2581,10 +2581,18 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { // 抓完选区后下半场再把焦点交还 QA,让 ESC/X 继续可用。 #[cfg(target_os = "windows")] { - if let Some(current_external) = capture_external_focus_target() { - inner.qa_state.lock().qa_focus_target = Some(current_external); - } - let saved_target = inner.qa_state.lock().qa_focus_target; + // 合并两次 lock:原来分 lock #1 写 + lock #2 读,两者之间 close_qa_panel 在别的 + // 线程把 qa_focus_target 清成 None 会被覆盖回旧 HWND。Cloud 评审指出的 TOCTOU。 + // 单次加锁里既写最新外部前台、再读出来交给后面的 restore_focus_target_if_possible + // —— capture_external_focus_target() 内部只调 GetForegroundWindow / pid 查询, + // 不会反向取 qa_state 锁,持锁期间调用安全。 + let saved_target = { + let mut state = inner.qa_state.lock(); + if let Some(current_external) = capture_external_focus_target() { + state.qa_focus_target = Some(current_external); + } + state.qa_focus_target + }; let _ = restore_focus_target_if_possible(saved_target); } let selection = capture_selection();