From 920a31fa0ffd06e7892400121b7d50f2abd7891f Mon Sep 17 00:00:00 2001 From: Cooper-X-Oak Date: Thu, 30 Apr 2026 23:23:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(windows):=20=E6=94=B6=E6=95=9B=E6=8F=92?= =?UTF-8?q?=E5=85=A5=E7=84=A6=E7=82=B9=E4=B8=8E=E5=89=AA=E8=B4=B4=E6=9D=BF?= =?UTF-8?q?=E5=89=AF=E4=BD=9C=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../windows-real-asr-insertion-smoke.ps1 | 4 +- openless-all/app/src-tauri/src/coordinator.rs | 113 ++++++++++++++- openless-all/app/src-tauri/src/insertion.rs | 130 +++++++++++++++++- openless-all/app/src-tauri/src/types.rs | 1 + openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/lib/types.ts | 2 +- openless-all/app/src/pages/History.tsx | 2 + 8 files changed, 247 insertions(+), 7 deletions(-) diff --git a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 index d7979b3c..0ce1dcd6 100644 --- a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 +++ b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 @@ -388,8 +388,8 @@ try { if ([string]::IsNullOrWhiteSpace($latest.rawTranscript) -or [string]::IsNullOrWhiteSpace($latest.finalText)) { throw "Latest history item is missing rawTranscript or finalText." } - if ($latest.insertStatus -ne "copiedFallback") { - throw "Expected Windows insertStatus copiedFallback, got '$($latest.insertStatus)'." + if (@("copiedFallback", "pasteSent") -notcontains $latest.insertStatus) { + throw "Expected Windows insertStatus copiedFallback or pasteSent, got '$($latest.insertStatus)'." } Focus-Window $inputTarget.Process | Out-Null diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 53b1d613..9d88a665 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -59,6 +59,7 @@ struct SessionState { /// 用户在 Processing 阶段按 Esc 取消:end_session 在 polish/insert 检查点跳过插入 + /// 跳过 history.append。issue #52。 cancelled: bool, + focus_target: Option, } impl Default for SessionState { @@ -68,6 +69,7 @@ impl Default for SessionState { started_at: Instant::now(), pending_stop: false, cancelled: false, + focus_target: None, } } } @@ -434,6 +436,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { // 新会话清掉旧 pending_stop / cancelled,避免上一会话遗留触发奇怪行为 state.pending_stop = false; state.cancelled = false; + state.focus_target = capture_focus_target(); } #[cfg(any(debug_assertions, test))] @@ -801,6 +804,8 @@ async fn end_session(inner: &Arc) -> Result<(), String> { return Ok(()); } + let focus_target = inner.state.lock().focus_target; + restore_focus_target_if_possible(focus_target); let status = inner.inserter.insert(&polished); let inserted_chars = polished.chars().count() as u32; @@ -850,6 +855,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { } else { match status { InsertStatus::Inserted => None, + InsertStatus::PasteSent => Some("已尝试粘贴".to_string()), InsertStatus::CopiedFallback => Some(if cfg!(target_os = "windows") { "已复制,请 Ctrl+V".to_string() } else { @@ -868,7 +874,11 @@ async fn end_session(inner: &Arc) -> Result<(), String> { Some(inserted_chars), ); - inner.state.lock().phase = SessionPhase::Idle; + { + let mut state = inner.state.lock(); + state.phase = SessionPhase::Idle; + state.focus_target = None; + } schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); Ok(()) @@ -902,7 +912,9 @@ fn cancel_session(inner: &Arc) { // Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾; // 其他阶段直接转 Idle。 if phase != SessionPhase::Processing { - inner.state.lock().phase = SessionPhase::Idle; + let mut state = inner.state.lock(); + state.phase = SessionPhase::Idle; + state.focus_target = None; } emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None); log::info!("[coord] session cancelled (was {phase:?})"); @@ -1233,6 +1245,95 @@ fn schedule_capsule_idle(inner: &Arc, delay_ms: u64) { }); } +#[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 +} + +#[cfg(target_os = "windows")] +fn restore_focus_target_if_possible(target: Option) { + use std::ffi::c_void; + use std::time::Duration; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, IsIconic, IsWindow, SetForegroundWindow, ShowWindow, SW_RESTORE, + }; + + let Some(raw_target) = target else { return }; + let hwnd = HWND(raw_target as *mut c_void); + if hwnd.0.is_null() { + return; + } + if !unsafe { IsWindow(hwnd).as_bool() } { + return; + } + + let foreground = unsafe { GetForegroundWindow() }; + if foreground == hwnd { + return; + } + + if unsafe { IsIconic(hwnd).as_bool() } { + let _ = unsafe { ShowWindow(hwnd, SW_RESTORE) }; + } + let _ = unsafe { SetForegroundWindow(hwnd) }; + std::thread::sleep(Duration::from_millis(60)); +} + +#[cfg(not(target_os = "windows"))] +fn restore_focus_target_if_possible(_target: Option) {} + +#[cfg(target_os = "windows")] +fn show_capsule_window_no_activate() -> bool { + use std::iter::once; + use windows::core::PCWSTR; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + FindWindowW, SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, + SWP_NOSIZE, SWP_SHOWWINDOW, SW_SHOWNOACTIVATE, + }; + + 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 false, + }; + if hwnd == HWND::default() || hwnd.0.is_null() { + return false; + } + + 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(not(target_os = "windows"))] +fn show_capsule_window_no_activate() -> bool { + false +} + fn emit_capsule( inner: &Arc, state: CapsuleState, @@ -1255,7 +1356,13 @@ fn emit_capsule( if let Some(window) = app.get_webview_window("capsule") { let visible = !matches!(state, CapsuleState::Idle); if show_capsule && visible { - let _ = window.show(); + if cfg!(target_os = "windows") { + if !show_capsule_window_no_activate() { + let _ = window.show(); + } + } else { + let _ = window.show(); + } // 胶囊 show() 在 macOS 会调 makeKeyAndOrderFront: 抢走主窗口焦点。 // 若 OpenLess 已是前台 app,用 makeKeyWindow 还原主窗口(不激活 NSApp)。 #[cfg(target_os = "macos")] diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 2f3b88a2..0a1d4b6f 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -10,8 +10,13 @@ //! → CGEventPost,跟我们这里完全同源。 //! 3. 其他平台 (Windows/Linux) 仍用 enigo。 +use std::time::Duration; + use crate::types::InsertStatus; +#[cfg(not(target_os = "macos"))] +const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(150); + pub struct TextInserter; impl TextInserter { @@ -20,6 +25,16 @@ impl TextInserter { } /// Insert `text` at the current cursor position. + #[cfg(not(target_os = "macos"))] + pub fn insert(&self, text: &str) -> InsertStatus { + if text.is_empty() { + return InsertStatus::CopiedFallback; + } + insert_with_clipboard_restore(text) + } + + /// Insert `text` at the current cursor position. + #[cfg(target_os = "macos")] pub fn insert(&self, text: &str) -> InsertStatus { if text.is_empty() { return InsertStatus::CopiedFallback; @@ -41,6 +56,13 @@ impl Default for TextInserter { } } +#[cfg(not(target_os = "macos"))] +#[derive(Debug)] +struct ClipboardRestorePlan { + inserted_text: String, + previous_text: Option, +} + fn copy_to_clipboard(text: &str) -> bool { let mut clipboard = match arboard::Clipboard::new() { Ok(c) => c, @@ -56,6 +78,93 @@ fn copy_to_clipboard(text: &str) -> bool { true } +#[cfg(not(target_os = "macos"))] +fn copy_to_clipboard_with_restore_plan(text: &str) -> Result { + let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; + let previous_text = match clipboard.get_text() { + Ok(existing) => Some(existing), + Err(err) => { + log::warn!( + "[insertion] clipboard get_text failed before overwrite: {}", + err + ); + None + } + }; + clipboard + .set_text(text.to_string()) + .map_err(|e| e.to_string())?; + Ok(ClipboardRestorePlan { + inserted_text: text.to_string(), + previous_text, + }) +} + +#[cfg(not(target_os = "macos"))] +fn insert_with_clipboard_restore(text: &str) -> InsertStatus { + let restore_plan = match copy_to_clipboard_with_restore_plan(text) { + Ok(plan) => plan, + Err(err) => { + log::error!("[insertion] clipboard write failed: {}", err); + return InsertStatus::Failed; + } + }; + + if let Err(err) = simulate_paste() { + log::warn!("[insertion] simulated paste failed: {}", err); + return InsertStatus::CopiedFallback; + } + + maybe_restore_clipboard(restore_plan); + insertion_success_status() +} + +#[cfg(not(target_os = "macos"))] +fn maybe_restore_clipboard(plan: ClipboardRestorePlan) { + if plan.previous_text.is_none() { + return; + } + + std::thread::sleep(CLIPBOARD_RESTORE_DELAY); + + let mut clipboard = match arboard::Clipboard::new() { + Ok(clipboard) => clipboard, + Err(err) => { + log::warn!( + "[insertion] clipboard re-open failed during restore: {}", + err + ); + return; + } + }; + + let current_text = match clipboard.get_text() { + Ok(current) => Some(current), + Err(err) => { + log::warn!( + "[insertion] clipboard get_text failed during restore: {}", + err + ); + None + } + }; + + if !should_restore_clipboard(current_text.as_deref(), &plan.inserted_text) { + return; + } + + if let Some(previous_text) = plan.previous_text { + if let Err(err) = clipboard.set_text(previous_text) { + log::warn!("[insertion] clipboard restore failed: {}", err); + } + } +} + +#[cfg(not(target_os = "macos"))] +fn should_restore_clipboard(current_text: Option<&str>, inserted_text: &str) -> bool { + matches!(current_text, Some(current) if current == inserted_text) +} + #[cfg(target_os = "macos")] fn simulate_paste() -> Result<(), String> { if !matches!( @@ -92,7 +201,7 @@ fn insertion_success_status() -> InsertStatus { #[cfg(not(target_os = "macos"))] fn insertion_success_status() -> InsertStatus { // Windows/Linux 的 Ctrl+V 只能证明粘贴快捷键已发送,不能证明目标控件已接收。 - InsertStatus::CopiedFallback + InsertStatus::PasteSent } // ─────────────────────────── macOS native CGEvent paste ─────────────────────────── @@ -172,3 +281,22 @@ mod macos { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(not(target_os = "macos"))] + fn restore_only_when_clipboard_still_holds_inserted_text() { + assert!(should_restore_clipboard( + Some("dictated text"), + "dictated text" + )); + assert!(!should_restore_clipboard( + Some("user changed clipboard"), + "dictated text" + )); + assert!(!should_restore_clipboard(None, "dictated text")); + } +} diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 6891ca6e..7df815fa 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -32,6 +32,7 @@ impl PolishMode { #[serde(rename_all = "camelCase")] pub enum InsertStatus { Inserted, + PasteSent, CopiedFallback, Failed, } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 6f54b553..4005cb88 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -118,6 +118,7 @@ export const en: typeof zhCN = { chars: '{{count}} chars', vocabHits: '{{count}} vocab hits', inserted: 'Inserted', + pasteSent: 'Paste sent', copiedFallback: 'Copied (use {{shortcut}})', insertFailed: 'Insert failed', confirmClear: 'Delete all {{count}} history entries? This cannot be undone.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index a7861edb..2e373a15 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -116,6 +116,7 @@ export const zhCN = { chars: '{{count}} 字', vocabHits: '{{count}} 个热词', inserted: '已插入', + pasteSent: '已尝试粘贴', copiedFallback: '已复制(需 {{shortcut}})', insertFailed: '插入失败', confirmClear: '确定清空全部 {{count}} 条记录?此操作不可恢复。', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 501c6c50..2c618669 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -4,7 +4,7 @@ export type PolishMode = 'raw' | 'light' | 'structured' | 'formal'; -export type InsertStatus = 'inserted' | 'copiedFallback' | 'failed'; +export type InsertStatus = 'inserted' | 'pasteSent' | 'copiedFallback' | 'failed'; export interface DictationSession { id: string; diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 2976d2f1..596df1ab 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -200,6 +200,8 @@ export function History() { { item.insertStatus === 'inserted' ? t('history.inserted') + : item.insertStatus === 'pasteSent' + ? t('history.pasteSent') : item.insertStatus === 'copiedFallback' ? t('history.copiedFallback', { shortcut: detectOS() === 'win' ? 'Ctrl+V' : '⌘V' }) : t('history.insertFailed') From d2074173b76edef4aec9101cf262a2c98cdd89fa Mon Sep 17 00:00:00 2001 From: Cooper-X-Oak Date: Fri, 1 May 2026 00:00:47 +0800 Subject: [PATCH 2/2] fix(windows-gnu): suppress MinGW auto-exports for cdylib --- .cargo/config.toml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..314f86e4 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,4 @@ +[target.x86_64-pc-windows-gnu] +# MinGW's PE linker auto-exports Rust symbols from the desktop-only cdylib, +# which overflows the export ordinal table during GNU builds/tests. +rustflags = ["-C", "link-arg=-Wl,--exclude-all-symbols"]