20 KiB
AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
Project Overview
ttree is a lightweight macOS AI translator built with Tauri 2 + Svelte 5 + Rust. It is designed as a hidden spotlight-style app: the main window starts hidden, global shortcuts show it instantly, and the window auto-hides on blur.
Core features:
CmdOrCtrl+Shift+Spaceby default toggles the translation window.CmdOrCtrl+Shift+Sby default triggers screenshot OCR translation.- Translation engines: Google and Bing free web endpoints (no key) plus OpenAI-compatible streaming chat completions. A 中⇄英 "adaptive" target auto-picks direction by script detection.
- macOS OCR uses Apple Vision via
objc2-vision(VNRecognizeTextRequest). - Settings persist locally through
tauri-plugin-store; shortcuts are user-configurable and re-registered immediately after saving. API keys are stored in the macOS Keychain, not the plaintext store.
Common Commands
Install dependencies without running arbitrary dependency lifecycle scripts:
npm run ci:install
Run the app in development mode:
npm run tauri dev
The Tauri window is configured with visible: false, so the app may appear to launch with no window. Use the configured global shortcut (default CmdOrCtrl+Shift+Space) to show it.
Frontend-only development server:
npm run dev
Frontend checks and build:
npm run check
npm run build
Frontend tests:
npm test
Rust backend checks/tests:
cd src-tauri && cargo check
cd src-tauri && cargo test
cd src-tauri && cargo test <test_name>
cd src-tauri && cargo clippy
cd src-tauri && cargo fmt
Production app build:
npm run tauri build
Build outputs are under src-tauri/target/release/bundle/, including bundle/macos/TTREE.app and bundle/dmg/*.dmg (productName is TTREE).
An ordinary local tauri build is not proof that a distributable macOS build is valid. Before release, the final .app, DMG, updater archive, updater signature, and latest.json must pass the community-signing checks described below.
Release Process (应用内更新)
The app self-updates via the Tauri Updater plugin. Releases are built and signed by CI, then published to GitHub Releases; installed apps fetch latest.json from the repo and update in place.
When the user asks to publish a new version, follow these steps in order:
- Bump the version in both places (they must match) and commit:
package.json→"version"src-tauri/tauri.conf.json→"version"
- Verify locally before tagging:
npm run check && npm test cd src-tauri && cargo check && cd .. - Tag and push the tag (triggers the release workflow on
v*tags):git tag vX.Y.Z git push origin vX.Y.Z - CI (
.github/workflows/release.yml) builds a singleuniversal-apple-darwin(arm64 + x86_64 combined; the runner only ships arm64 by default, so the workflow explicitly runsrustup target add x86_64-apple-darwinfirst).scripts/package-macos-community.shsigns the app with the persistent community certificate, rebuilds the DMG/updater archive, and verifies both containers. - The workflow creates a draft Release first. It must pass
scripts/verify-macos-community-signature.shand verify all four release assets before publishing. Manualworkflow_dispatchruns are verification-only and never publish. - Watch the run:
gh run watch -R ZASENJC/ttree(or check https://github.com/ZASENJC/ttree/actions). Confirm the published Release listslatest.json+ the signed.tar.gz/.sigassets. - Installed apps auto-check for updates on startup and every 6h, hitting the network only once ≥7 days since the last check; users install from 设置 → 通用. New installs get the latest Release immediately.
Updater-signing prerequisites (already done, do not repeat): signing keys live at ~/.config/ttree-updater/ on this machine; the public key is in tauri.conf.json and the private key is in the TAURI_SIGNING_PRIVATE_KEY repo secret. The private key must carry a strong passphrase: without a second factor, anyone who exfiltrates the key can sign an updater package that installed apps auto-accept (RCE). The passphrase lives in macOS Keychain (account updater-signing-password, service com.samwstu.ttree) for local builds and in the TAURI_SIGNING_PRIVATE_KEY_PASSWORD repo secret for CI. To build a signed update locally instead of via CI: source ./scripts/signing-env.sh && ./scripts/package-macos-community.sh — the environment loader reads the passphrase from Keychain, while the package script withholds the key from the Tauri build and exposes it only to the updater signer. If the key was ever stored unencrypted, rotate it: generate a new rsign/minisign keypair with a passphrase, update tauri.conf.json pubkey, and ship one release on the old key that bumps users to the new key.
Community code-signing prerequisites (configured July 2026): the persistent certificate is TTREE Open Source Code Signing, SHA-1 B8D254561BB6CAD78AA71FA18D62A32D10F50C08. Local encrypted material lives under ~/.config/ttree-code-signing/; its password is stored in Keychain account community-code-signing-password, service com.samwstu.ttree. CI secrets contain only MACOS_COMMUNITY_CERTIFICATE and MACOS_COMMUNITY_CERTIFICATE_PASSWORD; the expected identity and SHA-1 are pinned literals in the workflow and release scripts and must never be secret-overridable. Never commit, print, rotate, recreate, or replace the certificate/private key. Losing or rotating it changes the TCC identity and forces every user to reauthorize Screen Recording.
macOS Code Signing and Screen Recording TCC
This is a release-critical invariant. macOS Screen Recording permission is attached to the app's code signing designated requirement, not just its display name, path, or CFBundleIdentifier.
- Screenshot OCR must keep using Apple's native free-selection command
/usr/sbin/screencapture -i -x -r, followed by Apple Vision OCR. Do not replace it with a custom Tauri selection window, CoreGraphics overlay, or a different screenshot architecture merely to address a permission prompt. - The failure mode seen in July 2026 was an unsigned release bundle whose executable only had a linker-generated ad-hoc signature. Its identity looked like
Identifier=ttree-<hash>,TeamIdentifier=not set,Info.plist=not bound, anddesignated => cdhash .... Every rebuild changed the CDHash, so System Settings could show TTREE as enabled for the old build while the new build was denied and asked again. - Tauri updater minisign keys and macOS code-signing certificates solve different problems. A valid
.tar.gz.sigdoes not give the.appa stable macOS identity and does not preserve TCC permissions; both signing layers are required. - The current GitHub release mode is community-signed, not Apple-notarized. Every distributed build must use the same
TTREE Open Source Code Signingcertificate and the explicit designated requirementidentifier "com.samwstu.ttree" and certificate leaf = H"b8d254561bb6cad78aa71fa18d62a32d10f50c08". - Never publish when
codesign --verify --deep --strictfails,Signature=adhoc, the authority/fingerprint differs,Info.plistis not bound, sealed resources are absent, or the designated requirement is identifier-only/cdhash-based. Runscripts/verify-macos-community-signature.shas the hard gate. - Gatekeeper rejection by
spctlis expected because the community certificate is not Apple-notarized. Release notes and README must instruct first-time users to use System Settings → Privacy & Security → Open Anyway, then grant Screen & System Audio Recording and restart TTREE. workflow_dispatchmust build/upload temporary verification artifacts only. Only a pushedv*tag matchingpackage.jsonmay create a draft Release, and the draft may publish only after asset verification.- A development build working is insufficient. For permission regression testing, install packaged build A, authorize Screen Recording once, then create packaged build B with a different CDHash but the same certificate-backed designated requirement. Install B without calling
tccutil reset; OCR must work without another permission prompt. - Migrating users from an old ad-hoc build to the first community-signed build may require removing/resetting the stale TTREE Screen Recording entry and authorizing once. Later builds signed by the same identity must retain permission.
- A custom ad-hoc requirement may be used only for contained local diagnosis. It is not secure against bundle-ID impersonation and must never be distributed or used as the production fix.
- If the project later joins Apple Developer Program, Developer ID + notarization may replace community signing, but that is an intentional identity migration requiring its own user reauthorization plan. Do not silently mix or switch signing modes.
Architecture
Tauri / Rust backend
src-tauri/src/lib.rs is the backend composition point. It registers plugins, commands, global shortcuts, tray setup, macOS vibrancy, and the blur-to-hide window behavior.
Important backend modules:
commands.rsexposes Tauri commands used by the Svelte frontend:- translation (Google / Bing one-shot, or OpenAI streaming)
- AI chat (
chat,get/set_chat_ai_config) — separate config from translation - screenshot OCR(Apple 原生
screencapture自由框选 + Vision OCR) - show main window
- get/set OpenAI translation config, get/set appearance config
- get/set shortcut config,
set_shortcut_recording(suppresses global shortcuts during key capture) get_pinned/set_pinned— window always-on-top + blur-no-hide toggle- chat history:
load_chat_history,load_conversations,start_new_conversation,append_chat_history,clear_chat_history
shortcut.rsbuilds the global shortcut plugin and dispatches five registered shortcuts:toggle(show/hide window, emitstrigger-translateon show to reset the frontend to the translate view),ocr(screenshot OCR),ai_dialog(open AI chat),selection_translate(capture selected text and translate), andselection_ai_dialog(capture selected text and send to AI). All are optional. Two pairs may intentionally share the same key —toggle↔selection_translateandai_dialog↔selection_ai_dialog; on collision the selection action wins (dispatch order inParsedShortcuts::entrieslists selection actions first), so a shared key runs the selection behavior when text is selected and falls back to the plain toggle/dialog otherwise. All other key collisions are rejected at validation. Startup uses persisted shortcut config; saving validates, unregisters existing shortcuts, and registers the deduplicated set immediately.config.rsowns persisted config models (OpenAiConfig,ChatAiConfig,ShortcutConfig,AppearanceConfig) and store reads/writes throughtauri-plugin-store. Translation and chat use separate AI config entries; each carries its own optional prompt (translate_prompt,chat_prompt).ShortcutConfigholds five independently-optional shortcuts:toggle,ocr,ai_dialog,selection_translate,selection_ai_dialog(empty string disables one). API keys never touch the store —configreads/writes them throughkeychain.save_openai/save_chat_aivalidatebase_url(must be http(s), rejectsfile:/data:) and after a successful Keychain write they also delete any leftover legacy plaintextapi_keykey from the store.Debugimpls redactapi_key. Window size is persisted too, but debounced (500ms) and written on a blocking thread becauseWindowEvent::Resizedfires dozens of times/second during a drag.keychain.rswraps macOS Keychain generic-password items (security-framework). Service is the bundle idcom.samwstu.ttree; accounts areopenai.api_key/chat_ai.api_key.get_secretreturnsOk(None)for a missing item (first-use); non-macOS is a compile-gated stub that errors.config::load_secret_migrateddoes a one-time plaintext→Keychain migration: if the Keychain has nothing but the old store key still holds a plaintext value, it copies the value into the Keychain and clears the store copy.history.rspersists AI chat history as line-delimited JSON (chat-history.jsonl, a separate file fromsettings.json). Append-only writes (one line per message, singlewrite_allper round), astatic Mutexserializes all read/append/clear, and a single corrupt line is skipped rather than aborting the parse. Conversations are split by{"type":"session_start"}markers (#[serde(untagged)]so legacy{"role","content"}lines still parse);Conversation.summaryis the first user line truncated to 40 chars.read_conversationsreturns old→new;read_allflattens to a message stream for backward compatibility.window.rscentralizes main-window show/hide/focus behavior. The app keeps the window hidden instead of destroying it for instant recall.translate/mod.rsdispatches byEngine(openai/google/bing), owns a shared pooledreqwest::Client(two instances: default, and one with a cookie jar for Bing's token dance), and disables redirects (SSRF guard — OpenAI-compatible POSTs are single-hop, so 3xx can't bounce anapi_key-bearing request to an internal host). It also definesChatMessageandvalidate_messages— the IPC-boundary guard (role whitelistuser/assistant/system, 64 KiB per message, ≤200 messages) applied to bothchatandappend_chat_history.translate/google.rscalls the freetranslate.googleapis.comendpoint and parses its nested array response.translate/bing.rscalls the freebing.com/ttranslatev3endpoint. Bing needs a one-shot token (IG / IID / key / token) scraped from the translator page's HTML before POSTing, so it uses the cookie-store client and a browser User-Agent. Unofficial endpoint — may break or rate-limit.translate/openai.rsstreams OpenAI-compatible SSE responses and emitstranslate-chunk(translation) orchat-chunk(AI chat) events to the frontend.selection/macos.rscaptures the currently selected text via the macOS Accessibility API; falls back to a temporaryCmd+C+ clipboard read if Accessibility fails. Non-macOS returnsNone.screenshot.rsinvokes/usr/sbin/screencapture -i -x -rfor Apple's native interactive free-selection UI, writes the selected region to a unique temporary PNG, and treats Esc/no file as cancellation.commands::screenshot_ocrsends that PNG to Vision and removes it afterward.ocr/vision.rsperforms macOS-native OCR with Apple Vision (VNRecognizeTextRequest).tray.rscreates the menu bar tray menu (翻译,设置,退出).
Svelte frontend
src/App.svelte holds two pieces of main-window view state: view (main / settings) and, within main, mode (translate / chat). On mount it loads chat history + appearance config, reads the current version, and kicks off the update auto-check. It listens for five backend events: open-settings, trigger-translate, trigger-ocr, trigger-selected-translate, trigger-ai-dialog. Screenshot selection is owned by the native macOS service; there is no screenshot-selection frontend window.
Important frontend areas:
src/lib/components/TranslatePanel.svelteis the main spotlight UI. It handles Enter-to-translate, Esc-to-hide, engine/language selectors, and the dual-column result view (web translation + AI translation shown side by side).src/lib/views/ChatPanel.svelteis the AI chat view. It sends multi-turn messages, streams the assistant reply viachat-chunk, and surfaces the conversation-history sidebar.src/lib/stores/translation.svelte.tsowns translation state, including two web engines (google/bing) switchable viaWEB_ENGINESand an adaptive target (default) that callsresolveAdaptiveTarget— CJK-containing input → English, otherwise → Chinese.src/lib/stores/chat.svelte.tsowns chat state and orchestrates history.sendText/resetdrive the AI-dialog flow;recentChatContext(fromchatContext.ts) trims to the last 24 messages and drops leading orphan assistant turns before sending.src/lib/stores/appearance.svelte.tsapplies panel opacity to a--panel-opacityCSS var ondocument.documentElement. The opacity min (0.35) is intentionally kept in sync withconfig.rs::PANEL_OPACITY_MINso frontend/backend clamping agree.src/lib/stores/update.svelte.tsdrives the in-app updater. It checks on startup if ≥7 days since the last check (persisted insettings.jsonasupdate.last_check), re-checks every 6h while running, and only re-stamps the timestamp onuptodate/error(so an available-but-uninstalled update survives). Download → install →relaunch().src/lib/stores/languages.tsholds the language list, theadaptive中⇄英 target, andresolveAdaptiveTarget(script-based).- The
.svelte.tsstores (translation,chat,appearance,update) use the.svelte.tsextension because they contain Svelte 5 runes ($state). Do not move rune-based stores back to plain.ts; doing so leaves$stateuncompiled and causes a runtime white screen. (chatContext.ts/languages.ts/shortcuts.tsare plain modules with no runes.) src/lib/shortcuts/shortcutRecorder.tsis a pure utility that converts keyboard events into Tauri-style shortcut strings. It has a Vitest unit test (shortcutRecorder.test.ts).src/lib/api/tauri.tsis the frontend boundary for allinvokecalls and event subscriptions.Enginehere is the string union"openai" | "google" | "bing", matching the RustEngineenum'srename_all = "lowercase".src/lib/views/Settings.svelteedits OpenAI config, chat config, custom shortcuts, autostart, panel opacity, and exposes the updater (check/download/install). It temporarily pins the window on enter and restores the prior pin state on leave.- Styling is based on CSS custom properties in
src/styles/tokens.cssplus global rules insrc/styles/global.css.
Runtime Notes
- Screenshot OCR requires macOS Screen Recording permission. In dev mode the permission prompt may refer to the terminal or dev binary, so dev success does not validate a release package. Packaged builds must keep the certificate-pinned community designated requirement unchanged across updates. Selected-text capture is separate: it prefers Accessibility permission and falls back to a temporary copy.
- OpenAI translation results stream via the
translate-chunkevent; AI chat streams viachat-chunk; Google and Bing return a complete string from thetranslatecommand. - When the window is pinned (
set_pinned), it stays always-on-top and does not auto-hide on blur. - The Google and Bing endpoints are unofficial free endpoints and may be rate-limited or break (Bing scrapes a token from HTML, which is especially fragile).
- API keys live in the macOS Keychain, not the plaintext store. The store holds only non-sensitive config; the only trace of keys is transiently during the one-time plaintext→Keychain migration.
Current Caveats
- The README may lag behind implementation details; check
config.rs,shortcut.rs, andSettings.sveltefor the current shortcut behavior. (It also still claims API keys are plaintext and only two engines exist — both outdated; trust the code.) - Rust unit tests cover: native screenshot command flags, translation response/token parsing (Google, Bing), SSE parsing (OpenAI), selection text normalization, message validation, chat-history JSONL parsing + summary truncation + conversation splitting, and base_url normalization.
scripts/tests/verify-macos-community-signature.test.shandscripts/tests/release-workflow.test.shcover the release identity/workflow contract. Frontend unit tests cover the shortcut recorder (shortcutRecorder.test.ts), chat-context trimming (chatContext.test.ts), and the language list / adaptive target (languages.test.ts); no component-level test suite yet.