Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 发送复制快捷键。'
19 changes: 19 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
3 changes: 3 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ 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.

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.
7 changes: 6 additions & 1 deletion docs/building-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down
7 changes: 6 additions & 1 deletion docs/building.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 设备。

## 配置翻译引擎

Expand All @@ -76,6 +80,7 @@ API Key 只保存在应用运行内存中,不会写入浏览器存储或提交

- 快捷键没有响应:确认设置中的选区快捷键已启用且没有被其他应用占用。
- 快捷键没有读到选区:检查“系统设置 -> 隐私与安全性”中逐词的“辅助功能”权限;复制回退还需要在“自动化”中允许逐词控制 System Events。修改后重新启动开发客户端。
- 提示剪贴板已更新:候选版本之后又发生了剪贴板写入。逐词会取消本次捕获且不会把该候选内容发送给翻译引擎;请检查当前剪贴板,退出并重新打开逐词后再试。
- 自动模式提示没有引擎:确认 Ollama 应用正在运行,并用 `ollama list` 检查模型是否已经下载。
- 端口被占用:退出已有的逐词开发进程后重试。

Expand Down
5 changes: 3 additions & 2 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 - <app>`.
6. Review the draft GitHub prerelease and its `.app` / `.dmg` artifacts before publishing.

## Signing and notarization

Expand Down
13 changes: 11 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 - <app>`
- 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

Expand Down
3 changes: 3 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
8 changes: 8 additions & 0 deletions src-tauri/Entitlements.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
2 changes: 1 addition & 1 deletion src-tauri/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>NSAppleEventsUsageDescription</key>
<string>用于在辅助功能接口不可用时读取当前选中的文字。</string>
<string>用于在辅助功能接口无法提供选中文字时,控制 System Events 发送复制快捷键。</string>
</dict>
</plist>
Loading