fix(insertion): macOS 让「恢复剪贴板」开关真正生效,修复被回退的 #525 (#683)

#525 的 macOS 剪贴板恢复被 Android 移植 commit 1ea467a 用 cfg(not(any(macos,android,ios))) 误伤排除。本 PR 把 40+ 处 cfg 放宽为 not(any(android,ios)),macOS 重新纳入;insert() 改为「保存原剪贴板 → 写转写 → Cmd+V → 按开关决定是否恢复」。

已解决与 beta(#712 安卓集成)在 insert() 签名上的冲突,cargo check 通过,四平台 CI 全绿。
Claude 审核:cfg 边界清晰、三态逻辑正确,无阻塞性问题。Closes #525
This commit is contained in:
TRIP authored and GitHub committed 2026-06-21 00:27:39 +08:00
1 parent cfb09b14f8
commit 50274d1acd
1 file changed
+52 -44
+52 -44
View File
@@ -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,
restore_clipboard_after_paste: bool,
_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;
}
macos_insert_status_after_paste(simulate_paste())
if restore_clipboard_after_paste {
schedule_clipboard_restore(restore_plan);
}
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<String>,
}
#[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<String>,
}
#[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<Mutex<Option<PendingClipboardRestore>>> =
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<ClipboardRestorePlan, String> {
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<String>) -> (u64, Option<String>) {
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<String>) -> (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<String>,
@@ -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]