diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 27d8fe4..ff2e32e 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -35,3 +35,9 @@ jobs: - run: npm ci - run: npm run build - run: npm run desktop:check + - run: plutil -lint src-tauri/Info.plist src-tauri/Entitlements.plist + - run: npm run desktop:build:app + - name: Verify bundled Apple Events usage text + run: | + test "$(/usr/libexec/PlistBuddy -c 'Print :NSAppleEventsUsageDescription' 'src-tauri/target/release/bundle/macos/逐词.app/Contents/Info.plist')" = \ + '用于在辅助功能接口无法提供选中文字时,控制 System Events 发送复制快捷键。' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9643927..321f6e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,6 +61,7 @@ jobs: workspaces: src-tauri - run: npm ci - run: npm run check + - run: npm run desktop:check - run: npx playwright install chromium - run: npm run test:smoke - uses: tauri-apps/tauri-action@v1 @@ -78,3 +79,21 @@ jobs: releaseBody: "See CHANGELOG.md for details." releaseDraft: true prerelease: true + - name: Verify signed bundle permissions + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + shell: bash + run: | + set -euo pipefail + app_path='src-tauri/target/release/bundle/macos/逐词.app' + temp_dir="$(mktemp -d)" + trap 'rm -rf "$temp_dir"' EXIT + entitlements_path="$temp_dir/entitlements.plist" + codesign --verify --deep --strict --verbose=2 "$app_path" + signature_details="$(codesign -dv --verbose=4 "$app_path" 2>&1)" + grep -Fq 'Authority=Developer ID Application:' <<< "$signature_details" + grep -Fxq "TeamIdentifier=$APPLE_TEAM_ID" <<< "$signature_details" + codesign -d --xml --entitlements "$entitlements_path" "$app_path" + test "$(/usr/libexec/PlistBuddy -c 'Print :NSAppleEventsUsageDescription' "$app_path/Contents/Info.plist")" = \ + '用于在辅助功能接口无法提供选中文字时,控制 System Events 发送复制快捷键。' + test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.automation.apple-events' "$entitlements_path")" = true diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f8ec5..95ee5d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ All notable changes are documented here. Versions follow Semantic Versioning. - GitHub pull request, CI, security scanning, dependency updates, and beta release workflows. - Automated API and browser smoke coverage. +### Fixed + +- Copy fallback now snapshots every declared macOS pasteboard representation, restores it as current-host-only, bounds System Events waits without blocking the app event loop, and quarantines ambiguous post-Copy failures until restart. + ## [0.1.0-beta.1] - 2026-08-11 ### Added diff --git a/README.md b/README.md index ed0cef3..fa8f50f 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ npm ci npm run tauri dev ``` -全局划词需要在“系统设置 → 隐私与安全性 → 辅助功能”中允许逐词。客户端会优先通过 macOS Accessibility API 读取选区;不支持该属性的应用会使用复制回退,尝试恢复原来的文本剪贴板,并可能请求“自动化 → System Events”权限。关闭选区快捷键只会取消全局快捷键注册,不会撤销已经授予的系统权限。 +全局划词需要在“系统设置 → 隐私与安全性 → 辅助功能”中允许逐词。客户端会优先通过 macOS Accessibility API 读取选区;不支持该属性的应用会使用复制回退,并可能请求“自动化 → System Events”权限。复制回退会短暂使用系统剪贴板:客户端先在内存中快照全部项目及其已声明格式,只在 `changeCount` 仍符合候选版本时恢复;恢复开始前已观察到的额外或意外变化会保留较新内容并取消本次捕获。剪贴板快照不会发送给翻译引擎,恢复后的快照仅保留在当前 Mac。macOS 不提供写入者身份或原子条件恢复,因此复制回退仍有无法完全消除的并发边界;Copy 已发出但捕获未安全完成时,本次运行会停用复制回退,需检查当前剪贴板并重启逐词。关闭选区快捷键只会取消全局快捷键注册,不会撤销已经授予的系统权限。 生成本地 `.app`: diff --git a/docs/architecture.md b/docs/architecture.md index f446287..5e7d2e1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,6 +8,7 @@ Web UI ---- HTTP ---- Node API ---- Ollama / cloud model Desktop UI - invoke --- Tauri ---- Ollama / cloud model | +--- macOS Accessibility selection capture + +--- Native NSPasteboard snapshot + System Events copy fallback ``` Both backends tokenize text before model invocation and validate that every input token has exactly one output segment. The web API remains useful for hosted deployments. The Tauri backend allows a signed desktop build to run without a bundled Node runtime. @@ -15,3 +16,5 @@ Both backends tokenize text before model invocation and validate that every inpu Document text extraction happens in the frontend so it works in both surfaces. Preserving the layout of an edited source document is intentionally a separate export subsystem. Secrets must never be compiled into frontend assets. A user-supplied cloud API key is held in page memory; production shared credentials require a separately authenticated backend. + +The copy fallback materializes every declared pasteboard representation on the macOS main thread before sending Command+C. The external System Events process is waited on away from the UI thread and is terminated and reaped after a 15-second watchdog; pasteboard observation and restoration then return to the main thread. The completion task survives cancellation of the originating frontend invoke so it can still finish the guarded restoration. Once Copy has been dispatched, only a stable candidate followed by successful restoration permits another fallback transaction. A forced stop, no-selection timeout, generation anomaly, native read or restore failure, or abandoned request quarantines copy fallback until the app restarts, because terminating `osascript` cannot retract an event already delivered to System Events and a successful send does not prove that the target application has processed it. The in-memory snapshot is still released on the main thread when that attempt ends. Candidate text is returned only after its generation remains stable and the original snapshot has been restored with `NSPasteboardContentsCurrentHostOnly`. `NSPasteboard` exposes neither writer identity, the original host-only option, nor an atomic conditional replacement, so the first post-command generation cannot be attributed with certainty and a check-to-write TOCTOU boundary remains. A delayed copy may still change the current clipboard after an error, even though quarantine prevents it from entering a newer Wordwise transaction. These are signed-release test and disclosure obligations, not absolute safety guarantees. diff --git a/docs/building-en.md b/docs/building-en.md index b5d672a..d162356 100644 --- a/docs/building-en.md +++ b/docs/building-en.md @@ -51,7 +51,11 @@ npm run tauri dev Wordwise opens as a native desktop window. Select English in another macOS app and press the default `Option + K` shortcut to capture it. You can disable selection translation or record another key combination in Settings. -The first selection capture requires permission in **System Settings -> Privacy & Security -> Accessibility**. Wordwise first tries the macOS Accessibility API. When an app does not expose its selection there, Wordwise uses a copy fallback, attempts to restore the previous text clipboard, and may also request permission under **Automation -> System Events**. Disabling the shortcut only unregisters the global shortcut; remove Wordwise in macOS System Settings to revoke the permissions themselves. +The first selection capture requires permission in **System Settings -> Privacy & Security -> Accessibility**. Wordwise first tries the macOS Accessibility API. When an app does not expose its selection there, Wordwise uses a copy fallback and may also request permission under **Automation -> System Events**. The fallback briefly uses the system pasteboard: Wordwise snapshots every pasteboard item and declared representation in memory, then restores them only while `changeCount` still identifies the candidate generation. An additional or unexpected change observed before restoration begins keeps the newer contents and cancels the capture. The snapshot is never sent to a translation engine and is released from memory after capture. Disabling the shortcut only unregisters the global shortcut; remove Wordwise in macOS System Settings to revoke the permissions themselves. + +`NSPasteboard` exposes neither writer identity nor an atomic compare-and-swap operation. Wordwise cannot prove that the first generation observed after Command+C came from the target application, and a narrow TOCTOU window remains between the generation check and the restoration write. The implementation protects additional or unexpected changes observed before restoration begins and checks the generation again after clearing. A signed release candidate must still exercise the concurrent-write release checks, and conditional restoration must not be described as an absolute atomic guarantee. + +macOS also does not expose whether the original contents used `NSPasteboardContentsCurrentHostOnly`. Wordwise restores snapshots with `CurrentHostOnly` so sensitive local-only contents are not widened to Universal Clipboard. As a consequence, the restoration itself does not make previously syncable contents available to the user's other Apple devices. ## Configure a translation engine @@ -76,6 +80,7 @@ API keys remain in application memory and are not written to browser storage or - The shortcut does not respond: confirm that selection translation is enabled in Settings and that another app has not reserved the key combination. - The shortcut cannot read the selection: check Wordwise under **System Settings -> Privacy & Security -> Accessibility**. The copy fallback also needs Wordwise to control System Events under **Automation**. Restart the development app after changing either permission. +- Wordwise reports that the pasteboard changed: another write occurred after the candidate generation. Wordwise cancels that capture and does not send the candidate text to a translation engine. Check the current pasteboard, quit and reopen Wordwise, then try again. - Automatic mode reports that no engine is configured: make sure Ollama is running and use `ollama list` to confirm that the model was downloaded. - A development port is already in use: stop the existing Wordwise development process and try again. diff --git a/docs/building.md b/docs/building.md index a104123..cf8bbed 100644 --- a/docs/building.md +++ b/docs/building.md @@ -51,7 +51,11 @@ npm run tauri dev 逐词会以原生桌面窗口打开。在其他 macOS 应用中选中英文,按下默认快捷键 `Option + K` 即可读取选区。可以在设置中关闭选区快捷键,或录入新的组合键。 -第一次划词时,需要在“系统设置 -> 隐私与安全性 -> 辅助功能”中允许逐词。客户端会优先使用 macOS Accessibility API;应用没有暴露选区时,才会使用复制回退,尝试恢复原来的文本剪贴板,并可能要求在“自动化”中允许逐词控制 System Events。关闭选区快捷键只会取消全局快捷键注册;如需撤销读取权限,请在 macOS 系统设置中移除逐词。 +第一次划词时,需要在“系统设置 -> 隐私与安全性 -> 辅助功能”中允许逐词。客户端会优先使用 macOS Accessibility API;应用没有暴露选区时,才会使用复制回退,并可能要求在“自动化”中允许逐词控制 System Events。复制回退会短暂使用系统剪贴板:客户端先在内存中快照全部 pasteboard item 及其已声明 representation,只在 `changeCount` 仍符合候选版本时恢复;恢复开始前已观察到的额外或意外变化会保留较新内容并取消本次捕获。快照不会发送给翻译引擎,捕获结束后从内存释放。关闭选区快捷键只会取消全局快捷键注册;如需撤销读取权限,请在 macOS 系统设置中移除逐词。 + +`NSPasteboard` 不公开写入者身份,也没有原子 compare-and-swap。客户端无法证明 Command+C 后观察到的第一次变化一定来自目标应用,版本检查与恢复写入之间也存在无法完全消除的极窄 TOCTOU 窗口。实现会保护恢复开始前已观察到的额外或意外变化,并在清空后再次检查版本;签名候选包仍必须按发布清单测试并发写入,不应把条件恢复描述成绝对原子保证。 + +macOS 也不公开原内容是否使用 `NSPasteboardContentsCurrentHostOnly`。逐词会用 `CurrentHostOnly` 恢复快照,避免把原本仅本机的敏感内容扩大到 Universal Clipboard;相应地,恢复后的旧内容不会由这次恢复操作主动同步到其他 Apple 设备。 ## 配置翻译引擎 @@ -76,6 +80,7 @@ API Key 只保存在应用运行内存中,不会写入浏览器存储或提交 - 快捷键没有响应:确认设置中的选区快捷键已启用且没有被其他应用占用。 - 快捷键没有读到选区:检查“系统设置 -> 隐私与安全性”中逐词的“辅助功能”权限;复制回退还需要在“自动化”中允许逐词控制 System Events。修改后重新启动开发客户端。 +- 提示剪贴板已更新:候选版本之后又发生了剪贴板写入。逐词会取消本次捕获且不会把该候选内容发送给翻译引擎;请检查当前剪贴板,退出并重新打开逐词后再试。 - 自动模式提示没有引擎:确认 Ollama 应用正在运行,并用 `ollama list` 检查模型是否已经下载。 - 端口被占用:退出已有的逐词开发进程后重试。 diff --git a/docs/releasing.md b/docs/releasing.md index 3e33381..61078ca 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -4,9 +4,10 @@ 1. Update `CHANGELOG.md` and keep the versions in `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` identical. 2. Run `npm run check`, `npm run test:smoke`, and `npm run desktop:check`. -3. Verify selection capture manually with macOS Accessibility permission allowed and denied. +3. On an unsigned local `.app`, verify the full selection matrix in `docs/testing.md`, including byte-identical multi-representation fallback restoration, denied Automation, no selection, and an extra write after the candidate generation. 4. Create and push a beta tag such as `v0.1.0-beta.1`. -5. Review the draft GitHub prerelease and its `.app` / `.dmg` artifacts before publishing. +5. On the signed and notarized candidate, repeat permission revoke/regrant and first-run TCC checks. Confirm the bundled `NSAppleEventsUsageDescription` and inspect the effective entitlements with `codesign -d --entitlements - `. +6. Review the draft GitHub prerelease and its `.app` / `.dmg` artifacts before publishing. ## Signing and notarization diff --git a/docs/testing.md b/docs/testing.md index 799ceaa..b8a26c3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,10 +12,19 @@ ## Release gate - All pull request checks -- Manual macOS selection capture with Accessibility allowed and denied +- AX-selected text succeeds without changing any pasteboard item or representation +- Forced copy fallback restores byte-identical plain text, RTF/HTML, image, file URL, and multi-item pasteboards +- Denied Automation leaves the original pasteboard unchanged and reports **Automation -> System Events** +- With no concurrent write and an unchanged `changeCount`, no selection reports the existing reason without reading stale pasteboard text, then quarantines copy fallback until restart +- An unexpected generation observed before restoration starts is preserved and cancels translation +- A stalled System Events process is terminated by the 15-second watchdog without freezing the app event loop +- A timeout, no-selection result, generation anomaly, native failure, or abandoned request quarantines copy fallback until restart, so a late event cannot enter a newer transaction +- First-change attribution and the restore check-to-write TOCTOU boundary are reviewed as residual risks, not marked as atomically solved +- Accessibility and Automation permission revoke/regrant paths work after restart +- The signed candidate shows the expected first-run TCC text; inspect its effective entitlements with `codesign -d --entitlements - ` - Ollama available, cloud available, and full fallback paths - TXT, Markdown, DOCX, and PDF imports -- Unsigned local bundle launch before signing or notarization +- Unsigned local bundle launch before signing, plus signed/notarized candidate launch before publication ## Nightly candidates diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 23e8790..72bce3f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4712,6 +4712,9 @@ name = "wordwise" version = "0.1.0-beta.1" dependencies = [ "core-foundation", + "objc2", + "objc2-app-kit", + "objc2-foundation", "regex", "reqwest 0.12.28", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a80da1f..a531d0a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,3 +23,6 @@ tauri-plugin-global-shortcut = "2" [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.10" +objc2 = { version = "0.6", features = ["exception"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard", "NSPasteboardItem"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData", "NSString"] } diff --git a/src-tauri/Entitlements.plist b/src-tauri/Entitlements.plist new file mode 100644 index 0000000..5062340 --- /dev/null +++ b/src-tauri/Entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.automation.apple-events + + + diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index e122db2..e079dfb 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -3,6 +3,6 @@ NSAppleEventsUsageDescription - 用于在辅助功能接口不可用时读取当前选中的文字。 + 用于在辅助功能接口无法提供选中文字时,控制 System Events 发送复制快捷键。 diff --git a/src-tauri/src/selection.rs b/src-tauri/src/selection.rs index 629caa3..f6ce23a 100644 --- a/src-tauri/src/selection.rs +++ b/src-tauri/src/selection.rs @@ -1,14 +1,67 @@ +#[cfg(target_os = "macos")] +mod clipboard_policy; +#[cfg(target_os = "macos")] +mod macos_pasteboard; + #[cfg(target_os = "macos")] mod macos { + use super::macos_pasteboard::{PasteboardError, PasteboardSession}; use core_foundation::{ base::{CFGetTypeID, CFRelease, CFTypeRef, TCFType}, string::{CFString, CFStringGetTypeID, CFStringRef}, }; - use std::{ffi::c_void, process::Command}; + use std::{ + cell::RefCell, + ffi::c_void, + os::unix::process::ExitStatusExt, + process::{Child, Command, Stdio}, + sync::atomic::{AtomicBool, AtomicU64, Ordering}, + thread, + time::{Duration, Instant}, + }; type AXUIElementRef = *const c_void; type AXError = i32; - const NO_SELECTION_MARKER: &str = "__WORDWISE_NO_SELECTION_5E715CF9__"; + static NEXT_TRANSACTION_ID: AtomicU64 = AtomicU64::new(1); + static FALLBACK_QUARANTINED: AtomicBool = AtomicBool::new(false); + const COPY_SCRIPT: &str = + r#"tell application "System Events" to key code 8 using command down"#; + const COPY_COMMAND_TIMEOUT: Duration = Duration::from_secs(15); + const COPY_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(10); + const COPY_FAILURE_SETTLE_INTERVAL: Duration = Duration::from_millis(100); + + thread_local! { + static PENDING_FALLBACK: RefCell> = const { RefCell::new(None) }; + } + + pub(super) enum CaptureStart { + Selected(String), + Fallback(FallbackCapture), + } + + pub(super) struct FallbackCapture { + transaction_id: u64, + deadline: Instant, + child: Option, + cleanup_app: Option, + } + + pub(super) struct ReadyFallback { + transaction_id: u64, + command_result: Result<(), CopyCommandFailure>, + } + + struct PendingFallback { + transaction_id: u64, + session: PasteboardSession, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum CopyCommandFailure { + SpawnFailed, + Rejected, + UnsafeTermination, + } #[link(name = "ApplicationServices", kind = "framework")] unsafe extern "C" { @@ -56,64 +109,259 @@ mod macos { } let value = CFString::wrap_under_create_rule(selected as CFStringRef).to_string(); - let trimmed = value.trim().to_owned(); - (!trimmed.is_empty()).then_some(trimmed) - } - } - - fn clipboard_fallback() -> Result { - // Seed the clipboard so a no-op copy cannot return stale user content. - let script = format!( - r#" -set previousClipboard to the clipboard -set sentinel to "{NO_SELECTION_MARKER}" -try - set the clipboard to sentinel - tell application "System Events" - keystroke "c" using command down - end tell - set selectedText to sentinel - repeat 10 times - delay 0.05 - try - set selectedText to the clipboard as text - on error - set selectedText to sentinel - end try - if selectedText is not sentinel then exit repeat - end repeat - set the clipboard to previousClipboard - if selectedText is sentinel then return sentinel - return selectedText -on error errorMessage number errorNumber - try - set the clipboard to previousClipboard - end try - error errorMessage number errorNumber -end try -"# - ); - - let output = Command::new("osascript") + trimmed_selection(Some(value)).ok() + } + } + + impl FallbackCapture { + pub(super) fn transaction_id(&self) -> u64 { + self.transaction_id + } + + pub(super) fn wait(mut self) -> ReadyFallback { + let child = self + .child + .take() + .expect("fallback child must exist until the watchdog starts"); + let _ = self.cleanup_app.take(); + ReadyFallback { + transaction_id: self.transaction_id, + command_result: wait_for_copy_command(child, self.deadline), + } + } + } + + impl Drop for FallbackCapture { + fn drop(&mut self) { + let Some(child) = self.child.take() else { + return; + }; + + terminate_and_reap(child); + FALLBACK_QUARANTINED.store(true, Ordering::Release); + if let Some(app) = self.cleanup_app.take() { + let transaction_id = self.transaction_id; + let _ = app.run_on_main_thread(move || { + quarantine_transaction(transaction_id); + }); + } + } + } + + impl ReadyFallback { + pub(super) fn finish(self) -> Result { + objc2::rc::autoreleasepool(|_| finish_fallback(self)) + } + } + + fn begin_fallback(app: tauri::AppHandle) -> Result { + if FALLBACK_QUARANTINED.load(Ordering::Acquire) { + return Err(quarantined_fallback_message()); + } + if pending_fallback_exists() { + return Err("正在读取上一段选区,请稍后重试".into()); + } + + let session = PasteboardSession::begin().map_err(pasteboard_message)?; + if !session.original_is_current().map_err(pasteboard_message)? { + return Err(pasteboard_message(PasteboardError::SnapshotUnstable)); + } + + let child = match Command::new("/usr/bin/osascript") .arg("-e") - .arg(script) - .output() - .map_err(|error| format!("无法启动系统选区读取:{error}"))?; + .arg(COPY_SCRIPT) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => child, + Err(_) => { + return Err(copy_command_failure_message( + &session, + CopyCommandFailure::SpawnFailed, + )) + } + }; - if !output.status.success() { - return Err( - "无法通过复制读取选中文字。请在“系统设置 → 隐私与安全性”中检查逐词的“辅助功能”和“自动化 → System Events”权限。".into(), - ); + let transaction_id = NEXT_TRANSACTION_ID.fetch_add(1, Ordering::Relaxed); + let deadline = Instant::now() + COPY_COMMAND_TIMEOUT; + PENDING_FALLBACK.with(|pending| { + *pending.borrow_mut() = Some(PendingFallback { + transaction_id, + session, + }); + }); + + Ok(CaptureStart::Fallback(FallbackCapture { + transaction_id, + deadline, + child: Some(child), + cleanup_app: Some(app), + })) + } + + fn finish_fallback(ready: ReadyFallback) -> Result { + let pending = take_pending_fallback(ready.transaction_id)?; + if matches!( + ready.command_result, + Err(CopyCommandFailure::UnsafeTermination) + ) { + return Err(quarantine_after_dispatched_copy( + "System Events 选区读取未能正常结束。".into(), + )); + } + + if let Err(failure) = ready.command_result { + return Err(copy_command_failure_message(&pending.session, failure)); + } + + let candidate = match pending.session.wait_for_copy() { + Ok(candidate) => candidate, + Err(PasteboardError::NoSelection) => { + return Err(quarantine_after_dispatched_copy( + "没有检测到选中的文字。".into(), + )) + } + Err(error) => { + return Err(quarantine_after_dispatched_copy( + pasteboard_quarantine_reason(error).into(), + )) + } + }; + + let text = pending.session.read_text(candidate); + if matches!(text, Err(PasteboardError::ConcurrentChange)) { + return Err(quarantine_after_dispatched_copy( + pasteboard_quarantine_reason(PasteboardError::ConcurrentChange).into(), + )); + } + + if let Err(error) = pending.session.restore(candidate) { + return Err(quarantine_after_dispatched_copy( + pasteboard_quarantine_reason(error).into(), + )); + } + + match text { + Ok(text) => trimmed_selection(text), + Err(error) => Err(quarantine_after_dispatched_copy( + pasteboard_quarantine_reason(error).into(), + )), + } + } + + fn pending_fallback_exists() -> bool { + PENDING_FALLBACK.with(|pending| pending.borrow().is_some()) + } + + fn take_pending_fallback(transaction_id: u64) -> Result { + PENDING_FALLBACK.with(|pending| { + let mut pending = pending.borrow_mut(); + match pending.as_ref() { + Some(value) if value.transaction_id == transaction_id => { + Ok(pending.take().expect("matching transaction must exist")) + } + Some(_) => Err("选区读取事务已过期,未更改当前剪贴板,请重试".into()), + None => Err("选区读取事务已结束,请重试".into()), + } + }) + } + + fn wait_for_copy_command( + mut child: Child, + deadline: Instant, + ) -> Result<(), CopyCommandFailure> { + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) if status.signal().is_some() => { + return Err(CopyCommandFailure::UnsafeTermination) + } + Ok(Some(_)) => return Err(CopyCommandFailure::Rejected), + Ok(None) => { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + terminate_and_reap(child); + thread::sleep(COPY_FAILURE_SETTLE_INTERVAL); + return Err(CopyCommandFailure::UnsafeTermination); + } + thread::sleep(remaining.min(COPY_COMMAND_POLL_INTERVAL)); + } + Err(_) => { + terminate_and_reap(child); + thread::sleep(COPY_FAILURE_SETTLE_INTERVAL); + return Err(CopyCommandFailure::UnsafeTermination); + } + } + } + } + + fn terminate_and_reap(mut child: Child) { + if child.kill().is_ok() { + let _ = child.wait(); + return; + } + + if matches!(child.try_wait(), Ok(Some(_))) { + return; } - parse_clipboard_output(&output.stdout) + let _ = thread::Builder::new() + .name("wordwise-osascript-reaper".into()) + .spawn(move || { + let _ = child.kill(); + let _ = child.wait(); + }); } - fn parse_clipboard_output(stdout: &[u8]) -> Result { - let selected = String::from_utf8_lossy(stdout).trim().to_owned(); - if selected == NO_SELECTION_MARKER { - return Err("没有检测到选中的文字".into()); + fn copy_command_failure_message( + session: &PasteboardSession, + failure: CopyCommandFailure, + ) -> String { + match session.original_is_current() { + Ok(false) => return pasteboard_message(PasteboardError::ConcurrentChange), + Err(error) => return pasteboard_message(error), + Ok(true) => {} } + + match failure { + CopyCommandFailure::SpawnFailed => { + "无法启动 System Events 选区读取,请重试" + } + CopyCommandFailure::Rejected => { + "无法发送复制快捷键。请在“系统设置 → 隐私与安全性 → 自动化”中允许逐词控制 System Events。" + } + CopyCommandFailure::UnsafeTermination => return quarantined_fallback_message(), + } + .into() + } + + fn quarantined_fallback_message() -> String { + "无法确认 System Events 不会再发送复制事件。为避免影响下一次划词,本次运行已禁用复制回退;请检查当前剪贴板,然后退出并重新打开逐词。" + .into() + } + + fn quarantine_after_dispatched_copy(reason: String) -> String { + FALLBACK_QUARANTINED.store(true, Ordering::Release); + format!("{reason} {}", quarantined_fallback_message()) + } + + fn quarantine_transaction(transaction_id: u64) { + FALLBACK_QUARANTINED.store(true, Ordering::Release); + PENDING_FALLBACK.with(|pending| { + let mut pending = pending.borrow_mut(); + if pending + .as_ref() + .is_some_and(|value| value.transaction_id == transaction_id) + { + pending.take(); + } + }); + } + + fn trimmed_selection(value: Option) -> Result { + let selected = value.unwrap_or_default().trim().to_owned(); if selected.is_empty() { Err("没有检测到选中的文字".into()) } else { @@ -121,49 +369,212 @@ end try } } - pub fn capture() -> Result { - accessibility_selection().map_or_else(clipboard_fallback, Ok) + fn pasteboard_message(error: PasteboardError) -> String { + match error { + PasteboardError::SnapshotUnstable => { + "剪贴板正在变化。为避免覆盖其他应用的新内容,本次划词已取消,请重试。" + } + PasteboardError::SnapshotIncomplete => { + "无法安全保存当前剪贴板的全部格式,因此未执行复制。请稍后重试。" + } + PasteboardError::SnapshotTooLarge => { + "当前剪贴板内容过大,无法安全执行复制回退。请清理剪贴板后重试。" + } + PasteboardError::NativeAccess => "无法安全访问系统剪贴板,本次划词已取消,请重试。", + PasteboardError::NoSelection => "没有检测到选中的文字", + PasteboardError::ConcurrentChange => { + "检测到剪贴板发生额外或意外变化。为避免继续覆盖或翻译无关内容,本次划词已取消,请重试。" + } + PasteboardError::RestoreBuildFailed => { + "无法准备完整的剪贴板恢复数据,因此未执行复制。请稍后重试。" + } + PasteboardError::RestoreFailed => { + "无法完整恢复原剪贴板,本次选中文字不会用于翻译。请检查剪贴板内容后重试。" + } + } + .into() + } + + fn pasteboard_quarantine_reason(error: PasteboardError) -> &'static str { + match error { + PasteboardError::SnapshotUnstable => "剪贴板状态无法保持稳定。", + PasteboardError::SnapshotIncomplete => "无法确认剪贴板快照仍然完整。", + PasteboardError::SnapshotTooLarge => "剪贴板状态超出安全处理范围。", + PasteboardError::NativeAccess => "无法安全访问系统剪贴板。", + PasteboardError::NoSelection => "没有检测到选中的文字。", + PasteboardError::ConcurrentChange => "检测到剪贴板发生额外或意外变化。", + PasteboardError::RestoreBuildFailed => "无法准备完整的剪贴板恢复数据。", + PasteboardError::RestoreFailed => "无法完整恢复原剪贴板。", + } + } + + pub(super) fn begin_capture(app: tauri::AppHandle) -> Result { + if let Some(selection) = accessibility_selection() { + return Ok(CaptureStart::Selected(selection)); + } + + objc2::rc::autoreleasepool(|_| begin_fallback(app)) + } + + pub(super) fn quarantine_fallback(transaction_id: u64) -> String { + quarantine_transaction(transaction_id); + quarantined_fallback_message() } #[cfg(test)] mod tests { - use super::{parse_clipboard_output, NO_SELECTION_MARKER}; + use super::{ + pasteboard_message, pasteboard_quarantine_reason, trimmed_selection, + wait_for_copy_command, CopyCommandFailure, PasteboardError, + }; + use std::{ + process::Command, + time::{Duration, Instant}, + }; #[test] - fn rejects_unchanged_clipboard_marker() { + fn trims_copied_selection() { assert_eq!( - parse_clipboard_output(NO_SELECTION_MARKER.as_bytes()).unwrap_err(), + trimmed_selection(Some(" underlying code\n".into())).unwrap(), + "underlying code" + ); + } + + #[test] + fn rejects_missing_or_empty_selection() { + assert_eq!(trimmed_selection(None).unwrap_err(), "没有检测到选中的文字"); + assert_eq!( + trimmed_selection(Some(" \n".into())).unwrap_err(), "没有检测到选中的文字" ); } #[test] - fn trims_copied_selection() { + fn concurrent_change_error_never_includes_clipboard_content() { + let message = pasteboard_message(PasteboardError::ConcurrentChange); + assert!(message.contains("剪贴板发生额外或意外变化")); + assert!(!message.contains("underlying code")); + } + + #[test] + fn quarantine_reasons_never_tell_the_user_to_retry_in_place() { + let errors = [ + PasteboardError::SnapshotUnstable, + PasteboardError::SnapshotIncomplete, + PasteboardError::SnapshotTooLarge, + PasteboardError::NativeAccess, + PasteboardError::NoSelection, + PasteboardError::ConcurrentChange, + PasteboardError::RestoreBuildFailed, + PasteboardError::RestoreFailed, + ]; + + for error in errors { + assert!(!pasteboard_quarantine_reason(error).contains("请重试")); + } + } + + #[test] + fn command_watchdog_accepts_success_and_rejects_nonzero_exit() { + let success = Command::new("/usr/bin/true").spawn().unwrap(); assert_eq!( - parse_clipboard_output(b" underlying code\n").unwrap(), - "underlying code" + wait_for_copy_command(success, Instant::now() + Duration::from_secs(1)), + Ok(()) + ); + + let failure = Command::new("/usr/bin/false").spawn().unwrap(); + assert_eq!( + wait_for_copy_command(failure, Instant::now() + Duration::from_secs(1)), + Err(CopyCommandFailure::Rejected) ); } #[test] - fn rejects_empty_clipboard_output() { + fn command_watchdog_quarantines_a_signalled_process() { + let mut child = Command::new("/bin/sleep").arg("1").spawn().unwrap(); + child.kill().unwrap(); assert_eq!( - parse_clipboard_output(b" \n").unwrap_err(), - "没有检测到选中的文字" + wait_for_copy_command(child, Instant::now() + Duration::from_secs(1)), + Err(CopyCommandFailure::UnsafeTermination) + ); + } + + #[test] + fn command_watchdog_terminates_a_hung_process() { + let child = Command::new("/bin/sleep").arg("1").spawn().unwrap(); + assert_eq!( + wait_for_copy_command(child, Instant::now() + Duration::from_millis(10)), + Err(CopyCommandFailure::UnsafeTermination) ); } } } +#[cfg(target_os = "macos")] +async fn run_on_macos_main_thread( + app: &tauri::AppHandle, + task: impl FnOnce() -> T + Send + 'static, +) -> Result +where + T: Send + 'static, +{ + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + app.run_on_main_thread(move || { + let _ = sender.send(task()); + }) + .map_err(|_| "无法在 macOS 主线程读取系统选区,请重试".to_string())?; + + tauri::async_runtime::spawn_blocking(move || receiver.recv()) + .await + .map_err(|_| "系统选区读取任务意外终止,请重试".to_string())? + .map_err(|_| "macOS 主线程未返回选区读取结果,请重试".to_string()) +} + +#[cfg(target_os = "macos")] +async fn complete_macos_fallback( + app: tauri::AppHandle, + fallback: macos::FallbackCapture, +) -> Result { + let transaction_id = fallback.transaction_id(); + let ready = match tauri::async_runtime::spawn_blocking(move || fallback.wait()).await { + Ok(ready) => ready, + Err(_) => { + let message = + run_on_macos_main_thread(&app, move || macos::quarantine_fallback(transaction_id)) + .await?; + return Err(message); + } + }; + run_on_macos_main_thread(&app, move || ready.finish()).await? +} + #[tauri::command] -pub fn capture_selected_text() -> Result { +pub async fn capture_selected_text(app: tauri::AppHandle) -> Result { #[cfg(target_os = "macos")] { - macos::capture() + let capture_app = app.clone(); + let capture = + run_on_macos_main_thread(&app, move || macos::begin_capture(capture_app)).await??; + match capture { + macos::CaptureStart::Selected(selection) => Ok(selection), + macos::CaptureStart::Fallback(fallback) => { + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + tauri::async_runtime::spawn(async move { + let result = complete_macos_fallback(app, fallback).await; + let _ = sender.send(result); + }); + + tauri::async_runtime::spawn_blocking(move || receiver.recv()) + .await + .map_err(|_| "System Events 完成任务意外终止,请重试".to_string())? + .map_err(|_| "System Events 未返回选区读取结果,请重试".to_string())? + } + } } #[cfg(not(target_os = "macos"))] { + let _ = app; Err("当前 beta 版本仅支持 macOS 全局划词".into()) } } diff --git a/src-tauri/src/selection/clipboard_policy.rs b/src-tauri/src/selection/clipboard_policy.rs new file mode 100644 index 0000000..ea2d3a9 --- /dev/null +++ b/src-tauri/src/selection/clipboard_policy.rs @@ -0,0 +1,108 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct ClipboardGeneration(isize); + +impl ClipboardGeneration { + pub(super) fn new(value: isize) -> Self { + Self(value) + } + + pub(super) fn next(self) -> Self { + Self(self.0.wrapping_add(1)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum CopyObservation { + Waiting, + Candidate(ClipboardGeneration), + UnexpectedChange, +} + +pub(super) fn observe_copy( + original: ClipboardGeneration, + current: ClipboardGeneration, +) -> CopyObservation { + if current == original { + CopyObservation::Waiting + } else if generation_is_next(original, current) { + CopyObservation::Candidate(current) + } else { + CopyObservation::UnexpectedChange + } +} + +pub(super) fn generation_is_current( + expected: ClipboardGeneration, + current: ClipboardGeneration, +) -> bool { + expected == current +} + +pub(super) fn generation_is_next( + previous: ClipboardGeneration, + current: ClipboardGeneration, +) -> bool { + current == previous.next() +} + +#[cfg(test)] +mod tests { + use super::{ + generation_is_current, generation_is_next, observe_copy, ClipboardGeneration, + CopyObservation, + }; + + #[test] + fn unchanged_generation_keeps_waiting_for_copy() { + let generation = ClipboardGeneration::new(12); + assert_eq!( + observe_copy(generation, generation), + CopyObservation::Waiting + ); + } + + #[test] + fn one_generation_change_becomes_the_copy_candidate() { + let original = ClipboardGeneration::new(12); + let changed = ClipboardGeneration::new(13); + assert_eq!( + observe_copy(original, changed), + CopyObservation::Candidate(changed) + ); + } + + #[test] + fn a_generation_jump_is_rejected_as_concurrent_activity() { + assert_eq!( + observe_copy(ClipboardGeneration::new(12), ClipboardGeneration::new(97)), + CopyObservation::UnexpectedChange + ); + } + + #[test] + fn generation_wraparound_still_accepts_one_change() { + let original = ClipboardGeneration::new(isize::MAX); + let changed = ClipboardGeneration::new(isize::MIN); + assert_eq!( + observe_copy(original, changed), + CopyObservation::Candidate(changed) + ); + } + + #[test] + fn next_generation_rejects_a_skipped_clear_transition() { + let previous = ClipboardGeneration::new(20); + assert!(generation_is_next(previous, ClipboardGeneration::new(21))); + assert!(!generation_is_next(previous, ClipboardGeneration::new(22))); + } + + #[test] + fn candidate_can_be_restored_only_while_it_is_current() { + let candidate = ClipboardGeneration::new(13); + assert!(generation_is_current(candidate, candidate)); + assert!(!generation_is_current( + candidate, + ClipboardGeneration::new(14) + )); + } +} diff --git a/src-tauri/src/selection/macos_pasteboard.rs b/src-tauri/src/selection/macos_pasteboard.rs new file mode 100644 index 0000000..5716fa1 --- /dev/null +++ b/src-tauri/src/selection/macos_pasteboard.rs @@ -0,0 +1,404 @@ +use super::clipboard_policy::{ + generation_is_current, generation_is_next, observe_copy, ClipboardGeneration, CopyObservation, +}; +use objc2::{exception, rc::Retained, runtime::ProtocolObject}; +use objc2_app_kit::{ + NSPasteboard, NSPasteboardContentsOptions, NSPasteboardItem, NSPasteboardTypeString, + NSPasteboardWriting, +}; +use objc2_foundation::{NSArray, NSData, NSString}; +use std::{ + panic::AssertUnwindSafe, + thread, + time::{Duration, Instant}, +}; + +const MAX_PASTEBOARD_ITEMS: usize = 256; +const MAX_TYPES_PER_ITEM: usize = 256; +const MAX_SNAPSHOT_BYTES: usize = 128 * 1024 * 1024; +const SNAPSHOT_ATTEMPTS: usize = 3; +const COPY_TIMEOUT: Duration = Duration::from_millis(500); +const COPY_POLL_INTERVAL: Duration = Duration::from_millis(50); +const CANDIDATE_SETTLE_INTERVAL: Duration = Duration::from_millis(10); + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PasteboardRepresentation { + type_name: String, + data: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct PasteboardSnapshot { + items: Vec>, +} + +struct PreparedSnapshot { + objects: Retained>>, + is_empty: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum PasteboardError { + SnapshotUnstable, + SnapshotIncomplete, + SnapshotTooLarge, + NativeAccess, + NoSelection, + ConcurrentChange, + RestoreBuildFailed, + RestoreFailed, +} + +pub(super) struct PasteboardSession { + pasteboard: Retained, + original: ClipboardGeneration, + prepared: PreparedSnapshot, +} + +impl PasteboardSession { + pub(super) fn begin() -> Result { + let pasteboard = catch_native(NSPasteboard::generalPasteboard)?; + + for _ in 0..SNAPSHOT_ATTEMPTS { + let before = generation(&pasteboard)?; + let snapshot = catch_native(|| snapshot_contents(&pasteboard))??; + let after = generation(&pasteboard)?; + if before != after { + continue; + } + + let prepared = catch_native(|| prepare_snapshot(&snapshot))??; + if generation_is_current(after, generation(&pasteboard)?) { + return Ok(Self { + pasteboard, + original: after, + prepared, + }); + } + } + + Err(PasteboardError::SnapshotUnstable) + } + + pub(super) fn original_is_current(&self) -> Result { + Ok(generation_is_current( + self.original, + generation(&self.pasteboard)?, + )) + } + + pub(super) fn wait_for_copy(&self) -> Result { + let deadline = Instant::now() + COPY_TIMEOUT; + loop { + let current = generation(&self.pasteboard)?; + match observe_copy(self.original, current) { + CopyObservation::Waiting if Instant::now() < deadline => { + thread::sleep(COPY_POLL_INTERVAL); + } + CopyObservation::Waiting => return Err(PasteboardError::NoSelection), + CopyObservation::Candidate(candidate) => { + thread::sleep(CANDIDATE_SETTLE_INTERVAL); + if generation_is_current(candidate, generation(&self.pasteboard)?) { + return Ok(candidate); + } + return Err(PasteboardError::ConcurrentChange); + } + CopyObservation::UnexpectedChange => return Err(PasteboardError::ConcurrentChange), + } + } + } + + pub(super) fn read_text( + &self, + candidate: ClipboardGeneration, + ) -> Result, PasteboardError> { + if !generation_is_current(candidate, generation(&self.pasteboard)?) { + return Err(PasteboardError::ConcurrentChange); + } + + let text = catch_native(|| { + let string_type = unsafe { NSPasteboardTypeString }; + self.pasteboard + .stringForType(string_type) + .map(|value| value.to_string()) + }); + + if !generation_is_current(candidate, generation(&self.pasteboard)?) { + return Err(PasteboardError::ConcurrentChange); + } + + text + } + + pub(super) fn restore(&self, candidate: ClipboardGeneration) -> Result<(), PasteboardError> { + restore_prepared(&self.pasteboard, &self.prepared, candidate) + } +} + +fn generation(pasteboard: &NSPasteboard) -> Result { + catch_native(|| ClipboardGeneration::new(pasteboard.changeCount())) +} + +fn catch_native(operation: impl FnOnce() -> T) -> Result { + exception::catch(AssertUnwindSafe(operation)).map_err(|_| PasteboardError::NativeAccess) +} + +fn snapshot_contents(pasteboard: &NSPasteboard) -> Result { + let native_items = pasteboard + .pasteboardItems() + .ok_or(PasteboardError::SnapshotIncomplete)? + .to_vec(); + if native_items.len() > MAX_PASTEBOARD_ITEMS { + return Err(PasteboardError::SnapshotTooLarge); + } + + let mut total_bytes = 0usize; + let mut items = Vec::with_capacity(native_items.len()); + for native_item in native_items { + let native_types = native_item.types().to_vec(); + if native_types.is_empty() { + return Err(PasteboardError::SnapshotIncomplete); + } + if native_types.len() > MAX_TYPES_PER_ITEM { + return Err(PasteboardError::SnapshotTooLarge); + } + + let mut representations = Vec::with_capacity(native_types.len()); + for native_type in native_types { + let type_name = native_type.to_string(); + let data = native_item + .dataForType(&native_type) + .ok_or(PasteboardError::SnapshotIncomplete)?; + total_bytes = total_bytes + .checked_add(type_name.len()) + .and_then(|value| value.checked_add(data.len())) + .ok_or(PasteboardError::SnapshotTooLarge)?; + if total_bytes > MAX_SNAPSHOT_BYTES { + return Err(PasteboardError::SnapshotTooLarge); + } + representations.push(PasteboardRepresentation { + type_name, + data: data.to_vec(), + }); + } + items.push(representations); + } + + Ok(PasteboardSnapshot { items }) +} + +fn prepare_snapshot(snapshot: &PasteboardSnapshot) -> Result { + let mut writers = Vec::with_capacity(snapshot.items.len()); + for representations in &snapshot.items { + let item = NSPasteboardItem::new(); + for representation in representations { + let native_type = NSString::from_str(&representation.type_name); + let data = NSData::with_bytes(&representation.data); + if !item.setData_forType(&data, &native_type) { + return Err(PasteboardError::RestoreBuildFailed); + } + } + writers.push(ProtocolObject::from_retained(item)); + } + + Ok(PreparedSnapshot { + objects: NSArray::from_retained_slice(&writers), + is_empty: writers.is_empty(), + }) +} + +fn restore_prepared( + pasteboard: &NSPasteboard, + prepared: &PreparedSnapshot, + candidate: ClipboardGeneration, +) -> Result<(), PasteboardError> { + if !generation_is_current(candidate, generation(pasteboard)?) { + return Err(PasteboardError::ConcurrentChange); + } + + // The original sync option is not observable, so restoration stays local to avoid widening it. + let cleared = ClipboardGeneration::new( + catch_native(|| { + pasteboard + .prepareForNewContentsWithOptions(NSPasteboardContentsOptions::CurrentHostOnly) + }) + .map_err(|_| PasteboardError::RestoreFailed)?, + ); + if !generation_is_next(candidate, cleared) { + return Err(PasteboardError::RestoreFailed); + } + if !generation_is_current( + cleared, + generation(pasteboard).map_err(|_| PasteboardError::RestoreFailed)?, + ) { + return Err(PasteboardError::ConcurrentChange); + } + + if !prepared.is_empty { + let restored = catch_native(|| pasteboard.writeObjects(&prepared.objects)) + .map_err(|_| PasteboardError::RestoreFailed)?; + if !restored { + return Err(PasteboardError::RestoreFailed); + } + } + + if !generation_is_current( + cleared, + generation(pasteboard).map_err(|_| PasteboardError::RestoreFailed)?, + ) { + return Err(PasteboardError::ConcurrentChange); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + generation, prepare_snapshot, restore_prepared, snapshot_contents, PasteboardError, + PasteboardRepresentation, PasteboardSnapshot, + }; + use objc2_app_kit::NSPasteboard; + + #[test] + fn snapshot_model_keeps_item_type_order_and_exact_bytes() { + let snapshot = PasteboardSnapshot { + items: vec![ + vec![ + PasteboardRepresentation { + type_name: "public.utf8-plain-text".into(), + data: b"first".to_vec(), + }, + PasteboardRepresentation { + type_name: "public.html".into(), + data: b"first".to_vec(), + }, + ], + vec![PasteboardRepresentation { + type_name: "public.png".into(), + data: vec![0, 1, 2, 255], + }], + ], + }; + + assert_eq!(snapshot.items.len(), 2); + assert_eq!(snapshot.items[0][1].type_name, "public.html"); + assert_eq!(snapshot.items[1][0].data, vec![0, 1, 2, 255]); + } + + #[test] + fn native_round_trip_preserves_empty_and_multi_item_snapshots() { + objc2::rc::autoreleasepool(|_| { + let pasteboard = NSPasteboard::pasteboardWithUniqueName(); + let empty = PasteboardSnapshot::default(); + let empty_prepared = prepare_snapshot(&empty).unwrap(); + pasteboard.clearContents(); + assert!(empty_prepared.is_empty); + assert_eq!(snapshot_contents(&pasteboard).unwrap(), empty); + + let transient = prepare_snapshot(&PasteboardSnapshot { + items: vec![vec![PasteboardRepresentation { + type_name: "public.utf8-plain-text".into(), + data: b"transient selection".to_vec(), + }]], + }) + .unwrap(); + pasteboard.clearContents(); + assert!(pasteboard.writeObjects(&transient.objects)); + let candidate = generation(&pasteboard).unwrap(); + restore_prepared(&pasteboard, &empty_prepared, candidate).unwrap(); + assert_eq!(snapshot_contents(&pasteboard).unwrap(), empty); + + let mut utf16_text = vec![0xff, 0xfe]; + utf16_text.extend("underlying".encode_utf16().flat_map(u16::to_le_bytes)); + let seed_snapshot = PasteboardSnapshot { + items: vec![ + vec![ + PasteboardRepresentation { + type_name: "public.utf8-plain-text".into(), + data: b"underlying".to_vec(), + }, + PasteboardRepresentation { + type_name: "public.html".into(), + data: b"underlying".to_vec(), + }, + PasteboardRepresentation { + type_name: "public.rtf".into(), + data: br#"{\rtf1 underlying}"#.to_vec(), + }, + PasteboardRepresentation { + type_name: "public.utf16-external-plain-text".into(), + data: utf16_text, + }, + ], + vec![PasteboardRepresentation { + type_name: "public.png".into(), + data: vec![0, 1, 2, 3, 254, 255], + }], + vec![PasteboardRepresentation { + type_name: "public.file-url".into(), + data: b"file:///tmp/wordwise-test.txt".to_vec(), + }], + ], + }; + let seed = prepare_snapshot(&seed_snapshot).unwrap(); + pasteboard.clearContents(); + assert!(pasteboard.writeObjects(&seed.objects)); + let expected = snapshot_contents(&pasteboard).unwrap(); + let prepared = prepare_snapshot(&expected).unwrap(); + let second_transient = prepare_snapshot(&PasteboardSnapshot { + items: vec![vec![PasteboardRepresentation { + type_name: "public.utf8-plain-text".into(), + data: b"transient selection".to_vec(), + }]], + }) + .unwrap(); + pasteboard.clearContents(); + assert!(pasteboard.writeObjects(&second_transient.objects)); + let candidate = generation(&pasteboard).unwrap(); + + restore_prepared(&pasteboard, &prepared, candidate).unwrap(); + assert_eq!(snapshot_contents(&pasteboard).unwrap(), expected); + }); + } + + #[test] + fn conditional_restore_preserves_a_newer_external_update() { + objc2::rc::autoreleasepool(|_| { + let pasteboard = NSPasteboard::pasteboardWithUniqueName(); + let original = prepare_snapshot(&PasteboardSnapshot { + items: vec![vec![PasteboardRepresentation { + type_name: "public.utf8-plain-text".into(), + data: b"original".to_vec(), + }]], + }) + .unwrap(); + let candidate = prepare_snapshot(&PasteboardSnapshot { + items: vec![vec![PasteboardRepresentation { + type_name: "public.utf8-plain-text".into(), + data: b"candidate".to_vec(), + }]], + }) + .unwrap(); + let external_snapshot = PasteboardSnapshot { + items: vec![vec![PasteboardRepresentation { + type_name: "public.utf8-plain-text".into(), + data: b"external update".to_vec(), + }]], + }; + let external = prepare_snapshot(&external_snapshot).unwrap(); + + pasteboard.clearContents(); + assert!(pasteboard.writeObjects(&candidate.objects)); + let candidate_generation = generation(&pasteboard).unwrap(); + pasteboard.clearContents(); + assert!(pasteboard.writeObjects(&external.objects)); + + assert_eq!( + restore_prepared(&pasteboard, &original, candidate_generation), + Err(PasteboardError::ConcurrentChange) + ); + assert_eq!(snapshot_contents(&pasteboard).unwrap(), external_snapshot); + }); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7724f95..27d8d75 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -34,6 +34,7 @@ "longDescription": "本地优先的英语逐词翻译工具,支持 macOS 全局快捷键、本地模型、云端模型和文档导入。", "macOS": { "minimumSystemVersion": "12.0", + "entitlements": "Entitlements.plist", "infoPlist": "Info.plist" } }