diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 41702dfe..65e23f7c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -91,6 +91,23 @@ 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); + +/// 给 #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, @@ -2550,8 +2567,39 @@ 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 上半场:把焦点临时 + // 还给"用户原 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")] + { + // 合并两次 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(); + #[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(); @@ -3859,6 +3907,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; @@ -4217,12 +4292,33 @@ 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={}", + capsule_state_log_name(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={}", + capsule_state_log_name(state) + ); + } hide_capsule_window_if_present(); let _ = window.hide(); } 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 adea1faf..5bffa9c4 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" ); @@ -1124,24 +1138,38 @@ 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")] -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}; +pub(crate) fn refocus_qa_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("qa") { + let _ = show_qa_window_no_activate(&window); + } +} - 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() { +#[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 { + // 函数名沿用历史命名,实际行为已切到「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; } - - let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) }; + let _ = window.set_focus(); true } 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..f7f08bf2 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -319,37 +319,71 @@ 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。 + // + // 现代 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())?; let profile_guid = parse_required_guid("text service profile GUID", snapshot.profile_guid())?; + let lang_id = snapshot.lang_id(); - with_profile_manager(|manager| unsafe { + with_input_processor_profiles(|profiles| unsafe { + profiles.ChangeCurrentLanguage(lang_id)?; + profiles.ActivateLanguageProfile(&clsid, lang_id, &profile_guid) + })?; + + let modern_result = with_profile_manager(|manager| unsafe { manager.ActivateProfile( TF_PROFILETYPE_INPUTPROCESSOR, - snapshot.lang_id(), + lang_id, &clsid, &profile_guid, 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); 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 { + let modern_result = with_profile_manager(|manager| unsafe { manager.ActivateProfile( TF_PROFILETYPE_KEYBOARDLAYOUT, - snapshot.lang_id(), + lang_id, &zero_guid, &zero_guid, hkl, PROFILE_RESTORE_FLAGS, ) - }) + }); + if let Err(err) = modern_result { + log::warn!( + "[windows-ime] legacy restore OK but modern ActivateProfile (keyboard) failed: {err}" + ); + } + Ok(()) } } }