diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 5f5db4a2..28965d4e 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -63,6 +63,7 @@ use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionCon mod dictation; mod hotkey_supervisors; +mod ime_insertion; mod qa; mod resources; mod voice_agent_hotkeys; @@ -70,6 +71,7 @@ mod voice_agent_hotkeys; // glob 重导出:让 dictation.rs/qa.rs/resources.rs/impl 里所有 `super::裸名` // 引用继续通过父模块解析(拆分前的 `use super::*` 契约)。 pub(crate) use hotkey_supervisors::*; +pub(crate) use ime_insertion::*; pub(crate) use voice_agent_hotkeys::*; #[cfg(test)] @@ -1194,231 +1196,6 @@ fn spawn_qa_recorder_error_monitor(inner: &Arc, rx: mpsc::Receiver, - session_id: SessionId, - prepared: PreparedWindowsImeSession, -) { - slots.retain(|slot| slot.session_id != session_id); - slots.push(PreparedWindowsImeSessionSlot { - session_id, - prepared, - }); -} - -#[cfg(target_os = "windows")] -fn take_matching_prepared_windows_ime_session( - slots: &mut Vec, - session_id: SessionId, -) -> Option { - let index = slots - .iter() - .position(|slot| slot.session_id == session_id)?; - Some(slots.remove(index).prepared) -} - -#[cfg(target_os = "windows")] -fn take_current_prepared_windows_ime_session_for_restore( - slots: &mut Vec, - session_id: SessionId, - current_session_id: SessionId, -) -> Option { - let prepared = take_matching_prepared_windows_ime_session(slots, session_id)?; - if current_session_id == session_id { - Some(prepared) - } else { - None - } -} - -#[cfg(target_os = "windows")] -fn restore_prepared_windows_ime_session(inner: &Arc, session_id: SessionId) { - let state = inner.state.lock(); - let prepared = { - let mut slot = inner.prepared_windows_ime_session.lock(); - take_current_prepared_windows_ime_session_for_restore( - &mut slot, - session_id, - state.session_id, - ) - }; - if let Some(prepared) = prepared { - inner.windows_ime.restore_session(prepared); - } -} - -#[cfg(not(target_os = "windows"))] -fn restore_prepared_windows_ime_session(_inner: &Arc, _session_id: SessionId) {} - -#[cfg(target_os = "windows")] -async fn insert_with_windows_ime_first( - inner: &Arc, - session_id: SessionId, - polished: &str, - restore_clipboard: bool, - allow_non_tsf_insertion_fallback: bool, - paste_shortcut: PasteShortcut, - ime_target: Option, -) -> InsertStatus { - let prepared = { - let mut slot = inner.prepared_windows_ime_session.lock(); - take_matching_prepared_windows_ime_session(&mut slot, session_id) - }; - let Some(prepared) = prepared else { - log::warn!("[windows-ime] no prepared TSF session for this dictation"); - if should_try_non_tsf_insertion_fallback( - allow_non_tsf_insertion_fallback, - InsertStatus::Failed, - ) { - return insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut); - } - log::warn!("[windows-ime] non-TSF insertion fallback is disabled; failing insert"); - return InsertStatus::Failed; - }; - - let request = crate::windows_ime_ipc::ImeSubmitRequest { - session_id: Uuid::new_v4().to_string(), - text: polished.to_string(), - created_at: Utc::now().to_rfc3339(), - target: ime_target, - }; - - let ime_status = match inner.windows_ime.submit_prepared(&prepared, request).await { - Ok(status) => status, - Err(error) => { - log::warn!("[windows-ime] TSF submit failed: {error}"); - InsertStatus::Failed - } - }; - inner.windows_ime.restore_session(prepared); - - if ime_status == InsertStatus::Inserted { - ime_status - } else if should_try_non_tsf_insertion_fallback(allow_non_tsf_insertion_fallback, ime_status) { - insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut) - } else { - log::warn!("[windows-ime] TSF did not insert; non-TSF insertion fallback is disabled"); - InsertStatus::Failed - } -} - -#[cfg(target_os = "windows")] -fn should_try_non_tsf_insertion_fallback( - allow_non_tsf_insertion_fallback: bool, - ime_status: InsertStatus, -) -> bool { - allow_non_tsf_insertion_fallback && ime_status != InsertStatus::Inserted -} - -#[cfg(target_os = "windows")] -fn insert_via_non_tsf_fallback( - inner: &Arc, - polished: &str, - _restore_clipboard: bool, - _paste_shortcut: PasteShortcut, -) -> InsertStatus { - let status = finish_non_tsf_insertion_fallback( - || inner.inserter.insert_via_unicode_keystrokes(polished), - || inner.inserter.copy_fallback(polished), - ); - - match status { - InsertStatus::Inserted => { - log::warn!( - "[windows-ime] TSF unavailable; inserted via paced Unicode SendInput fallback" - ); - } - InsertStatus::CopiedFallback => { - log::warn!( - "[windows-ime] TSF unavailable; Unicode SendInput failed, left text on clipboard" - ); - } - InsertStatus::PasteSent | InsertStatus::Failed => { - log::warn!( - "[windows-ime] TSF unavailable; Unicode SendInput fallback failed and copy fallback failed" - ); - } - } - - status -} - -#[cfg(any(target_os = "windows", test))] -fn finish_non_tsf_insertion_fallback( - mut unicode_fallback: U, - mut copy_only_fallback: C, -) -> InsertStatus -where - U: FnMut() -> InsertStatus, - C: FnMut() -> InsertStatus, -{ - match unicode_fallback() { - InsertStatus::Inserted => InsertStatus::Inserted, - InsertStatus::PasteSent | InsertStatus::CopiedFallback | InsertStatus::Failed => { - match copy_only_fallback() { - InsertStatus::CopiedFallback => InsertStatus::CopiedFallback, - // TextInserter::copy_fallback is copy-only: success is CopiedFallback. - // Treat any other status as failure so this helper never invents an insert. - InsertStatus::Inserted | InsertStatus::PasteSent | InsertStatus::Failed => { - InsertStatus::Failed - } - } - } - } -} - -#[cfg(test)] -mod non_tsf_fallback_tests { - use super::finish_non_tsf_insertion_fallback; - use crate::types::InsertStatus; - - #[test] - fn unicode_fallback_runs_before_copy_fallback() { - let mut copy_called = false; - let status = finish_non_tsf_insertion_fallback( - || InsertStatus::Inserted, - || { - copy_called = true; - InsertStatus::CopiedFallback - }, - ); - - assert_eq!(status, InsertStatus::Inserted); - assert!(!copy_called); - } - - #[test] - fn copy_fallback_runs_after_unicode_failure() { - let mut copy_called = false; - let status = finish_non_tsf_insertion_fallback( - || InsertStatus::Failed, - || { - copy_called = true; - InsertStatus::CopiedFallback - }, - ); - - assert_eq!(status, InsertStatus::CopiedFallback); - assert!(copy_called); - } - - #[test] - fn double_failure_does_not_pretend_text_was_copied() { - let mut copy_called = false; - let status = finish_non_tsf_insertion_fallback( - || InsertStatus::Failed, - || { - copy_called = true; - InsertStatus::Failed - }, - ); - - assert_eq!(status, InsertStatus::Failed); - assert!(copy_called); - } -} - // ─────────────────────────── helpers ─────────────────────────── #[cfg(any(debug_assertions, test))] @@ -4046,370 +3823,6 @@ 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; - - let foreground = unsafe { GetForegroundWindow() }; - if foreground.0.is_null() { - None - } else { - Some(foreground.0 as usize) - } -} - -#[cfg(not(target_os = "windows"))] -fn capture_focus_target() -> Option { - None -} - -/// 捕获用户开始 dictation 时的前台 app 标签("localizedName (bundle.id)"),用作 LLM -/// polish/translate 的上下文前提,让模型按 app 调风格。详见 issue #116。 -/// -/// macOS 走 NSWorkspace.frontmostApplication(公开 API,无需额外权限); -/// Windows 复用前台 HWND 拿窗口标题;Linux/其他平台返回 None。 -#[cfg(target_os = "macos")] -fn capture_frontmost_app() -> Option { - use objc2::msg_send; - use objc2::runtime::{AnyClass, AnyObject}; - - unsafe { - let cls = AnyClass::get("NSWorkspace")?; - let workspace: *mut AnyObject = msg_send![cls, sharedWorkspace]; - if workspace.is_null() { - return None; - } - let app: *mut AnyObject = msg_send![workspace, frontmostApplication]; - if app.is_null() { - return None; - } - let name_obj: *mut AnyObject = msg_send![app, localizedName]; - let bundle_obj: *mut AnyObject = msg_send![app, bundleIdentifier]; - let name = nsstring_to_string(name_obj); - let bundle = nsstring_to_string(bundle_obj); - match (name, bundle) { - (Some(n), Some(b)) => Some(format!("{n} ({b})")), - (Some(n), None) => Some(n), - (None, Some(b)) => Some(b), - (None, None) => None, - } - } -} - -#[cfg(target_os = "macos")] -unsafe fn nsstring_to_string(ns_string: *mut objc2::runtime::AnyObject) -> Option { - use objc2::msg_send; - if ns_string.is_null() { - return None; - } - let utf8: *const std::os::raw::c_char = unsafe { msg_send![ns_string, UTF8String] }; - if utf8.is_null() { - return None; - } - let cstr = unsafe { std::ffi::CStr::from_ptr(utf8) }; - let s = cstr.to_string_lossy().into_owned(); - if s.is_empty() { - None - } else { - Some(s) - } -} - -#[cfg(target_os = "windows")] -fn capture_frontmost_app() -> Option { - use windows::Win32::UI::WindowsAndMessaging::{ - GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW, - }; - - unsafe { - let hwnd = GetForegroundWindow(); - if hwnd.0.is_null() { - return None; - } - let len = GetWindowTextLengthW(hwnd); - if len <= 0 { - return None; - } - let mut buf = vec![0u16; (len + 1) as usize]; - let copied = GetWindowTextW(hwnd, &mut buf); - if copied <= 0 { - return None; - } - let title = String::from_utf16_lossy(&buf[..copied as usize]); - if title.is_empty() { - None - } else { - Some(title) - } - } -} - -#[cfg(not(any(target_os = "macos", target_os = "windows")))] -fn capture_frontmost_app() -> Option { - None -} - -#[cfg(target_os = "windows")] -fn restore_focus_target_if_possible(target: Option) -> bool { - use std::ffi::c_void; - use windows::Win32::Foundation::HWND; - use windows::Win32::UI::WindowsAndMessaging::{ - GetForegroundWindow, IsIconic, IsWindow, SetForegroundWindow, ShowWindow, SW_RESTORE, - }; - - let Some(raw_target) = target else { - log::warn!("[coord] no original Windows insertion target captured"); - return false; - }; - let hwnd = HWND(raw_target as *mut c_void); - if hwnd.0.is_null() { - return false; - } - if !unsafe { IsWindow(hwnd).as_bool() } { - log::warn!("[coord] original Windows insertion target is no longer a valid window"); - return false; - } - - let foreground = unsafe { GetForegroundWindow() }; - if foreground == hwnd { - return true; - } - - if unsafe { IsIconic(hwnd).as_bool() } { - let _ = unsafe { ShowWindow(hwnd, SW_RESTORE) }; - } - let _ = unsafe { SetForegroundWindow(hwnd) }; - std::thread::sleep(std::time::Duration::from_millis(60)); - - let foreground = unsafe { GetForegroundWindow() }; - if foreground != hwnd { - log::warn!("[coord] failed to restore original Windows insertion target before paste"); - return false; - } - true -} - -#[cfg(not(target_os = "windows"))] -fn restore_focus_target_if_possible(_target: Option) -> bool { - true -} - -#[cfg(target_os = "windows")] -fn windows_hwnd_is_present(hwnd: windows::Win32::Foundation::HWND) -> bool { - hwnd != windows::Win32::Foundation::HWND::default() -} - -#[cfg(target_os = "windows")] -fn capture_ime_submit_target() -> Option { - use windows::Win32::UI::WindowsAndMessaging::{ - GetForegroundWindow, GetGUIThreadInfo, GetWindowThreadProcessId, GUITHREADINFO, - }; - - let foreground = unsafe { GetForegroundWindow() }; - if !windows_hwnd_is_present(foreground) { - return None; - } - - let mut foreground_process_id = 0; - let foreground_thread_id = - unsafe { GetWindowThreadProcessId(foreground, Some(&mut foreground_process_id)) }; - if foreground_thread_id == 0 { - return None; - } - - let mut gui_info = GUITHREADINFO { - cbSize: std::mem::size_of::() as u32, - ..Default::default() - }; - let target_window = if unsafe { GetGUIThreadInfo(foreground_thread_id, &mut gui_info).is_ok() } - && windows_hwnd_is_present(gui_info.hwndFocus) - { - gui_info.hwndFocus - } else { - foreground - }; - - let mut process_id = 0; - let thread_id = unsafe { GetWindowThreadProcessId(target_window, Some(&mut process_id)) }; - if process_id == 0 || thread_id == 0 { - return None; - } - - Some(ImeSubmitTarget { - process_id, - thread_id, - }) -} - -// Windows topmost overlay 的已知 OS 级限制(issue #457): -// `SetWindowPos(HWND_TOPMOST)` 让 capsule 在普通桌面合成、最大化窗口、borderless -// windowed fullscreen 上正常叠加;但**对独占全屏(exclusive fullscreen)DirectX / -// OpenGL 应用无效** —— 那条路径绕过桌面合成器,标准 topmost 窗口不参与合成 → -// 用户看不见 capsule。这是 OS 层面的限制,用户空间无法绕过(除非接入 DirectX -// overlay,工程量与风险都不在 surgical 修复范围内)。 -// -// 用户侧 workaround:把游戏切到 borderless windowed fullscreen(Minecraft Java 默认 -// 即是;F11 在不同版本表现不一致,按设置里的「全屏」选项决定)。 -// -// 相关 UIPI 限制:若游戏以管理员身份运行而 OpenLess 不是,`WH_KEYBOARD_LL` 收不到 -// 游戏的按键 → hotkey 完全不触发。这里跟 SetWindowPos 路径无关,但同源不可绕过。 -#[cfg(target_os = "windows")] -fn show_capsule_window_no_activate( - _app: &AppHandle, - window: &tauri::WebviewWindow, -) -> bool { - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - use windows::Win32::Foundation::HWND; - use windows::Win32::UI::WindowsAndMessaging::{ - SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, - SWP_SHOWWINDOW, SW_SHOWNOACTIVATE, - }; - - let Ok(handle) = window.window_handle() else { - // #470 诊断 v2:Win32 show 路径最可能的暗点之一。此前静默 return, - // 无法观测「胶囊完全不显示」是否卡在这里。 - log::warn!( - "[capsule] no_activate failed: window_handle() unavailable — Win32 show skipped" - ); - return false; - }; - let RawWindowHandle::Win32(raw) = handle.as_raw() else { - log::warn!("[capsule] no_activate failed: non-Win32 RawWindowHandle — Win32 show skipped"); - return false; - }; - let hwnd = HWND(raw.hwnd.get() as *mut _); - - let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) }; - let _ = unsafe { - SetWindowPos( - hwnd, - HWND_TOPMOST, - 0, - 0, - 0, - 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, - ) - }; - true -} - -#[cfg(target_os = "macos")] -fn show_capsule_window_no_activate( - _app: &AppHandle, - window: &tauri::WebviewWindow, -) -> bool { - use objc2::msg_send; - use objc2::runtime::AnyObject; - - let Ok(handle) = window.ns_window() else { - return false; - }; - let ns_window = handle as *mut AnyObject; - if ns_window.is_null() { - return false; - } - - // emit_capsule 已经把窗口操作 marshal 到 Tauri 主线程;这里不能再调用 - // window.show()/set_focus()/NSApp.activate,否则 AeroSpace 会把 workspace 切回 - // OpenLess 主窗口所在空间。先让胶囊加入所有 Spaces,再用 - // orderFrontRegardless 做无激活展示。 - if let Err(e) = window.set_visible_on_all_workspaces(true) { - log::warn!("[capsule] set visible on all macOS Spaces failed: {e}"); - } - - unsafe { - const NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES: usize = 1 << 0; - const NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY: usize = 1 << 8; - let behavior: usize = msg_send![ns_window, collectionBehavior]; - let behavior = behavior - | NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES - | NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY; - let _: () = msg_send![ns_window, setCollectionBehavior: behavior]; - let _: () = msg_send![ns_window, orderFrontRegardless]; - } - true -} - -#[cfg(target_os = "linux")] -fn show_capsule_window_no_activate( - _app: &AppHandle, - _window: &tauri::WebviewWindow, -) -> bool { - true -} - -#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] -fn show_capsule_window_no_activate( - _app: &AppHandle, - _window: &tauri::WebviewWindow, -) -> bool { - false -} - -#[cfg(target_os = "windows")] -fn hide_capsule_window_if_present() { - use std::iter::once; - use windows::core::PCWSTR; - use windows::Win32::Foundation::HWND; - use windows::Win32::UI::WindowsAndMessaging::{ - FindWindowW, SetWindowPos, ShowWindow, HWND_NOTOPMOST, SWP_HIDEWINDOW, SWP_NOACTIVATE, - SWP_NOMOVE, SWP_NOSIZE, SW_HIDE, - }; - - let title: Vec = "OpenLess Capsule".encode_utf16().chain(once(0)).collect(); - let hwnd = match unsafe { FindWindowW(PCWSTR::null(), PCWSTR(title.as_ptr())) } { - Ok(hwnd) => hwnd, - Err(_) => return, - }; - if hwnd == HWND::default() || hwnd.0.is_null() { - return; - } - - let _ = unsafe { ShowWindow(hwnd, SW_HIDE) }; - let _ = unsafe { - SetWindowPos( - hwnd, - HWND_NOTOPMOST, - 0, - 0, - 0, - 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_HIDEWINDOW, - ) - }; -} - -#[cfg(not(target_os = "windows"))] -fn hide_capsule_window_if_present() {} - fn emit_capsule( inner: &Arc, state: CapsuleState, diff --git a/openless-all/app/src-tauri/src/coordinator/ime_insertion.rs b/openless-all/app/src-tauri/src/coordinator/ime_insertion.rs new file mode 100644 index 00000000..3450f5ca --- /dev/null +++ b/openless-all/app/src-tauri/src/coordinator/ime_insertion.rs @@ -0,0 +1,598 @@ +//! OS 级窗口 & 插入管线:Windows IME 会话(store/take/restore)、TSF 优先插入、 +//! 非 TSF 回退,以及焦点目标抓取/恢复、前台 App 抓取、capsule 窗口 no-activate 显示/隐藏。 +//! +//! 从 `coordinator.rs` 机械拆出(行为保持,仅可见性提升)。大量 unsafe + Win32/objc2 FFI, +//! 所有 cfg 分支与 SAFETY/issue 注释原样跟随。 + +use super::*; + +#[cfg(target_os = "windows")] +pub(crate) fn store_prepared_windows_ime_session( + slots: &mut Vec, + session_id: SessionId, + prepared: PreparedWindowsImeSession, +) { + slots.retain(|slot| slot.session_id != session_id); + slots.push(PreparedWindowsImeSessionSlot { + session_id, + prepared, + }); +} + +#[cfg(target_os = "windows")] +pub(crate) fn take_matching_prepared_windows_ime_session( + slots: &mut Vec, + session_id: SessionId, +) -> Option { + let index = slots + .iter() + .position(|slot| slot.session_id == session_id)?; + Some(slots.remove(index).prepared) +} + +#[cfg(target_os = "windows")] +pub(crate) fn take_current_prepared_windows_ime_session_for_restore( + slots: &mut Vec, + session_id: SessionId, + current_session_id: SessionId, +) -> Option { + let prepared = take_matching_prepared_windows_ime_session(slots, session_id)?; + if current_session_id == session_id { + Some(prepared) + } else { + None + } +} + +#[cfg(target_os = "windows")] +pub(crate) fn restore_prepared_windows_ime_session(inner: &Arc, session_id: SessionId) { + let state = inner.state.lock(); + let prepared = { + let mut slot = inner.prepared_windows_ime_session.lock(); + take_current_prepared_windows_ime_session_for_restore( + &mut slot, + session_id, + state.session_id, + ) + }; + if let Some(prepared) = prepared { + inner.windows_ime.restore_session(prepared); + } +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn restore_prepared_windows_ime_session(_inner: &Arc, _session_id: SessionId) {} + +#[cfg(target_os = "windows")] +pub(crate) async fn insert_with_windows_ime_first( + inner: &Arc, + session_id: SessionId, + polished: &str, + restore_clipboard: bool, + allow_non_tsf_insertion_fallback: bool, + paste_shortcut: PasteShortcut, + ime_target: Option, +) -> InsertStatus { + let prepared = { + let mut slot = inner.prepared_windows_ime_session.lock(); + take_matching_prepared_windows_ime_session(&mut slot, session_id) + }; + let Some(prepared) = prepared else { + log::warn!("[windows-ime] no prepared TSF session for this dictation"); + if should_try_non_tsf_insertion_fallback( + allow_non_tsf_insertion_fallback, + InsertStatus::Failed, + ) { + return insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut); + } + log::warn!("[windows-ime] non-TSF insertion fallback is disabled; failing insert"); + return InsertStatus::Failed; + }; + + let request = crate::windows_ime_ipc::ImeSubmitRequest { + session_id: Uuid::new_v4().to_string(), + text: polished.to_string(), + created_at: Utc::now().to_rfc3339(), + target: ime_target, + }; + + let ime_status = match inner.windows_ime.submit_prepared(&prepared, request).await { + Ok(status) => status, + Err(error) => { + log::warn!("[windows-ime] TSF submit failed: {error}"); + InsertStatus::Failed + } + }; + inner.windows_ime.restore_session(prepared); + + if ime_status == InsertStatus::Inserted { + ime_status + } else if should_try_non_tsf_insertion_fallback(allow_non_tsf_insertion_fallback, ime_status) { + insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut) + } else { + log::warn!("[windows-ime] TSF did not insert; non-TSF insertion fallback is disabled"); + InsertStatus::Failed + } +} + +#[cfg(target_os = "windows")] +pub(crate) fn should_try_non_tsf_insertion_fallback( + allow_non_tsf_insertion_fallback: bool, + ime_status: InsertStatus, +) -> bool { + allow_non_tsf_insertion_fallback && ime_status != InsertStatus::Inserted +} + +#[cfg(target_os = "windows")] +pub(crate) fn insert_via_non_tsf_fallback( + inner: &Arc, + polished: &str, + _restore_clipboard: bool, + _paste_shortcut: PasteShortcut, +) -> InsertStatus { + let status = finish_non_tsf_insertion_fallback( + || inner.inserter.insert_via_unicode_keystrokes(polished), + || inner.inserter.copy_fallback(polished), + ); + + match status { + InsertStatus::Inserted => { + log::warn!( + "[windows-ime] TSF unavailable; inserted via paced Unicode SendInput fallback" + ); + } + InsertStatus::CopiedFallback => { + log::warn!( + "[windows-ime] TSF unavailable; Unicode SendInput failed, left text on clipboard" + ); + } + InsertStatus::PasteSent | InsertStatus::Failed => { + log::warn!( + "[windows-ime] TSF unavailable; Unicode SendInput fallback failed and copy fallback failed" + ); + } + } + + status +} + +#[cfg(any(target_os = "windows", test))] +pub(crate) fn finish_non_tsf_insertion_fallback( + mut unicode_fallback: U, + mut copy_only_fallback: C, +) -> InsertStatus +where + U: FnMut() -> InsertStatus, + C: FnMut() -> InsertStatus, +{ + match unicode_fallback() { + InsertStatus::Inserted => InsertStatus::Inserted, + InsertStatus::PasteSent | InsertStatus::CopiedFallback | InsertStatus::Failed => { + match copy_only_fallback() { + InsertStatus::CopiedFallback => InsertStatus::CopiedFallback, + // TextInserter::copy_fallback is copy-only: success is CopiedFallback. + // Treat any other status as failure so this helper never invents an insert. + InsertStatus::Inserted | InsertStatus::PasteSent | InsertStatus::Failed => { + InsertStatus::Failed + } + } + } + } +} + +#[cfg(test)] +mod non_tsf_fallback_tests { + use super::finish_non_tsf_insertion_fallback; + use crate::types::InsertStatus; + + #[test] + fn unicode_fallback_runs_before_copy_fallback() { + let mut copy_called = false; + let status = finish_non_tsf_insertion_fallback( + || InsertStatus::Inserted, + || { + copy_called = true; + InsertStatus::CopiedFallback + }, + ); + + assert_eq!(status, InsertStatus::Inserted); + assert!(!copy_called); + } + + #[test] + fn copy_fallback_runs_after_unicode_failure() { + let mut copy_called = false; + let status = finish_non_tsf_insertion_fallback( + || InsertStatus::Failed, + || { + copy_called = true; + InsertStatus::CopiedFallback + }, + ); + + assert_eq!(status, InsertStatus::CopiedFallback); + assert!(copy_called); + } + + #[test] + fn double_failure_does_not_pretend_text_was_copied() { + let mut copy_called = false; + let status = finish_non_tsf_insertion_fallback( + || InsertStatus::Failed, + || { + copy_called = true; + InsertStatus::Failed + }, + ); + + assert_eq!(status, InsertStatus::Failed); + assert!(copy_called); + } +} + +/// 与 capture_focus_target 类似,但前台窗口属于本进程(即用户停在 QA / capsule / main +/// 等自家窗口)时返回 None,让 caller 区分"用户没切到别处" vs "用户切到了另一个真正的 +/// 外部 app"。issue #466 多轮场景下用来刷新 qa_focus_target。 +#[cfg(target_os = "windows")] +pub(crate) 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"))] +pub(crate) fn capture_external_focus_target() -> Option { + None +} + +#[cfg(target_os = "windows")] +pub(crate) fn capture_focus_target() -> Option { + use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; + + let foreground = unsafe { GetForegroundWindow() }; + if foreground.0.is_null() { + None + } else { + Some(foreground.0 as usize) + } +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn capture_focus_target() -> Option { + None +} + +/// 捕获用户开始 dictation 时的前台 app 标签("localizedName (bundle.id)"),用作 LLM +/// polish/translate 的上下文前提,让模型按 app 调风格。详见 issue #116。 +/// +/// macOS 走 NSWorkspace.frontmostApplication(公开 API,无需额外权限); +/// Windows 复用前台 HWND 拿窗口标题;Linux/其他平台返回 None。 +#[cfg(target_os = "macos")] +pub(crate) fn capture_frontmost_app() -> Option { + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject}; + + unsafe { + let cls = AnyClass::get("NSWorkspace")?; + let workspace: *mut AnyObject = msg_send![cls, sharedWorkspace]; + if workspace.is_null() { + return None; + } + let app: *mut AnyObject = msg_send![workspace, frontmostApplication]; + if app.is_null() { + return None; + } + let name_obj: *mut AnyObject = msg_send![app, localizedName]; + let bundle_obj: *mut AnyObject = msg_send![app, bundleIdentifier]; + let name = nsstring_to_string(name_obj); + let bundle = nsstring_to_string(bundle_obj); + match (name, bundle) { + (Some(n), Some(b)) => Some(format!("{n} ({b})")), + (Some(n), None) => Some(n), + (None, Some(b)) => Some(b), + (None, None) => None, + } + } +} + +#[cfg(target_os = "macos")] +pub(crate) unsafe fn nsstring_to_string( + ns_string: *mut objc2::runtime::AnyObject, +) -> Option { + use objc2::msg_send; + if ns_string.is_null() { + return None; + } + let utf8: *const std::os::raw::c_char = unsafe { msg_send![ns_string, UTF8String] }; + if utf8.is_null() { + return None; + } + let cstr = unsafe { std::ffi::CStr::from_ptr(utf8) }; + let s = cstr.to_string_lossy().into_owned(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +#[cfg(target_os = "windows")] +pub(crate) fn capture_frontmost_app() -> Option { + use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW, + }; + + unsafe { + let hwnd = GetForegroundWindow(); + if hwnd.0.is_null() { + return None; + } + let len = GetWindowTextLengthW(hwnd); + if len <= 0 { + return None; + } + let mut buf = vec![0u16; (len + 1) as usize]; + let copied = GetWindowTextW(hwnd, &mut buf); + if copied <= 0 { + return None; + } + let title = String::from_utf16_lossy(&buf[..copied as usize]); + if title.is_empty() { + None + } else { + Some(title) + } + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub(crate) fn capture_frontmost_app() -> Option { + None +} + +#[cfg(target_os = "windows")] +pub(crate) fn restore_focus_target_if_possible(target: Option) -> bool { + use std::ffi::c_void; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, IsIconic, IsWindow, SetForegroundWindow, ShowWindow, SW_RESTORE, + }; + + let Some(raw_target) = target else { + log::warn!("[coord] no original Windows insertion target captured"); + return false; + }; + let hwnd = HWND(raw_target as *mut c_void); + if hwnd.0.is_null() { + return false; + } + if !unsafe { IsWindow(hwnd).as_bool() } { + log::warn!("[coord] original Windows insertion target is no longer a valid window"); + return false; + } + + let foreground = unsafe { GetForegroundWindow() }; + if foreground == hwnd { + return true; + } + + if unsafe { IsIconic(hwnd).as_bool() } { + let _ = unsafe { ShowWindow(hwnd, SW_RESTORE) }; + } + let _ = unsafe { SetForegroundWindow(hwnd) }; + std::thread::sleep(std::time::Duration::from_millis(60)); + + let foreground = unsafe { GetForegroundWindow() }; + if foreground != hwnd { + log::warn!("[coord] failed to restore original Windows insertion target before paste"); + return false; + } + true +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn restore_focus_target_if_possible(_target: Option) -> bool { + true +} + +#[cfg(target_os = "windows")] +pub(crate) fn windows_hwnd_is_present(hwnd: windows::Win32::Foundation::HWND) -> bool { + hwnd != windows::Win32::Foundation::HWND::default() +} + +#[cfg(target_os = "windows")] +pub(crate) fn capture_ime_submit_target() -> Option { + use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, GetGUIThreadInfo, GetWindowThreadProcessId, GUITHREADINFO, + }; + + let foreground = unsafe { GetForegroundWindow() }; + if !windows_hwnd_is_present(foreground) { + return None; + } + + let mut foreground_process_id = 0; + let foreground_thread_id = + unsafe { GetWindowThreadProcessId(foreground, Some(&mut foreground_process_id)) }; + if foreground_thread_id == 0 { + return None; + } + + let mut gui_info = GUITHREADINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let target_window = if unsafe { GetGUIThreadInfo(foreground_thread_id, &mut gui_info).is_ok() } + && windows_hwnd_is_present(gui_info.hwndFocus) + { + gui_info.hwndFocus + } else { + foreground + }; + + let mut process_id = 0; + let thread_id = unsafe { GetWindowThreadProcessId(target_window, Some(&mut process_id)) }; + if process_id == 0 || thread_id == 0 { + return None; + } + + Some(ImeSubmitTarget { + process_id, + thread_id, + }) +} + +// Windows topmost overlay 的已知 OS 级限制(issue #457): +// `SetWindowPos(HWND_TOPMOST)` 让 capsule 在普通桌面合成、最大化窗口、borderless +// windowed fullscreen 上正常叠加;但**对独占全屏(exclusive fullscreen)DirectX / +// OpenGL 应用无效** —— 那条路径绕过桌面合成器,标准 topmost 窗口不参与合成 → +// 用户看不见 capsule。这是 OS 层面的限制,用户空间无法绕过(除非接入 DirectX +// overlay,工程量与风险都不在 surgical 修复范围内)。 +// +// 用户侧 workaround:把游戏切到 borderless windowed fullscreen(Minecraft Java 默认 +// 即是;F11 在不同版本表现不一致,按设置里的「全屏」选项决定)。 +// +// 相关 UIPI 限制:若游戏以管理员身份运行而 OpenLess 不是,`WH_KEYBOARD_LL` 收不到 +// 游戏的按键 → hotkey 完全不触发。这里跟 SetWindowPos 路径无关,但同源不可绕过。 +#[cfg(target_os = "windows")] +pub(crate) fn show_capsule_window_no_activate( + _app: &AppHandle, + window: &tauri::WebviewWindow, +) -> bool { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, + SWP_SHOWWINDOW, SW_SHOWNOACTIVATE, + }; + + let Ok(handle) = window.window_handle() else { + // #470 诊断 v2:Win32 show 路径最可能的暗点之一。此前静默 return, + // 无法观测「胶囊完全不显示」是否卡在这里。 + log::warn!( + "[capsule] no_activate failed: window_handle() unavailable — Win32 show skipped" + ); + return false; + }; + let RawWindowHandle::Win32(raw) = handle.as_raw() else { + log::warn!("[capsule] no_activate failed: non-Win32 RawWindowHandle — Win32 show skipped"); + return false; + }; + let hwnd = HWND(raw.hwnd.get() as *mut _); + + let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) }; + let _ = unsafe { + SetWindowPos( + hwnd, + HWND_TOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, + ) + }; + true +} + +#[cfg(target_os = "macos")] +pub(crate) fn show_capsule_window_no_activate( + _app: &AppHandle, + window: &tauri::WebviewWindow, +) -> bool { + use objc2::msg_send; + use objc2::runtime::AnyObject; + + let Ok(handle) = window.ns_window() else { + return false; + }; + let ns_window = handle as *mut AnyObject; + if ns_window.is_null() { + return false; + } + + // emit_capsule 已经把窗口操作 marshal 到 Tauri 主线程;这里不能再调用 + // window.show()/set_focus()/NSApp.activate,否则 AeroSpace 会把 workspace 切回 + // OpenLess 主窗口所在空间。先让胶囊加入所有 Spaces,再用 + // orderFrontRegardless 做无激活展示。 + if let Err(e) = window.set_visible_on_all_workspaces(true) { + log::warn!("[capsule] set visible on all macOS Spaces failed: {e}"); + } + + unsafe { + const NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES: usize = 1 << 0; + const NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY: usize = 1 << 8; + let behavior: usize = msg_send![ns_window, collectionBehavior]; + let behavior = behavior + | NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES + | NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY; + let _: () = msg_send![ns_window, setCollectionBehavior: behavior]; + let _: () = msg_send![ns_window, orderFrontRegardless]; + } + true +} + +#[cfg(target_os = "linux")] +pub(crate) fn show_capsule_window_no_activate( + _app: &AppHandle, + _window: &tauri::WebviewWindow, +) -> bool { + true +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +pub(crate) fn show_capsule_window_no_activate( + _app: &AppHandle, + _window: &tauri::WebviewWindow, +) -> bool { + false +} + +#[cfg(target_os = "windows")] +pub(crate) fn hide_capsule_window_if_present() { + use std::iter::once; + use windows::core::PCWSTR; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + FindWindowW, SetWindowPos, ShowWindow, HWND_NOTOPMOST, SWP_HIDEWINDOW, SWP_NOACTIVATE, + SWP_NOMOVE, SWP_NOSIZE, SW_HIDE, + }; + + let title: Vec = "OpenLess Capsule".encode_utf16().chain(once(0)).collect(); + let hwnd = match unsafe { FindWindowW(PCWSTR::null(), PCWSTR(title.as_ptr())) } { + Ok(hwnd) => hwnd, + Err(_) => return, + }; + if hwnd == HWND::default() || hwnd.0.is_null() { + return; + } + + let _ = unsafe { ShowWindow(hwnd, SW_HIDE) }; + let _ = unsafe { + SetWindowPos( + hwnd, + HWND_NOTOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_HIDEWINDOW, + ) + }; +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn hide_capsule_window_if_present() {}