From 7251948ae70dcb1e688e4c0e08cf105102ef199e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Tue, 16 Jun 2026 12:25:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(insertion):=20macOS=20=E8=AE=A9=E3=80=8C?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=89=AA=E8=B4=B4=E6=9D=BF=E3=80=8D=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E7=9C=9F=E6=AD=A3=E7=94=9F=E6=95=88=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=A2=AB=E5=9B=9E=E9=80=80=E7=9A=84=20#525?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #525 的 macOS 剪贴板恢复(commit 4b4d219)被 Android 移植 commit 1ea467a 连同 cfg 门控一起回退:整套恢复机制被重新 #[cfg] 排除出 macOS,macOS insert() 退回「只写剪贴板 + Cmd+V」,于是设置里的「恢复剪贴板」开关在 macOS 上完全无效 ——无论开关开还是关,剪贴板都被留成转写文字。 - 把共享的剪贴板恢复机制(ClipboardRestorePlan / schedule_clipboard_restore / restore_clipboard_after_delay / should_restore_clipboard 等)的 cfg 从 not(any(macos,android,ios)) 放宽为 not(any(android,ios)),重新纳入 macOS (移动端仍排除;Windows/Linux 不变)。 - macOS insert() 改为:保存原剪贴板 → 写转写文字 → Cmd+V → 仅当「恢复剪贴板」 开关开启且粘贴成功时按 CLIPBOARD_RESTORE_DELAY 延迟恢复;粘贴失败则保留转写 文字供手动粘贴、不恢复。开 = 恢复,关 = 保持现状,开关说了算。 - 删除随之失效的 macos_insert_status_after_paste;恢复相关单测在 macOS 也运行。 Windows/Linux 专用项(insert_with_clipboard_restore / paste_keys / simulate_paste(shortcut) / 其 insertion_success_status 变体)的 macOS 排除保持不变。 --- openless-all/app/src-tauri/src/insertion.rs | 96 +++++++++++---------- 1 file changed, 52 insertions(+), 44 deletions(-) diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index d4c5ac18..114488cd 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -5,19 +5,19 @@ //! - macOS:用 CoreGraphics CGEvent 直接 post Cmd+V。 //! - Windows / Linux:用 enigo 按 `PasteShortcut` 模拟。 -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] use std::sync::atomic::{AtomicU64, Ordering}; -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] use std::time::Duration; -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] use once_cell::sync::Lazy; -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] use parking_lot::Mutex; use crate::types::{InsertStatus, PasteShortcut}; -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750); pub struct TextInserter; @@ -87,21 +87,37 @@ impl TextInserter { } } - /// macOS 路径:写剪贴板 + post Cmd+V。两个 `_` 参数仅为对齐跨平台签名。 + /// macOS 路径:保存原剪贴板 → 写转写文字 → post Cmd+V → 按需恢复原剪贴板。 + /// `_paste_shortcut` 在 macOS 不使用(固定 Cmd+V),仅为对齐跨平台签名。 #[cfg(target_os = "macos")] pub fn insert( &self, text: &str, restore_clipboard_after_paste: bool, - paste_shortcut: PasteShortcut, + _paste_shortcut: PasteShortcut, ) -> InsertStatus { if text.is_empty() { return InsertStatus::CopiedFallback; } - if !copy_to_clipboard(text) { - return InsertStatus::Failed; + // issue #525:先记下用户原剪贴板,粘贴成功且「恢复剪贴板」开关开启时再恢复,避免覆盖 + // 用户手动复制的内容。此前 macOS 完全不实现恢复(恢复机制曾被 cfg 排除),导致设置里 + // 的开关在 macOS 上无效——无论开关如何,剪贴板都被留成转写文字。 + 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; + } + if restore_clipboard_after_paste { + schedule_clipboard_restore(restore_plan); } - macos_insert_status_after_paste(simulate_paste()) + insertion_success_status() } /// Android:跨应用输入由 dictation 流程按用户策略处理;通用插入只写剪贴板兜底。 @@ -157,41 +173,30 @@ where } } -#[cfg(target_os = "macos")] -fn macos_insert_status_after_paste(result: Result<(), String>) -> InsertStatus { - match result { - Ok(()) => insertion_success_status(), - Err(err) => { - log::warn!("[insertion] simulated paste failed: {}", err); - InsertStatus::CopiedFallback - } - } -} - impl Default for TextInserter { fn default() -> Self { Self::new() } } -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] #[derive(Debug)] struct ClipboardRestorePlan { inserted_text: String, previous_text: Option, } -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] #[derive(Debug, Clone)] struct PendingClipboardRestore { latest_restore_id: u64, original_text: Option, } -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] static NEXT_CLIPBOARD_RESTORE_ID: AtomicU64 = AtomicU64::new(1); -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] static PENDING_CLIPBOARD_RESTORE: Lazy>> = Lazy::new(|| Mutex::new(None)); @@ -232,7 +237,7 @@ fn copy_to_clipboard(text: &str) -> bool { } } -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] 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() { @@ -279,7 +284,7 @@ fn insert_with_clipboard_restore( insertion_success_status() } -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn schedule_clipboard_restore(plan: ClipboardRestorePlan) { let (restore_id, original_text) = remember_pending_clipboard_restore(plan.previous_text.clone()); @@ -288,7 +293,7 @@ fn schedule_clipboard_restore(plan: ClipboardRestorePlan) { }); } -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn remember_pending_clipboard_restore(previous_text: Option) -> (u64, Option) { let restore_id = NEXT_CLIPBOARD_RESTORE_ID.fetch_add(1, Ordering::SeqCst); let original_text = { @@ -306,7 +311,7 @@ fn remember_pending_clipboard_restore(previous_text: Option) -> (u64, Op (restore_id, original_text) } -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn restore_clipboard_after_delay( plan: ClipboardRestorePlan, original_text: Option, @@ -357,7 +362,7 @@ fn restore_clipboard_after_delay( clear_pending_clipboard_restore(restore_id); } -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn is_latest_clipboard_restore(restore_id: u64) -> bool { matches!( PENDING_CLIPBOARD_RESTORE.lock().as_ref(), @@ -365,7 +370,7 @@ fn is_latest_clipboard_restore(restore_id: u64) -> bool { ) } -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn clear_pending_clipboard_restore(restore_id: u64) { let mut pending = PENDING_CLIPBOARD_RESTORE.lock(); if matches!(pending.as_ref(), Some(batch) if batch.latest_restore_id == restore_id) { @@ -373,7 +378,7 @@ fn clear_pending_clipboard_restore(restore_id: u64) { } } -#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn should_restore_clipboard(current_text: Option<&str>, inserted_text: &str) -> bool { matches!(current_text, Some(current) if current == inserted_text) } @@ -594,7 +599,7 @@ mod tests { use std::time::Duration; #[test] - #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] fn restore_only_when_clipboard_still_holds_inserted_text() { assert!(should_restore_clipboard( Some("dictated text"), @@ -724,7 +729,7 @@ mod tests { } #[test] - #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] fn pending_clipboard_restore_keeps_first_original_until_latest_restore() { *PENDING_CLIPBOARD_RESTORE.lock() = None; @@ -746,7 +751,7 @@ mod tests { } #[test] - #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] fn clipboard_restore_skips_when_clipboard_no_longer_matches_inserted_text() { assert!(should_restore_clipboard( Some("dictated text"), @@ -761,15 +766,18 @@ mod tests { #[test] #[cfg(target_os = "macos")] - fn macos_direct_write_or_paste_failure_keeps_copied_fallback_available() { - assert_eq!( - macos_insert_status_after_paste(Ok(())), - InsertStatus::Inserted - ); - assert_eq!( - macos_insert_status_after_paste(Err("AX direct write unavailable".to_string())), - InsertStatus::CopiedFallback - ); + fn macos_paste_success_reports_inserted_and_guards_restore() { + // 粘贴成功 → Inserted;恢复仅在剪贴板仍是刚插入的转写文字时进行(issue #525), + // 即「恢复剪贴板」开关在 macOS 上真正生效。 + assert_eq!(insertion_success_status(), InsertStatus::Inserted); + assert!(should_restore_clipboard( + Some("dictated text"), + "dictated text" + )); + assert!(!should_restore_clipboard( + Some("user changed clipboard"), + "dictated text" + )); } #[test]