From e0e645331513f1c74f98b6f2d5aa1a2823c1063e Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Mon, 18 May 2026 11:32:40 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(windows):=20=E4=BF=AE=E5=A4=8D=20#466?= =?UTF-8?q?=20#468=20#469=20IME=20=E5=88=87=E5=9B=9E=20/=20=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E5=90=AF=E5=8A=A8=20/=20QA=20=E7=84=A6=E7=82=B9=20+?= =?UTF-8?q?=20#470=20capsule=20=E8=AF=8A=E6=96=AD=20(#471)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 集中修复 4 个 Windows-only bug: - #469 IME 切不回 — restore_profile 对称补 legacy TSF ChangeCurrentLanguage + ActivateLanguageProfile,回归点是 c3b59329 升级激活路径时漏改恢复路径。现代 ActivateProfile 失败降为 warn(legacy 已完成视觉切回)。 - #468 Win11 静默启动失效 — single-instance 回调读 start_minimized,true 时跳过 show_main_window;托盘 / CLI intent 路径不受影响。 - #466 QA 弹窗 X/ESC 关不掉 — Win 上从 SW_SHOWNOACTIVATE 切到 Tauri window.show + set_focus,让 webview 拿键盘焦点。配套 focus-dance:begin_qa_session 抓选区前临时把焦点还给用户原 app,抓完再 refocus QA;多轮场景下用 GetWindowThreadProcessId 区分本进程窗口,刷新 qa_focus_target,避免 stale HWND。 - #470 录音胶囊不显示 — 加 per-process 一次性诊断 log(CapsuleState 用 capsule_state_log_name 显式短名,不走 Debug 防字段扩展时泄露),下次能直接定位 prefs / state / Win32 哪一环失败。 pr_agent 五轮迭代到 No major issues + No security concerns;Cloud 评审建议的 begin_qa_session 双锁 TOCTOU 已合并为单 scope。三平台 build + pr_agent CI 全绿。 #470 没标 closes,是 refs—需要真机日志才能定位真因,先 ship 诊断手段。 --- openless-all/app/src-tauri/src/coordinator.rs | 98 ++++++++++++++++++- .../app/src-tauri/src/coordinator/qa.rs | 12 ++- openless-all/app/src-tauri/src/lib.rs | 56 ++++++++--- .../app/src-tauri/src/windows_ime_profile.rs | 46 +++++++-- 4 files changed, 190 insertions(+), 22 deletions(-) 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(()) } } } From 42a7530cd170aae7b48ac647f51ecf5857dcc4f2 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 18 May 2026 11:34:27 +0800 Subject: [PATCH 2/4] =?UTF-8?q?chore(release):=20bump=20version=20to=201.3?= =?UTF-8?q?.4-3=20(Beta)=20=E2=80=94=20Windows=20#466=20#468=20#469=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 b4e8a01a..ce41bdab 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "1.3.4-2", + "version": "1.3.4-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.3.4-2", + "version": "1.3.4-3", "dependencies": { "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-autostart": "^2.5.1", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 27ac0df4..82e7759e 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -1,7 +1,7 @@ { "name": "openless-app", "private": true, - "version": "1.3.4-2", + "version": "1.3.4-3", "type": "module", "scripts": { "dev": "vite", diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 0e5eb23b..380a44f6 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -3751,7 +3751,7 @@ dependencies = [ [[package]] name = "openless" -version = "1.3.4-2" +version = "1.3.4-3" dependencies = [ "anyhow", "arboard", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 7d3c7352..b747762b 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.3.4-2" +version = "1.3.4-3" 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 c5bbfeb1..816e93bc 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.3.4-2", + "version": "1.3.4-3", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev", From be24bb7b952c746f666ad8af0bcab529b3fa7bf8 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 18 May 2026 12:50:28 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(settings-ui):=20=E6=9D=83=E9=99=90?= =?UTF-8?q?=E9=A1=B5=E5=BE=BD=E7=AB=A0=E4=B8=8D=E6=8D=A2=E8=A1=8C=20+=20?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=20ASR=20=E5=8C=BA=E5=9D=97=20Win=20=E7=81=B0?= =?UTF-8?q?=E6=98=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 权限页 (Settings → 快捷键 → 权限): - Pill 组件加 whiteSpace:nowrap + flexShrink:0 → "已安装"徽章在 Win 窄宽下不再被挤成 3 行(每个汉字一行)。 - 行内 message span 加 whiteSpace:nowrap + overflow:hidden + textOverflow:ellipsis + minWidth:0 → 长文本("Windows 低层键盘 hook 已安装")超宽时省略号收尾,徽章保持完整。 - zh-CN 描述大段精简:descAcc / descNoAcc / micDesc / accDesc / hotkeyDesc / networkDesc / windowsImeDesc / windowsIme.* 都改短, 减少 Win 上拥挤感(用户反馈 zh 文案过长)。 高级页 (Settings → 高级 → 本地 ASR): - Windows 上把"本地 ASR 模型(实验性)"标题区 + 警告小字 + Qwen3 行 整组 opacity:0.45 灰显 —— Qwen3 在 Win 是 stub 不支持,那条"实验性" 主线在 Win 没意义;用户视觉关注点应落到下方独立的 Foundry 行(Foundry 保持正常颜色)。Toggle 的 disabled 行为已经在原 onToggle 条件里,本次 纯加视觉灰显。 --- openless-all/app/src/i18n/zh-CN.ts | 20 ++++----- openless-all/app/src/pages/_atoms.tsx | 2 + .../src/pages/settings/AdvancedSection.tsx | 41 +++++++++++-------- .../src/pages/settings/PermissionsSection.tsx | 12 +++++- 4 files changed, 47 insertions(+), 28 deletions(-) diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index eee752ef..51e94e24 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -686,17 +686,17 @@ export const zhCN = { }, permissions: { title: '权限', - descAcc: 'OpenLess 需要以下系统权限才能正常工作。授权后通常需要完全退出 App 重启一次才生效。', - descNoAcc: 'OpenLess 需要麦克风可用,并依赖全局快捷键监听状态判断 native hook 是否正常工作。', + descAcc: 'OpenLess 需要以下系统权限。授权后通常要完全退出 App 重启一次才生效。', + descNoAcc: '麦克风必需;全局快捷键状态用来检测 native hook 是否运行。', micLabel: '麦克风', micDesc: '用于捕获你的语音输入。', accLabel: '辅助功能', - accDesc: '用于监听全局快捷键并将识别结果写入光标位置。', + accDesc: '监听全局快捷键并把识别结果写入光标。', hotkeyLabel: '全局快捷键', - hotkeyDescWithAdapter: '当前适配器:{{adapter}}。用于判断快捷键监听是否已经安装。', - hotkeyDescPlain: '用于判断快捷键监听是否已经安装。', + hotkeyDescWithAdapter: '适配器:{{adapter}}。', + hotkeyDescPlain: '判断快捷键监听是否已安装。', networkLabel: '网络', - networkDesc: '云端 ASR / LLM 调用所必需。本地模式可关闭。', + networkDesc: '云端 ASR / LLM 必需,本地模式可关。', networkOk: '可用', checking: '检查中…', granted: '已授权', @@ -709,13 +709,13 @@ export const zhCN = { hotkeyStarting: '安装中…', hotkeyFailed: '监听失败', windowsImeLabel: 'Windows 输入法后端', - windowsImeDesc: '用于在语音会话期间临时切换到 OpenLess TSF 输入法,避免剪贴板插入限制。', + windowsImeDesc: '语音输入时临时切到 OpenLess TSF,绕过剪贴板限制。', windowsImeInstalled: '已安装', windowsImeUnavailable: '不可用', windowsIme: { - installed: '已安装。语音输入时会临时切换到 OpenLess 输入法。', - notInstalled: '未安装。OpenLess 正在使用剪贴板 / WM_PASTE 兜底。', - registrationBroken: '注册已损坏。请重新安装 OpenLess 输入法。', + installed: '已安装,按需切到 OpenLess 输入法。', + notInstalled: '未安装,走剪贴板 / WM_PASTE 兜底。', + registrationBroken: '注册损坏,请重装 OpenLess 输入法。', notWindows: '仅 Windows 可用。', }, }, diff --git a/openless-all/app/src/pages/_atoms.tsx b/openless-all/app/src/pages/_atoms.tsx index 7f2de1fc..9f1bc75d 100644 --- a/openless-all/app/src/pages/_atoms.tsx +++ b/openless-all/app/src/pages/_atoms.tsx @@ -90,6 +90,8 @@ export function Pill({ children, tone = 'default', size = 'md', style }: PillPro color: t.color, border: t.bd === 'transparent' ? '0.5px solid transparent' : `0.5px solid ${t.bd}`, fontWeight: 500, + whiteSpace: 'nowrap', + flexShrink: 0, ...sz, ...style, }} diff --git a/openless-all/app/src/pages/settings/AdvancedSection.tsx b/openless-all/app/src/pages/settings/AdvancedSection.tsx index 54955307..7da5109a 100644 --- a/openless-all/app/src/pages/settings/AdvancedSection.tsx +++ b/openless-all/app/src/pages/settings/AdvancedSection.tsx @@ -156,9 +156,13 @@ export function AdvancedSection() { - {/* 标题 + 右上角 inline 警告小字(替换原琥珀大警告条)。 */} + {/* 标题 + 右上角 inline 警告小字(替换原琥珀大警告条)。 + Windows:标题区整体灰显 —— "本地 ASR 模型(实验性)" 在 Win 上几乎只有 + Qwen3 占位、本平台暂不支持;Foundry 走的是另一条独立路径,不属于"实验性" + 框架。灰显视觉让用户知道这条"实验性"主线在 Win 不可用,关注点转到下方 + Foundry 行。 */}
-
+
{t('settings.advanced.localAsrTitle')}
{t('settings.advanced.localAsrDesc')} @@ -173,6 +177,7 @@ export function AdvancedSection() { flexShrink: 0, maxWidth: '52%', paddingTop: 2, + opacity: isWin ? 0.45 : 1, }}> ⚠️ {t('settings.advanced.localAsrWarningShort')}
@@ -188,20 +193,24 @@ export function AdvancedSection() { + 不可点 + desc=notSupportedHere,跟"本平台不可用"视觉一致。跨平台 异常(Windows profile 同步到 local-qwen3)时 active 状态靠下方独立 "禁用本地 ASR" 行兜底,避免 Toggle ON + desc 说不支持的自相矛盾感 - (pr_agent #403 'Stale Windows state' 修法)。 */} - -
- { - if (next) requestEnable('local-qwen3'); - else void performSwitch('volcengine'); - } : undefined} - /> -
-
+ (pr_agent #403 'Stale Windows state' 修法)。 + Windows 整行灰显,跟"本地 ASR 实验性"标题区视觉对齐 —— 用户一眼看出 + 这条线在 Win 上不能用,关注点落到下方 Foundry 行。 */} +
+ +
+ { + if (next) requestEnable('local-qwen3'); + else void performSwitch('volcengine'); + } : undefined} + /> +
+
+
{/* Foundry 行 —— 仅 Windows 露出(macOS 不展示 Windows 端模型内容)。 */} {isWin && ( diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index 014f57d1..2719246c 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -126,7 +126,11 @@ export function PermissionsSection() { >
{hotkey?.message && ( - + {hotkey.message} )} @@ -140,7 +144,11 @@ export function PermissionsSection() { >
{windowsIme && ( - + {t(`settings.permissions.windowsIme.${windowsIme.state}`)} )} From ca5c6a6869d998828eb3f9b8a634b6147d104389 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 18 May 2026 13:56:02 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(windows):=20=E5=89=8D=E7=AB=AF=20App.ts?= =?UTF-8?q?x=20=E5=9C=A8=20show=20=E4=B8=BB=E7=AA=97=E5=8F=A3=E5=89=8D?= =?UTF-8?q?=E8=AF=BB=20startMinimized=20=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #471 在 Rust 端 setup() / single-instance callback 都加了 start_minimized suppression,本机日志确认 Rust 路径完全正确("[main] start_minimized=true → 跳过初始 show")。但用户在 Win11 1.3.4-3 上仍然「重启进桌面那一刻主 窗口就出来了」。 定位到最后一条遗漏路径在前端:App.tsx 里 mount 时一条 useEffect 无条件 通过 IPC 调 `getCurrentWindow().show()`,把 Rust 端已经 suppress 的窗口 又拉出来 —— 这条路径 Rust log 完全看不见(走的是 plugin-window 的 showWindow IPC,不经过我们的 show_main_window helper)。 那条 useEffect 当初是给 issue #163 引入的:Windows 权限探测可能死锁让 首屏卡灰,所以前端兜底 force show 一次。 修法:show 前先读 prefs.startMinimized,true 就 return。读 prefs 失败 (极少数情况)保持原 show 行为,不让 #163 的兜底语义丢失。 closes #468 (the actual one this time, Rust + JS 双端都收敛了) --- openless-all/app/src/App.tsx | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index af14908e..40231e5b 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -8,6 +8,7 @@ import { checkAccessibilityPermission, checkMicrophonePermission, getHotkeyStatus, + getSettings, handleWindowHotkeyEvent, isTauri, } from './lib/ipc'; @@ -43,14 +44,26 @@ export function App({ isCapsule, isQa }: AppProps) { let cancelled = false; requestAnimationFrame(() => { if (cancelled) return; - import('@tauri-apps/api/window') - .then(async ({ getCurrentWindow }) => { - const currentWindow = getCurrentWindow(); - if (!(await currentWindow.isVisible())) { - await currentWindow.show(); - } - }) - .catch(error => console.warn('[startup] show main window failed', error)); + (async () => { + // 尊重 prefs.startMinimized:开了静默启动就别在前端强 show 主窗口。否则 + // Rust 端 setup() 抑制掉的窗口,会被这条 useEffect 在 webview 加载完成后 + // 再通过 IPC 拉出来 —— issue #468 在 Rust 修复后用户仍能在 Win11 上复现 + // 的最后一条路径(Rust log 里看不到,因为走的是 plugin-window 的 IPC)。 + try { + const prefs = await getSettings(); + if (prefs.startMinimized) return; + } catch (err) { + // 读 prefs 失败兜底走原有 show 行为:让权限探测失败的用户也能进 UI, + // 避免透明 / 空白窗口前卡死(issue #163 引入这个 show 的原始意图)。 + console.warn('[startup] read startMinimized failed, falling back to show', err); + } + const { getCurrentWindow } = await import('@tauri-apps/api/window'); + if (cancelled) return; + const currentWindow = getCurrentWindow(); + if (!(await currentWindow.isVisible())) { + await currentWindow.show(); + } + })().catch(error => console.warn('[startup] show main window failed', error)); }); return () => { cancelled = true;