Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions openless-all/app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion openless-all/app/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "openless-app",
"private": true,
"version": "1.3.4-2",
"version": "1.3.4-3",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
98 changes: 97 additions & 1 deletion openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<R: tauri::Runtime>(
app: &AppHandle<R>,
Expand Down Expand Up @@ -2550,8 +2567,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();

Expand Down Expand Up @@ -3859,6 +3907,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;
Expand Down Expand Up @@ -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();
}
Expand Down
12 changes: 11 additions & 1 deletion openless-all/app/src-tauri/src/coordinator/qa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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 时不自动隐藏。
Expand All @@ -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,
Expand Down Expand Up @@ -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 状态从干净起跑
Expand Down Expand Up @@ -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;
}
Expand Down
56 changes: 42 additions & 14 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<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"
);
Expand Down Expand Up @@ -1124,24 +1138,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")]
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};
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);
}
}

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<R: tauri::Runtime>(_app: &AppHandle<R>) {}

#[cfg(target_os = "windows")]
fn show_qa_window_no_activate<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> 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
}

Expand Down
46 changes: 40 additions & 6 deletions openless-all/app/src-tauri/src/windows_ime_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading