fix(windows): 静默启动 #468 最后一公里 - 前端兜底 (#474)

* fix(windows): 修复 #466 #468 #469 IME 切回 / 静默启动 / QA 焦点 + #470 capsule 诊断 (#471)

集中修复 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 诊断手段。

* chore(release): bump version to 1.3.4-3 (Beta) — Windows #466 #468 #469 修复

* fix(settings-ui): 权限页徽章不换行 + 本地 ASR 区块 Win 灰显

权限页 (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 条件里,本次
  纯加视觉灰显。

* fix(windows): 前端 App.tsx 在 show 主窗口前读 startMinimized 兜底

#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 双端都收敛了)

---------

Co-authored-by: baiqing <lbx12309@icloud.com>
This commit is contained in:
TRIPandbaiqing authored and GitHub committed 2026-05-18 15:29:33 +08:00
1 parent ceda87f64f
commit b4e806e3ca
10 files changed
+217 -36

No files matched your search

+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "openless-app",
"private": true,
"version": "1.3.4-2",
"version": "1.3.4-3",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -3666,7 +3666,7 @@ dependencies = [
[[package]]
name = "openless"
version = "1.3.4-2"
version = "1.3.4-3"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -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"
+97 -1
View File
@@ -94,6 +94,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<R: tauri::Runtime>(
app: &AppHandle<R>,
@@ -2617,8 +2634,39 @@ async fn begin_qa_session(inner: &Arc<Inner>) -> 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();
@@ -3929,6 +3977,33 @@ fn schedule_capsule_idle(inner: &Arc<Inner>, 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<usize> {
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<usize> {
None
}
#[cfg(target_os = "windows")]
fn capture_focus_target() -> Option<usize> {
use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
@@ -4304,12 +4379,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();
}
@@ -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<SelectionContext>,
pub(super) front_app: Option<String>,
/// 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<usize>,
/// 用于忽略迟到的 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<Inner>) {
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<Inner>) {
state.messages.clear();
state.selection = None;
state.front_app = None;
state.qa_focus_target = None;
state.phase = QaPhase::Idle;
state.cancelled = false;
}
+42 -14
View File
@@ -85,6 +85,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::<Arc<coordinator::Coordinator>>()
.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"
);
@@ -1117,24 +1131,38 @@ pub(crate) fn hide_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) {
}
}
/// 抓完选区后把焦点重新交回 QA 浮窗(Windows focus-dance 下半场)。begin_qa_session
/// 在 capture_selection 跑完时调;非 Windows 平台是 no-op。issue #466。
#[cfg(target_os = "windows")]
pub(crate) fn refocus_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) {
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<R: tauri::Runtime>(_app: &AppHandle<R>) {}
#[cfg(target_os = "windows")]
fn show_qa_window_no_activate<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> bool {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOWNOACTIVATE};
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;
}
let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) };
let _ = window.set_focus();
true
}
@@ -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_profile_manager(|manager| unsafe {
with_input_processor_profiles(|profiles| unsafe {
profiles.ChangeCurrentLanguage(lang_id)
})?;
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(())
}
}
}
+1 -1
View File
@@ -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",
+21 -8
View File
@@ -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;