refactor: separate native platforms and automate signed dual-platform releases

This commit is contained in:
ZASENJC committed 2026-10-16 00:05:02 +08:00
1 parent 8e14266c18
commit 8d4628aeed
101 files changed
+2165 -2611

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
name: Build and release
on:
push:
branches: ['**']
tags: ['v*']
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: false
jobs:
metadata:
runs-on: ubuntu-latest
outputs:
app_version: ${{ steps.release.outputs.app_version }}
release_tag: ${{ steps.release.outputs.release_tag }}
publish: ${{ steps.release.outputs.publish }}
prerelease: ${{ steps.release.outputs.prerelease }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
- name: Validate release contracts
run: bash scripts/tests/run-release-tests.sh
- name: Resolve release metadata
id: release
run: python3 scripts/release/metadata.py --github-output "$GITHUB_OUTPUT"
macos:
needs: metadata
runs-on: macos-15
env:
MACOSX_DEPLOYMENT_TARGET: '11.0'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Prepare Metal compiler
run: xcrun -f metal >/dev/null 2>&1 || xcodebuild -downloadComponent MetalToolchain
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: macos-universal-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'apps/**/Cargo.toml') }}
restore-keys: macos-universal-
- name: Check and test shared libraries and native apps
run: |
cargo check --workspace --locked
cargo test --workspace --locked
- name: Build universal app without signing material
run: scripts/macos/package.sh universal-apple-darwin "$GITHUB_RUN_NUMBER" --build-only
- name: Sign and verify distributable macOS assets
if: github.event_name != 'pull_request' && github.repository == 'ZASENJC/ttree'
env:
MACOS_COMMUNITY_CERTIFICATE: ${{ secrets.MACOS_COMMUNITY_CERTIFICATE }}
MACOS_COMMUNITY_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_COMMUNITY_CERTIFICATE_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
RELEASE_TAG: ${{ needs.metadata.outputs.release_tag }}
APP_VERSION: ${{ needs.metadata.outputs.app_version }}
run: |
brew list minisign >/dev/null 2>&1 || brew install minisign
scripts/macos/package.sh universal-apple-darwin "$GITHUB_RUN_NUMBER" --package-only
scripts/release/generate-manifest.sh dist/macos "$RELEASE_TAG" "$GITHUB_REPOSITORY"
scripts/release/verify-macos-assets.sh dist/macos "$RELEASE_TAG" "$GITHUB_REPOSITORY" "$APP_VERSION"
python3 scripts/release/checksums.py dist/macos --write macos.sha256
- name: Upload signed macOS assets
if: github.event_name != 'pull_request' && github.repository == 'ZASENJC/ttree'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: macos-${{ github.run_id }}-${{ github.run_attempt }}
path: dist/macos/
if-no-files-found: error
retention-days: 7
compression-level: 0
windows:
needs: metadata
runs-on: windows-2022
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
with:
targets: x86_64-pc-windows-msvc
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: windows-x64-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'apps/**/Cargo.toml') }}
restore-keys: windows-x64-
- name: Check and test shared libraries and native apps
run: |
cargo check --workspace --locked
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
cargo test --workspace --locked
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Build and validate portable ZIP
run: ./scripts/windows/package.ps1
- name: Launch packaged Windows app
run: ./scripts/windows/smoke.ps1 -Exe target/package/windows/TTREE/TTREE.exe -Output target/windows-smoke
- name: Record Windows checksums
run: python scripts/release/checksums.py dist/windows --write windows.sha256
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: windows-${{ github.run_id }}-${{ github.run_attempt }}
path: dist/windows/
if-no-files-found: error
retention-days: 7
compression-level: 0
- name: Upload Windows smoke evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: windows-smoke-${{ github.run_id }}-${{ github.run_attempt }}
path: target/windows-smoke/
if-no-files-found: warn
retention-days: 7
publish:
needs: [metadata, macos, windows]
if: needs.metadata.outputs.publish == 'true' && github.repository == 'ZASENJC/ttree'
runs-on: ubuntu-latest
permissions:
contents: write
env:
APP_VERSION: ${{ needs.metadata.outputs.app_version }}
RELEASE_TAG: ${{ needs.metadata.outputs.release_tag }}
PRERELEASE: ${{ needs.metadata.outputs.prerelease }}
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: macos-${{ github.run_id }}-${{ github.run_attempt }}
path: dist/release
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: windows-${{ github.run_id }}-${{ github.run_attempt }}
path: dist/release
- name: Verify transferred assets and updater signature
env:
VERIFY_MACOS_CONTAINERS: '0'
run: |
sudo apt-get update -qq
sudo apt-get install -y minisign
python3 scripts/release/checksums.py dist/release --check macos.sha256 --check windows.sha256
scripts/release/verify-macos-assets.sh dist/release "$RELEASE_TAG" "$GITHUB_REPOSITORY" "$APP_VERSION"
python3 scripts/release/verify-assets.py dist/release "$APP_VERSION"
rm dist/release/macos.sha256 dist/release/windows.sha256
python3 scripts/release/checksums.py dist/release --write SHA256SUMS
- name: Generate release notes and publish verified draft
run: scripts/release/publish.sh
-470
View File
@@ -1,470 +0,0 @@
name: release
# Manual runs build and verify temporary artifacts only. A pushed v* tag may
# enter the separate publish job after the same verification job succeeds.
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }}
cancel-in-progress: true
jobs:
ocr-current-macos:
# Exercise real OCR on the stable newer Apple Silicon runtime.
runs-on: macos-26
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Check out source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
- name: Cache current macOS OCR dependencies
id: ocr-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: macos-26-ocr-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
macos-26-ocr-
- name: Record native OCR runtime
run: |
sw_vers
uname -m
swift --version
- name: Exercise native OCR on current macOS
run: |
cargo test --manifest-path src-tauri/Cargo.toml ocr:: -- --nocapture
- name: Diagnose native Vision if OCR fails
if: failure()
run: |
fixture_dir="$RUNNER_TEMP/ttree-ocr-diagnostics"
mkdir -p "$fixture_dir"
swift src-tauri/src/ocr/tests/generate-fixtures.swift "$fixture_dir"
swift src-tauri/src/ocr/tests/diagnose-vision.swift "$fixture_dir/english.png"
- name: Save compiled OCR dependencies
if: always() && steps.ocr-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: macos-26-ocr-${{ hashFiles('src-tauri/Cargo.lock') }}
ocr-macos27-compatibility:
runs-on: xcode-27
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Check out source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Probe native Vision capability on macOS 27
id: capability
run: |
sw_vers
uname -m
fixture_dir="$RUNNER_TEMP/ttree-ocr-capability"
mkdir -p "$fixture_dir"
swift src-tauri/src/ocr/tests/generate-fixtures.swift "$fixture_dir"
swift src-tauri/src/ocr/tests/diagnose-vision.swift "$fixture_dir/english.png" 2>&1 |
tee "$fixture_dir/vision.log"
if grep -Eq '(apple-default|default-explicit-cpu|rev3-english-cpu|rev3-chinese-english-cpu|rev2-chinese-english-cpu) software=(false|true): OK Hello OCR' "$fixture_dir/vision.log"; then
echo 'accurate_supported=true' >> "$GITHUB_OUTPUT"
elif grep -Fq 'AppleM2ScalerParavirtDriver' "$fixture_dir/vision.log" &&
grep -Fq 'fast-english-cpu software=false: OK Hello OCR' "$fixture_dir/vision.log"; then
echo 'accurate_supported=false' >> "$GITHUB_OUTPUT"
echo '::warning::macOS 27 VM lacks the image scaler used by Apple accurate Vision OCR; the independent Apple baseline also fails.'
echo 'macOS 27: accurate OCR cannot be validated on this VM because the Apple baseline fails and AppleM2ScalerParavirtDriver is unavailable. Real-image regression gates still run on macOS 14 and 26; physical macOS 27 verification is required.' >> "$GITHUB_STEP_SUMMARY"
else
echo '::error::Unexpected native Vision baseline failure'
exit 1
fi
- name: Install Rust stable
if: steps.capability.outputs.accurate_supported == 'true'
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
- name: Cache macOS 27 OCR dependencies
if: steps.capability.outputs.accurate_supported == 'true'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: macos-27-ocr-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
macos-27-ocr-
- name: Exercise accurate OCR when the platform supports it
if: steps.capability.outputs.accurate_supported == 'true'
run: |
cargo test --manifest-path src-tauri/Cargo.toml ocr:: -- --nocapture
verify:
needs: [ocr-current-macos, ocr-macos27-compatibility]
runs-on: macos-14
permissions:
contents: read
outputs:
app_version: ${{ steps.metadata.outputs.app_version }}
release_tag: ${{ steps.metadata.outputs.release_tag }}
env:
EXPECTED_MACOS_COMMUNITY_SIGNING_IDENTITY: TTREE Open Source Code Signing
EXPECTED_MACOS_COMMUNITY_CERT_SHA1: B8D254561BB6CAD78AA71FA18D62A32D10F50C08
MACOS_COMMUNITY_SIGNING_IDENTITY: TTREE Open Source Code Signing
MACOS_COMMUNITY_CERT_SHA1: B8D254561BB6CAD78AA71FA18D62A32D10F50C08
steps:
- name: Check out source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
- name: Install Rust stable
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Cache Cargo
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-community-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-community-cargo-
- name: Install frontend dependencies
run: npm run ci:install
- name: Run release contract tests
run: npm run test:release
- name: Check frontend
run: npm run check
- name: Test frontend
run: npm test
- name: Check Rust backend
run: cargo check --manifest-path src-tauri/Cargo.toml
- name: Test Rust backend
run: cargo test --manifest-path src-tauri/Cargo.toml
- name: Lint Rust backend
run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings
- name: Resolve and validate release metadata
id: metadata
shell: bash
run: |
set -euo pipefail
package_version="$(jq -r '.version' package.json)"
tauri_version="$(jq -r '.version' src-tauri/tauri.conf.json)"
if [[ -z "$package_version" || "$package_version" == "null" ]]; then
echo "::error::package.json does not contain a valid version"
exit 1
fi
if [[ "$package_version" != "$tauri_version" ]]; then
echo "::error::package.json version $package_version does not match tauri.conf.json version $tauri_version"
exit 1
fi
expected_tag="v$package_version"
if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
if [[ "$GITHUB_REF_NAME" != "$expected_tag" ]]; then
echo "::error::Tag $GITHUB_REF_NAME does not match package version $package_version"
exit 1
fi
release_tag="$GITHUB_REF_NAME"
else
release_tag="${expected_tag}-verify-${GITHUB_RUN_NUMBER}"
fi
echo "APP_VERSION=$package_version" >> "$GITHUB_ENV"
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
echo "app_version=$package_version" >> "$GITHUB_OUTPUT"
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
- name: Install updater signature verifier
run: brew list minisign >/dev/null 2>&1 || brew install minisign
- name: Build unsigned macOS app without signing material
run: ./scripts/package-macos-community.sh universal-apple-darwin "$GITHUB_RUN_NUMBER" --build-only
- name: Import pinned community signing certificate
id: community-signing
shell: bash
env:
MACOS_COMMUNITY_CERTIFICATE: ${{ secrets.MACOS_COMMUNITY_CERTIFICATE }}
MACOS_COMMUNITY_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_COMMUNITY_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
./scripts/validate-release-secrets.sh certificate
umask 077
keychain_path="$RUNNER_TEMP/ttree-community.keychain-db"
keychain_password="$(openssl rand -hex 24)"
certificate_path="$RUNNER_TEMP/ttree-community.p12"
certificate_pem="$RUNNER_TEMP/ttree-community.pem"
cleanup_import_files() {
rm -f "$certificate_path" "$certificate_pem"
}
trap cleanup_import_files EXIT
echo "::add-mask::$keychain_password"
echo "keychain_path=$keychain_path" >> "$GITHUB_OUTPUT"
printf '%s' "$MACOS_COMMUNITY_CERTIFICATE" |
openssl base64 -d -A -out "$certificate_path"
security create-keychain -p "$keychain_password" "$keychain_path"
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$keychain_password" "$keychain_path"
security import "$certificate_path" \
-k "$keychain_path" \
-P "$MACOS_COMMUNITY_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s \
-k "$keychain_password" \
"$keychain_path"
security find-certificate \
-c "$EXPECTED_MACOS_COMMUNITY_SIGNING_IDENTITY" \
-p \
"$keychain_path" > "$certificate_pem"
actual_sha1="$(openssl x509 -in "$certificate_pem" -noout -fingerprint -sha1 | sed 's/.*=//; s/://g' | tr '[:lower:]' '[:upper:]')"
if [[ "$actual_sha1" != "$EXPECTED_MACOS_COMMUNITY_CERT_SHA1" ]]; then
echo "::error::Imported community certificate does not match the pinned fingerprint"
exit 1
fi
sudo security add-trusted-cert \
-d \
-r trustRoot \
-p codeSign \
-k /Library/Keychains/System.keychain \
"$certificate_pem"
existing_keychains=()
while IFS= read -r keychain; do
existing_keychains+=("$keychain")
done < <(security list-keychains -d user | sed 's/^[[:space:]]*"//; s/"$//')
security list-keychains -d user -s "$keychain_path" "${existing_keychains[@]}"
security find-identity -v -p codesigning "$keychain_path" |
grep -F "$EXPECTED_MACOS_COMMUNITY_CERT_SHA1" >/dev/null
echo "MACOS_COMMUNITY_SIGNING_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV"
- name: Sign and package community-signed macOS app
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
./scripts/validate-release-secrets.sh updater
./scripts/package-macos-community.sh universal-apple-darwin "$GITHUB_RUN_NUMBER" --package-only
- name: Clean up signing keychain
if: always() && steps.community-signing.outputs.keychain_path != ''
shell: bash
run: security delete-keychain "${{ steps.community-signing.outputs.keychain_path }}" 2>/dev/null || true
- name: Verify final app signature
run: |
./scripts/verify-macos-community-signature.sh \
src-tauri/target/universal-apple-darwin/release/bundle/macos/TTREE.app
- name: Generate updater manifest
run: |
./scripts/generate-latest-json.sh \
src-tauri/target/community-release \
"$RELEASE_TAG" \
"$GITHUB_REPOSITORY"
- name: Verify packaged release assets
run: |
./scripts/verify-macos-release-assets.sh \
src-tauri/target/community-release \
"$RELEASE_TAG" \
"$GITHUB_REPOSITORY" \
"$APP_VERSION"
- name: Record verified asset checksums
working-directory: src-tauri/target/community-release
run: |
shasum -a 256 \
"TTREE_${APP_VERSION}_universal.dmg" \
TTREE_universal.app.tar.gz \
TTREE_universal.app.tar.gz.sig \
latest.json > SHA256SUMS
shasum -a 256 --check SHA256SUMS
- name: Upload temporary verification artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: community-macos-${{ github.run_id }}
path: src-tauri/target/community-release/
if-no-files-found: error
retention-days: 7
compression-level: 0
publish:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: verify
runs-on: macos-14
permissions:
contents: write
env:
APP_VERSION: ${{ needs.verify.outputs.app_version }}
RELEASE_TAG: ${{ needs.verify.outputs.release_tag }}
steps:
- name: Check out source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Download verified build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: community-macos-${{ github.run_id }}
path: src-tauri/target/community-release
- name: Install updater signature verifier
run: brew list minisign >/dev/null 2>&1 || brew install minisign
- name: Recheck transferred artifacts
working-directory: src-tauri/target/community-release
run: shasum -a 256 --check SHA256SUMS
- name: Recheck updater signature and manifest
env:
VERIFY_MACOS_CONTAINERS: "0"
run: |
./scripts/verify-macos-release-assets.sh \
src-tauri/target/community-release \
"$RELEASE_TAG" \
"$GITHUB_REPOSITORY" \
"$APP_VERSION"
- name: Create private draft Release
id: draft-release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
release_dir="src-tauri/target/community-release"
if existing_release="$(
gh release view "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--json isDraft \
--jq '.isDraft' 2>/dev/null
)"; then
if [[ "$existing_release" != "true" ]]; then
echo "::error::Release $RELEASE_TAG is already published"
exit 1
fi
existing_tag="$RELEASE_TAG"
gh release delete "$existing_tag" \
--repo "$GITHUB_REPOSITORY" \
--yes
fi
gh release create "$RELEASE_TAG" \
"$release_dir/TTREE_${APP_VERSION}_universal.dmg" \
"$release_dir/TTREE_universal.app.tar.gz" \
"$release_dir/TTREE_universal.app.tar.gz.sig" \
"$release_dir/latest.json" \
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--draft \
--title "TTREE $RELEASE_TAG" \
--notes-file scripts/community-release-notes.md
- name: Download and verify actual draft assets
env:
GH_TOKEN: ${{ github.token }}
VERIFY_MACOS_CONTAINERS: "0"
run: |
set -euo pipefail
release_dir="$GITHUB_WORKSPACE/src-tauri/target/community-release"
downloaded_dir="$RUNNER_TEMP/ttree-draft-assets"
mkdir -p "$downloaded_dir"
assets="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name')"
if [[ "$(printf '%s\n' "$assets" | sed '/^$/d' | wc -l | tr -d ' ')" != "4" ]]; then
echo "::error::Draft Release must contain exactly four assets"
exit 1
fi
for expected in \
"TTREE_${APP_VERSION}_universal.dmg" \
"TTREE_universal.app.tar.gz" \
"TTREE_universal.app.tar.gz.sig" \
"latest.json"
do
if ! grep -Fqx "$expected" <<< "$assets"; then
echo "::error::Draft Release is missing $expected"
exit 1
fi
done
gh release download "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--dir "$downloaded_dir"
(
cd "$downloaded_dir"
shasum -a 256 --check "$release_dir/SHA256SUMS"
)
./scripts/verify-macos-release-assets.sh \
"$downloaded_dir" \
"$RELEASE_TAG" \
"$GITHUB_REPOSITORY" \
"$APP_VERSION"
- name: Publish verified Release
id: publish-release
env:
GH_TOKEN: ${{ github.token }}
run: gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false
- name: Clean up unpublished draft Release
if: always() && steps.draft-release.outcome == 'success' && steps.publish-release.outcome != 'success'
env:
GH_TOKEN: ${{ github.token }}
run: |
if [[ "$(
gh release view "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--json isDraft \
--jq '.isDraft' 2>/dev/null || true
)" == "true" ]]; then
gh release delete "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--yes
fi
+5
View File
@@ -4,3 +4,8 @@
.env.*
!.env.example
/target
/dist/
.ruff_cache/
__pycache__/
*.pyc
+1 -7
View File
@@ -1,7 +1 @@
{
"recommendations": [
"svelte.svelte-vscode",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}
{"recommendations": ["rust-lang.rust-analyzer"]}
+1 -3
View File
@@ -1,3 +1 @@
{
"svelte.enable-ts-plugin": true
}
{"rust-analyzer.check.command": "clippy"}
+56 -161
View File
@@ -1,176 +1,71 @@
# AGENTS.md
# TTREE 工作规则
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+Space` by default toggles the translation window.
- `CmdOrCtrl+Shift+S` by 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
TTREE 是纯 Rust Cargo workspace,Mac 用 AppKit,Windows 用 Win32。入口分别为 `apps/macos`(包 `ttree-macos`,二进制 `ttree`)与 `apps/windows`(包/二进制 `ttree-windows`,发行时命名 `TTREE.exe`)。不要恢复已移除的 Web 前端或旧框架命令。
Install dependencies without running arbitrary dependency lifecycle scripts:
- `apps/macos/src/`:Mac 窗口、菜单、控制器、原生控件。遵守其局部 AGENTS.md。
- `apps/windows/src/`:Windows 窗口、绘制、对话框与设计布局。遵守其局部 AGENTS.md。
- `crates/ttree-core`:配置模型、语言、消息校验、取消与布局数据。
- `crates/ttree-storage`:本机 JSON 配置与 JSONL 历史。
- `crates/ttree-translate`:免费翻译引擎、健康分流和回退。
- `crates/ttree-ai`:HTTP / SSE、本地模型、HY-MT 下载与进程管理。
- `crates/ttree-ocr/src/{macos,windows}`:平台专用截图与 OCR;公共预处理和结果类型留在上层。
- `crates/ttree-platform/src/{macos,windows}`:平台专用划词;热键解析和自启入口共享。
- `scripts/{macos,windows,release}`:各平台打包与公共发布工具。
- `docs/`:开发与发布说明;图片只放 `docs/images/`。
- `target/`、`dist/`:忽略的编译/发行产物。禁止向根目录堆放截图、日志、压缩包、临时报告。
```bash
npm run ci:install
## 修改原则
1. 翻译、AI、配置、语言等业务语义优先改共享 crate,避免在两个 UI 里复制规则。
2. 原生控件与平台权限留在对应平台目录;共享 crate 的系统实现用 `cfg` 隔离。不得让 Windows 构建依赖 Apple 框架,反之亦然。
3. 共享行为发生变化时,检查两个调用端;若另一个平台尚未实现,明确说明,不宣称已验证。
4. 保留默认隐藏、快捷键唤起、Esc 收起、置顶时不因失焦隐藏等行为。不要重启无关应用、模型服务或清空用户配置。
5. API Key 当前保存在本机 JSON 配置,不能在文档中宣称已使用 Keychain。禁止把用户配置、历史、凭据或模型文件提交到仓库,不打印私钥/口令。
6. 不改变 `com.samwstu.ttree` 配置目录或历史格式来完成目录整理。版本唯一来源是根 `Cargo.toml` 的 `[workspace.package].version`;UI 使用 Cargo 版本常量。
7. README 使用克制、简洁、以体验为主的中文文风;插图标清实机截图与示意图,不用虚构界面冒充运行结果。
## 开发与必要验证
```sh
cargo check --workspace --locked
cargo test --workspace --locked
bash scripts/tests/run-release-tests.sh
# 在对应系统运行
cargo run -p ttree-macos -- --show
cargo run -p ttree-windows -- --show
```
Run the app in development mode:
修改 Windows 代码可在 Mac 上先 `cargo check -p ttree-windows --target x86_64-pc-windows-msvc --locked`;它仅验证类型,不等于原生链接、启动或交互验证。实际发布必须通过 Windows runner 的编译、测试、ZIP 验证和启动检查。
```bash
npm run tauri dev
```
验证按影响范围选择;不为低影响文档修改强制运行完整套件。发布变更须跑发布合约测试。不要通过删除有效的门禁绕过失败。构建失败先报告最小真实错误。完整说明见 [开发指南](docs/development.md)。
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:
`.github/workflows/build.yml` 是唯一构建发布工作流。
```bash
npm run dev
```
- 分支推送、PR:双平台构建与测试;其他分支不发布。
- `main` 推送:双平台成功后发布唯一的 `vX.Y.Z-dev.RUN.ATTEMPT` 预发布版,不覆盖 Latest。
- 推送 `vX.Y.Z` 标签:必须匹配工作区版本,双平台成功后发布正式版,更新 Latest。
- `workflow_dispatch`:只验证、上传临时 artifact,不发布。
- 发布 job 必须依赖 metadata、macos、windows 三者成功。先创建草稿,上传附件,再下载验证签名与哈希,最后公开;不覆盖既有 Release。
- 附件:Universal DMG、Mac `.app.tar.gz`、`.sig`、`latest.json`、Windows x64 ZIP、`SHA256SUMS`。
- 保留旧客户端更新签名兼容性;不能把纯 Rust 应用描述成已有自动更新界面。
Frontend checks and build:
## Mac 签名硬约束
```bash
npm run check
npm run build
```
Frontend tests:
```bash
npm test
```
Rust backend checks/tests:
```bash
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:
```bash
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:**
1. **Bump the version in both places** (they must match) and commit:
- `package.json` → `"version"`
- `src-tauri/tauri.conf.json` → `"version"`
2. **Verify locally** before tagging:
```bash
npm run check && npm test
cd src-tauri && cargo check && cd ..
```
3. **Tag and push the tag** (triggers the release workflow on `v*` tags):
```bash
git tag vX.Y.Z
git push origin vX.Y.Z
```
4. CI (`.github/workflows/release.yml`) builds a single `universal-apple-darwin` (arm64 + x86_64 combined; the runner only ships arm64 by default, so the workflow explicitly runs `rustup target add x86_64-apple-darwin` first). `scripts/package-macos-community.sh` signs the app with the persistent community certificate, rebuilds the DMG/updater archive, and verifies both containers.
5. The workflow creates a draft Release first. It must pass `scripts/verify-macos-community-signature.sh` and verify all four release assets before publishing. Manual `workflow_dispatch` runs are verification-only and never publish.
6. Watch the run: `gh run watch -R ZASENJC/ttree` (or check https://github.com/ZASENJC/ttree/actions). Confirm the published Release lists `latest.json` + the signed `.tar.gz`/`.sig` assets.
7. 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`, and `designated => 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.sig` does not give the `.app` a 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 Signing` certificate and the explicit designated requirement `identifier "com.samwstu.ttree" and certificate leaf = H"b8d254561bb6cad78aa71fa18d62a32d10f50c08"`.
- Never publish when `codesign --verify --deep --strict` fails, `Signature=adhoc`, the authority/fingerprint differs, `Info.plist` is not bound, sealed resources are absent, or the designated requirement is identifier-only/`cdhash`-based. Run `scripts/verify-macos-community-signature.sh` as the hard gate.
- Gatekeeper rejection by `spctl` is 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_dispatch` must build/upload temporary verification artifacts only. Only a pushed `v*` tag matching `package.json` may 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.rs` exposes 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.rs` builds the global shortcut plugin and dispatches five registered shortcuts: `toggle` (show/hide window, emits `trigger-translate` on show to reset the frontend to the translate view), `ocr` (screenshot OCR), `ai_dialog` (open AI chat), `selection_translate` (capture selected text and translate), and `selection_ai_dialog` (capture selected text and send to AI). All are optional. Two pairs may intentionally share the same key — `toggle`↔`selection_translate` and `ai_dialog`↔`selection_ai_dialog`; on collision the selection action wins (dispatch order in `ParsedShortcuts::entries` lists 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.rs` owns persisted config models (`OpenAiConfig`, `ChatAiConfig`, `ShortcutConfig`, `AppearanceConfig`) and store reads/writes through `tauri-plugin-store`. Translation and chat use separate AI config entries; each carries its own optional prompt (`translate_prompt`, `chat_prompt`). `ShortcutConfig` holds five independently-optional shortcuts: `toggle`, `ocr`, `ai_dialog`, `selection_translate`, `selection_ai_dialog` (empty string disables one). **API keys never touch the store** — `config` reads/writes them through `keychain`. `save_openai`/`save_chat_ai` validate `base_url` (must be http(s), rejects `file:`/`data:`) and after a successful Keychain write they also delete any leftover legacy plaintext `api_key` key from the store. `Debug` impls redact `api_key`. Window size is persisted too, but debounced (500ms) and written on a blocking thread because `WindowEvent::Resized` fires dozens of times/second during a drag.
- `keychain.rs` wraps macOS Keychain generic-password items (`security-framework`). Service is the bundle id `com.samwstu.ttree`; accounts are `openai.api_key` / `chat_ai.api_key`. `get_secret` returns `Ok(None)` for a missing item (first-use); non-macOS is a compile-gated stub that errors. `config::load_secret_migrated` does 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.rs` persists AI chat history as line-delimited JSON (`chat-history.jsonl`, a separate file from `settings.json`). Append-only writes (one line per message, single `write_all` per round), a `static Mutex` serializes 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.summary` is the first user line truncated to 40 chars. `read_conversations` returns old→new; `read_all` flattens to a message stream for backward compatibility.
- `window.rs` centralizes main-window show/hide/focus behavior. The app keeps the window hidden instead of destroying it for instant recall.
- `translate/mod.rs` dispatches by `Engine` (`openai`/`google`/`bing`), owns a shared pooled `reqwest::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 an `api_key`-bearing request to an internal host). It also defines `ChatMessage` and `validate_messages` — the IPC-boundary guard (role whitelist `user`/`assistant`/`system`, 64 KiB per message, ≤200 messages) applied to both `chat` and `append_chat_history`.
- `translate/google.rs` calls the free `translate.googleapis.com` endpoint and parses its nested array response.
- `translate/bing.rs` calls the free `bing.com/ttranslatev3` endpoint. 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.rs` streams OpenAI-compatible SSE responses and emits `translate-chunk` (translation) or `chat-chunk` (AI chat) events to the frontend.
- `selection/macos.rs` captures the currently selected text via the macOS Accessibility API; falls back to a temporary `Cmd+C` + clipboard read if Accessibility fails. Non-macOS returns `None`.
- `screenshot.rs` invokes `/usr/sbin/screencapture -i -x -r` for 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_ocr` sends that PNG to Vision and removes it afterward.
- `ocr/vision.rs` performs macOS-native OCR with Apple Vision (`VNRecognizeTextRequest`).
- `tray.rs` creates 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.svelte` is 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.svelte` is the AI chat view. It sends multi-turn messages, streams the assistant reply via `chat-chunk`, and surfaces the conversation-history sidebar.
- `src/lib/stores/translation.svelte.ts` owns translation state, including two web engines (`google`/`bing`) switchable via `WEB_ENGINES` and an adaptive target (default) that calls `resolveAdaptiveTarget` — CJK-containing input → English, otherwise → Chinese.
- `src/lib/stores/chat.svelte.ts` owns chat state and orchestrates history. `sendText`/`reset` drive the AI-dialog flow; `recentChatContext` (from `chatContext.ts`) trims to the last 24 messages and drops leading orphan assistant turns before sending.
- `src/lib/stores/appearance.svelte.ts` applies panel opacity to a `--panel-opacity` CSS var on `document.documentElement`. The opacity min (0.35) is intentionally kept in sync with `config.rs::PANEL_OPACITY_MIN` so frontend/backend clamping agree.
- `src/lib/stores/update.svelte.ts` drives the in-app updater. It checks on startup if ≥7 days since the last check (persisted in `settings.json` as `update.last_check`), re-checks every 6h while running, and only re-stamps the timestamp on `uptodate`/`error` (so an available-but-uninstalled update survives). Download → install → `relaunch()`.
- `src/lib/stores/languages.ts` holds the language list, the `adaptive` 中⇄英 target, and `resolveAdaptiveTarget` (script-based).
- The `.svelte.ts` stores (`translation`, `chat`, `appearance`, `update`) use the `.svelte.ts` extension because they contain Svelte 5 runes (`$state`). Do not move rune-based stores back to plain `.ts`; doing so leaves `$state` uncompiled and causes a runtime white screen. (`chatContext.ts` / `languages.ts` / `shortcuts.ts` are plain modules with no runes.)
- `src/lib/shortcuts/shortcutRecorder.ts` is 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.ts` is the frontend boundary for all `invoke` calls and event subscriptions. `Engine` here is the string union `"openai" | "google" | "bing"`, matching the Rust `Engine` enum's `rename_all = "lowercase"`.
- `src/lib/views/Settings.svelte` edits 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.css` plus global rules in `src/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-chunk` event; AI chat streams via `chat-chunk`; Google and Bing return a complete string from the `translate` command.
- 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`, and `Settings.svelte` for 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.sh` and `scripts/tests/release-workflow.test.sh` cover 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.
- 固定证书 `TTREE Open Source Code Signing`,SHA-1 `B8D254561BB6CAD78AA71FA18D62A32D10F50C08`。绝不打印、重建、替换、轮换、提交该证书或私钥;不得用 Secret 覆盖预期身份或指纹。
- 固定 designated requirement:`identifier "com.samwstu.ttree" and certificate leaf = H"b8d254561bb6cad78aa71fa18d62a32d10f50c08"`。录屏 TCC 依赖它,不能退化为 ad-hoc、identifier-only 或 CDHash 要求。
- 每个最终 `.app` 以及 DMG/归档中的 `.app` 必须通过 `scripts/macos/verify-signature.sh`(deep + strict、证书 Authority、绑定的 Info.plist、资源封印、指定要求)。禁止失败后发布。
- Apple 原生截图必须保持 `/usr/sbin/screencapture -i -x -r` + Vision;不得以解决权限为由改造为自绘选择窗口。
- 社区签名不等于 Apple 公证;`spctl` 拒绝并不能代替上述签名验证。README/Release 须保留“隐私与安全性 → 仍要打开”、录屏授权及重启说明。
- updater minisign 与 macOS 签名是两层独立保证,均须保留。更新私钥必须加密且口令至少 16 字符,只在打包后的 signer 步骤接收密钥;编译阶段不得暴露签名材料。
- 本机加密材料在 `~/.config/ttree-code-signing/` 和 `~/.config/ttree-updater/`,口令从既有 Keychain 项读取。CI 使用已有的四项证书/更新签名 Secrets,详见 [发布指南](docs/releasing.md)。
- TCC 跨升级实测需安装 A、授权一次、再安装 CDHash 不同但同证书的 B,不执行 `tccutil reset`,确认 OCR 不再提示。普通编译或签名检查不等于完成此实测。
+7 -158
View File
@@ -1,160 +1,9 @@
# CLAUDE.md
# TTREE
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
先阅读并遵守 [AGENTS.md](AGENTS.md),它是项目规则的唯一来源。
## 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+Space` by default toggles the translation window.
- `CmdOrCtrl+Shift+S` by 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 from the lockfile without running arbitrary lifecycle scripts:
```bash
npm run ci:install
```
Run the app in development mode:
```bash
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:
```bash
npm run dev
```
Frontend checks and build:
```bash
npm run check
npm run build
```
Frontend tests:
```bash
npm test
```
Rust backend checks/tests:
```bash
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:
```bash
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 only a development artifact. It is not proof that a distributable macOS build has the fixed community identity, valid updater signature, or verified release containers.
## 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:**
1. **Bump the version in both places** (they must match) and commit:
- `package.json` → `"version"`
- `src-tauri/tauri.conf.json` → `"version"`
2. **Verify locally** before tagging:
```bash
npm run check && npm test
cd src-tauri && cargo check && cd ..
```
3. **Tag and push the tag** (triggers the release workflow on `v*` tags):
```bash
git tag vX.Y.Z
git push origin vX.Y.Z
```
4. CI (`.github/workflows/release.yml`) builds `universal-apple-darwin` without signing material, then imports the fixed community certificate only for the signing/packaging stage. `scripts/package-macos-community.sh` signs the app, rebuilds the DMG/updater archive, and verifies both containers.
5. The workflow creates a draft Release first. It downloads the four actual draft assets and re-verifies their checksums, updater signature, manifest, and pinned community identity before publishing. Manual `workflow_dispatch` runs are verification-only and never publish.
6. Watch the run: `gh run watch -R ZASENJC/ttree` (or check https://github.com/ZASENJC/ttree/actions). Confirm the published Release lists `latest.json` plus the signed `.tar.gz`/`.sig` assets.
7. 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/`; the public key is in `tauri.conf.json`, and CI stores the encrypted private key plus its strong passphrase in separate secrets. For a local signed package, run `source ./scripts/signing-env.sh && ./scripts/package-macos-community.sh`; the package script withholds the updater key from npm/Cargo and exposes it only to the signer subprocess.
**Community code-signing invariant:** every distributed build must use `TTREE Open Source Code Signing`, SHA-1 `B8D254561BB6CAD78AA71FA18D62A32D10F50C08`, with designated requirement `identifier "com.samwstu.ttree" and certificate leaf = H"b8d254561bb6cad78aa71fa18d62a32d10f50c08"`. Never rotate, recreate, print, or replace that identity. It preserves macOS Screen Recording TCC authorization across updates. These releases are community-signed, not Apple-notarized; Gatekeeper's first-open warning is expected.
## 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.rs` exposes 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 原生自由框选 + 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.rs` builds the global shortcut plugin and dispatches five registered shortcuts: `toggle` (show/hide window, emits `trigger-translate` on show to reset the frontend to the translate view), `ocr` (screenshot OCR), `ai_dialog` (open AI chat), `selection_translate` (capture selected text and translate), and `selection_ai_dialog` (capture selected text and send to AI). All are optional. Two pairs may intentionally share the same key — `toggle`↔`selection_translate` and `ai_dialog`↔`selection_ai_dialog`; on collision the selection action wins (dispatch order in `ParsedShortcuts::entries` lists 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.rs` owns persisted config models (`OpenAiConfig`, `ChatAiConfig`, `ShortcutConfig`, `AppearanceConfig`) and store reads/writes through `tauri-plugin-store`. Translation and chat use separate AI config entries; each carries its own optional prompt (`translate_prompt`, `chat_prompt`). `ShortcutConfig` holds five independently-optional shortcuts: `toggle`, `ocr`, `ai_dialog`, `selection_translate`, `selection_ai_dialog` (empty string disables one). **API keys never touch the store** — `config` reads/writes endpoint-bound records through `keychain`, never returns real keys to the WebView, requires HTTPS for remote endpoints (HTTP is loopback-only), and compensates Store/Keychain failures. `Debug` impls redact `api_key`. Window size is persisted too, but debounced (500ms) and written on a blocking thread because `WindowEvent::Resized` fires dozens of times/second during a drag.
- `keychain.rs` wraps macOS Keychain generic-password items (`security-framework`). Service is the bundle id `com.samwstu.ttree`; accounts are `openai.api_key` / `chat_ai.api_key`. `get_secret` returns `Ok(None)` for a missing item (first-use); non-macOS is a compile-gated stub that errors. `config::load_secret_migrated` does 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.rs` persists AI chat history as line-delimited JSON (`chat-history.jsonl`, a separate file from `settings.json`). Append-only writes, a `static Mutex` serializes all read/append/clear, and corrupt JSON lines are skipped. `session_start` creates a conversation and `session_resume` appends to a stable conversation id; `read_conversations` returns least-recently-active → most-recently-active.
- `window.rs` centralizes main-window show/hide/focus behavior. The app keeps the window hidden instead of destroying it for instant recall.
- `translate/mod.rs` dispatches by `Engine` (`openai`/`google`/`bing`), pools clients by timeout/cookie mode, disables redirects, validates language codes and IPC payload sizes, and caps upstream response bodies. It also defines `ChatMessage` and `validate_messages` for both `chat` and `append_chat_history`.
- `translate/google.rs` calls the free `translate.googleapis.com` endpoint and parses its nested array response.
- `translate/bing.rs` calls the free `bing.com/ttranslatev3` endpoint. 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.rs` streams OpenAI-compatible SSE responses and emits `translate-chunk` (translation) or `chat-chunk` (AI chat) events to the frontend.
- `selection/macos.rs` captures the currently selected text via the macOS Accessibility API; falls back to a temporary `Cmd+C` + clipboard read if Accessibility fails. Non-macOS returns `None`.
- `screenshot.rs` invokes `/usr/sbin/screencapture -i -x -r` for Apple's native interactive free selection, writes to a random private temporary PNG, and treats Esc/no output as cancellation. `commands::screenshot_ocr` passes the PNG to Vision and removes it automatically afterward. Do not replace this architecture merely to address a permission prompt.
- `ocr/vision.rs` performs macOS-native OCR with Apple Vision (`VNRecognizeTextRequest`).
- `tray.rs` creates 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, kicks off update checks, and subscribes to the five backend trigger events. Screenshot selection belongs to the native macOS service; there is no screenshot-selection frontend window.
Important frontend areas:
- `src/lib/components/TranslatePanel.svelte` is 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.svelte` is the AI chat view. It sends multi-turn messages, streams the assistant reply via `chat-chunk`, and surfaces the conversation-history sidebar.
- `src/lib/stores/translation.svelte.ts` owns translation state, including two web engines (`google`/`bing`) switchable via `WEB_ENGINES` and an adaptive target (default) that calls `resolveAdaptiveTarget` — CJK-containing input → English, otherwise → Chinese.
- `src/lib/stores/chat.svelte.ts` owns chat state and orchestrates history. `sendText`/`reset` drive the AI-dialog flow; `recentChatContext` (from `chatContext.ts`) trims to the last 24 messages and drops leading orphan assistant turns before sending.
- `src/lib/stores/appearance.svelte.ts` applies panel opacity to a `--panel-opacity` CSS var on `document.documentElement`. The opacity min (0.35) is intentionally kept in sync with `config.rs::PANEL_OPACITY_MIN` so frontend/backend clamping agree.
- `src/lib/stores/update.svelte.ts` drives the in-app updater. It checks on startup if ≥7 days since the last check (persisted in `settings.json` as `update.last_check`), re-checks every 6h while running, and only re-stamps the timestamp on `uptodate`/`error` (so an available-but-uninstalled update survives). Download → install → `relaunch()`.
- `src/lib/stores/languages.ts` holds the language list, the `adaptive` 中⇄英 target, and `resolveAdaptiveTarget` (script-based).
- The `.svelte.ts` stores (`translation`, `chat`, `appearance`, `update`) use the `.svelte.ts` extension because they contain Svelte 5 runes (`$state`). Do not move rune-based stores back to plain `.ts`; doing so leaves `$state` uncompiled and causes a runtime white screen. (`chatContext.ts` / `languages.ts` / `shortcuts.ts` are plain modules with no runes.)
- `src/lib/shortcuts/shortcutRecorder.ts` is 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.ts` is the frontend boundary for all `invoke` calls and event subscriptions. `Engine` here is the string union `"openai" | "google" | "bing"`, matching the Rust `Engine` enum's `rename_all = "lowercase"`.
- `src/lib/views/Settings.svelte` edits 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.css` plus global rules in `src/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 development success does not validate a packaged release identity. Selection capture separately prefers Accessibility and falls back to a temporary copy.
- OpenAI translation results stream via the `translate-chunk` event; AI chat streams via `chat-chunk`; Google and Bing return a complete string from the `translate` command.
- 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
- Check `config.rs`, `shortcut.rs`, and `Settings.svelte` when behavior and prose diverge; security and release invariants in this file and `AGENTS.md` must stay aligned with the code.
- Rust unit tests cover: 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. 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.
- Mac:`apps/macos/`,局部规则见 `apps/macos/AGENTS.md`。
- Windows:`apps/windows/`,局部规则见 `apps/windows/AGENTS.md`。
- 共享业务:`crates/`。不要复制两套翻译、配置或模型规则。
- 开发:[docs/development.md](docs/development.md)。
- 打包与发布:[docs/releasing.md](docs/releasing.md)。
Generated
+40 -15
View File
@@ -3123,12 +3123,21 @@ dependencies = [
]
[[package]]
name = "ttree-app"
name = "ttree-core"
version = "0.2.0"
dependencies = [
"serde",
"serde_json",
"tokio",
"url",
]
[[package]]
name = "ttree-macos"
version = "0.2.0"
dependencies = [
"anyhow",
"global-hotkey",
"lucide-icons",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
@@ -3138,20 +3147,9 @@ dependencies = [
"ttree-ai",
"ttree-core",
"ttree-ocr",
"ttree-platform-macos",
"ttree-platform",
"ttree-storage",
"ttree-translate",
"windows",
]
[[package]]
name = "ttree-core"
version = "0.2.0"
dependencies = [
"serde",
"serde_json",
"tokio",
"url",
]
[[package]]
@@ -3172,7 +3170,7 @@ dependencies = [
]
[[package]]
name = "ttree-platform-macos"
name = "ttree-platform"
version = "0.2.0"
dependencies = [
"anyhow",
@@ -3210,6 +3208,24 @@ dependencies = [
"urlencoding",
]
[[package]]
name = "ttree-windows"
version = "0.2.0"
dependencies = [
"anyhow",
"global-hotkey",
"lucide-icons",
"tokio",
"ttree-ai",
"ttree-core",
"ttree-ocr",
"ttree-platform",
"ttree-storage",
"ttree-translate",
"windows",
"winresource",
]
[[package]]
name = "typed-path"
version = "0.12.3"
@@ -3663,6 +3679,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "winresource"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"version_check",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
+3 -2
View File
@@ -3,11 +3,12 @@ resolver = "2"
members = [
"crates/ttree-core",
"crates/ttree-storage",
"crates/ttree-platform-macos",
"crates/ttree-platform",
"crates/ttree-ocr",
"crates/ttree-translate",
"crates/ttree-ai",
"crates/ttree-app",
"apps/macos",
"apps/windows",
]
[workspace.package]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ZASENJC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+64 -115
View File
@@ -1,138 +1,87 @@
# TTREE
<p align="center"><img src="assets/icons/128x128.png" width="80" alt="TTREE 图标"></p>
<h1 align="center">TTREE</h1>
<p align="center"><strong>让语言退后。让想法向前。</strong></p>
<p align="center">翻译。截图识字。与 AI 对话。<br>轻轻唤起,接着思考。</p>
<p align="center"><a href="https://github.com/ZASENJC/ttree/releases/latest">下载正式版</a> · <a href="https://github.com/ZASENJC/ttree/releases">探索新版本</a> · <a href="docs/development.md">参与开发</a></p>
> 轻量 macOS AI 翻译器。快捷键秒级呼出,截图 OCR、划词翻译、AI 对话,多引擎流式输出。
<p align="center"><img src="docs/images/product.png" width="1200" alt="TTREE Mac 与 Windows 界面渲染示意:原文、免费译文和 AI 译文在同一窗口呈现"></p>
<p align="center"><sub>macOS 与 Windows · 界面渲染示意</sub></p>
基于 **Tauri 2 + Svelte 5 + Rust** 构建:窗口隐藏而非销毁,保证快捷键瞬时召回;macOS 端 OCR 直接调用系统 Vision 框架,划词捕获走原生 Accessibility,零重型依赖。
## 一个快捷键,回到心流。
---
阅读到一半,遇到另一种语言。选中它,或截下它。TTREE 随即出现,把译文带到眼前。按下 Esc,继续刚才的事。
## ✨ 功能
默认使用 **⌘ ⇧ Space**(Mac)或 **Ctrl ⇧ Space**(Windows)呼出。窗口隐藏时,仍在等待下一次需要。
- **快捷键呼出翻译** — 默认 `⌘⇧Space` 呼出/收起窗口,输入文本回车翻译,`Esc` 收起,失焦自动隐藏
- **截图 OCR 翻译** — `⌘⇧S` 框选屏幕区域,调用 macOS 原生 **Vision** 框架识别文字并自动翻译(按系统实际支持配置中/英/日等语言,截图先规范化为 8-bit sRGB 位图,失败时自动兼容重试)
- **划词翻译 / 划词 AI** — 选中任意应用内的文本,一键翻译或发送给 AI(基于 macOS Accessibility,未授权时回退到临时复制并恢复剪贴板)
- **AI 对话模式** — 独立于翻译的多轮对话,支持自定义 system 提示词
- **自动免费翻译** — Google / 微软必应无需 key,健康服务轮询分流;超时、限流、空响应或服务异常时自动切换,故障服务冷却 30–300 秒后恢复尝试,长文本自动分块
- **OpenAI 兼容翻译** — 流式逐字输出,可配置 base_url / api_key / 模型 / 翻译提示词
- **双栏对照** — 网页译文与 AI 译文并列展示,免费译文统一显示为“翻译”,可分别复制结果
- **固定窗口** — 置顶且失焦不隐藏,方便对照查阅
- **自定义快捷键** — 五个动作均可独立配置,支持「呼出」与「划词」共用同一组合键(有选中文本时执行划词)
- **系统托盘 + 开机自启**(可选)
- 毛玻璃磨砂界面,明暗主题自适应
## 看得懂。也聊得开。
## ⌨️ 快捷键
**文字,从画面里走出来。** 按下 **⌘ ⇧ S** 或 **Ctrl ⇧ S**,框选屏幕上的文字,识别后接着翻译。Mac 使用 Apple Vision;Windows 使用系统 OCR。
| 动作 | 默认 | 说明 |
|------|------|------|
| 呼出 / 收起翻译窗口 | `⌘⇧Space` | 显示时自动复位到翻译页 |
| 截图 OCR 翻译 | `⌘⇧S` | 框选区域 → Vision 识别 → 自动翻译 |
| 呼出 AI 对话 | —(可在设置配置) | 独立对话模式 |
| 划词翻译 | —(可在设置配置) | 捕获选中文本直接翻译 |
| 划词 AI 对话 | —(可在设置配置) | 捕获选中文本发送给 AI |
**两份译文,一眼对照。** Google 与 Bing 提供免费翻译,遇到服务异常自动切换。打开 AI 翻译,还能在同一个窗口里比较另一种表达。
所有快捷键均可在设置中自定义,留空即禁用;必须包含 `Cmd/Ctrl/Option` 中的至少一个修饰键。
**顺着想法,聊下去。** 切换到 AI 对话,保留上下文,继续追问。翻译与对话各自拥有模型和提示词设置。
## 📥 安装
**模型,由你选择。** 连接 OpenAI 兼容服务、Ollama 或本地模型服务;也可按需下载内置 HY-MT 翻译模型。模型文件独立下载,不占用安装包。
GitHub Release 使用项目固定的社区代码签名证书,但没有经过 Apple 公证。首次安装时 macOS 显示“无法验证开发者”是社区签名包的预期行为。
## 两个平台。一样顺手。
1. 从本仓库的 GitHub Release 下载 DMG,将 TTREE 拖入“应用程序”。
2. 首次打开若被阻止,进入 **系统设置 → 隐私与安全性**,找到 TTREE 提示并选择 **仍要打开**。
3. 按 `⌘⇧S` 使用截图 OCR,在系统提示中允许 **屏幕与系统音频录制**。
4. 授权后完全退出并重新打开 TTREE。
Mac 的轻盈,Windows 的熟悉。各自使用原生窗口和控件,共享翻译、对话与历史记录逻辑。
后续版本使用同一张社区证书和固定 designated requirement 签名,正常情况下升级不会再次索要录屏权限。请勿安装使用相同 Bundle ID 的未知构建;从旧的临时签名版本迁移到首个社区签名版本时,可能需要清理旧权限记录并重新授权一次。
| | macOS | Windows |
| --- | --- | --- |
| 原生界面 | AppKit · 系统毛玻璃 | Win32 · 系统明暗主题 |
| 截图识字 | 原生自由框选 + Apple Vision | 原生框选 + Windows OCR |
| 划词翻译 | Accessibility,必要时回退复制 | 临时复制并恢复剪贴板 |
| 下载 | Universal DMG · Apple 芯片与 Intel | x64 ZIP · 解压即用 |
## 📋 系统权限
![共享能力与双平台目录关系](docs/images/workspace.svg)
首次使用相关功能时,macOS 会弹出授权请求,请在 **系统设置 → 隐私与安全性** 中开启:
## 准备好,开始。
| 权限 | 用途 |
|------|------|
| **屏幕录制** | 截图 OCR |
| **辅助功能(Accessibility)** | 划词翻译 / 划词 AI(捕获选中文本)|
### 在 Mac 上
> 开发模式下授权对象可能是终端或 dev 二进制,因此开发版可用不代表发布包的权限身份正确。
1. [下载最新正式版](https://github.com/ZASENJC/ttree/releases/latest)中的 `TTREE_*_universal.dmg`,将 TTREE 拖入“应用程序”。
2. 首次打开若被拦截,进入 **系统设置 → 隐私与安全性 → 仍要打开**。
3. 使用截图翻译时,授予 **屏幕与系统音频录制** 权限,随后完全退出并重新打开 TTREE。划词翻译可按需授予 **辅助功能** 权限。
## 🚀 开发
Mac 发行包沿用固定社区证书签名,尚未经过 Apple 公证。同一证书用于后续升级,以维持录屏权限身份。旧临时签名版本迁移时,可能需要重新授权一次。
**前置**:Node.js 20+、Rust(stable)、Xcode Command Line Tools。
### 在 Windows 上
```bash
npm run ci:install # 按 lockfile 安装;禁用任意 lifecycle 脚本,仅重建 esbuild/fsevents
npm run tauri dev # 启动开发模式
1. 下载 `TTREE_*_windows_x64.zip`,解压到固定目录。
2. 运行文件夹里的 `TTREE.exe`。窗口默认隐藏,按 **Ctrl ⇧ Space** 呼出,也可使用托盘菜单。
3. OCR 需要系统安装相应语言的文字识别组件;缺少语言时按应用提示补充。
Windows 包为未使用商业证书签名的便携版,首次运行可能遇到 SmartScreen 提示。请核对下载来源及附件 `SHA256SUMS`。启用开机自启后,请保留解压目录。
### 几个顺手的动作
| 动作 | Mac | Windows |
| --- | --- | --- |
| 呼出 / 收起 | ⌘ ⇧ Space | Ctrl ⇧ Space |
| 截图翻译 | ⌘ ⇧ S | Ctrl ⇧ S |
| 发送 / 翻译 | Enter | Enter |
| 换行 | Shift + Enter | Shift + Enter |
| 收起窗口 | Esc | Esc |
AI 对话、划词翻译、划词 AI 的快捷键可在设置中配置。点按置顶,让窗口留在身边。
## 给创作者,留好空间。
一个 Cargo 工作区,两套原生界面。共享功能修改一次;平台交互在各自目录里打磨。
```text
apps/macos/ Mac 界面与交互
apps/windows/ Windows 界面与交互
crates/ 翻译、AI、配置、历史、OCR 与系统适配
scripts/macos/ Mac 打包与签名
scripts/windows/ Windows 打包与启动验证
scripts/release/ 双平台发布与附件校验
docs/ 开发、发布说明与图片
```
> Tauri 主窗口配置为 `visible: false`,开发模式下启动可能看不到窗口——用默认快捷键 `⌘⇧Space` 呼出。
开发入口:`cargo run -p ttree-macos -- --show`(Mac),`cargo run -p ttree-windows -- --show`(Windows)。完整依赖与检查命令见[开发指南](docs/development.md)。
仅启动前端(无后端):
推送到 `main`,自动构建两个平台并发布预发布版;推送匹配工作区版本的 `vX.Y.Z` 标签,验证后发布正式版。其他分支与 PR 只验证。[查看发布流程](docs/releasing.md) · [查看构建状态](https://github.com/ZASENJC/ttree/actions/workflows/build.yml)。
```bash
npm run dev
```
## 📦 构建
```bash
npm run tauri build # 产出 .app 与 .dmg
```
产物位于 `src-tauri/target/release/bundle/`(含 `bundle/macos/TTREE.app` 与 `bundle/dmg/*.dmg`)。
普通 `tauri build` 只用于本地开发,不能直接作为 GitHub 发布包。社区发布由 GitHub Actions 调用 `scripts/package-macos-community.sh`,用固定证书和 designated requirement 重签 `.app`,再重建并验证 DMG、updater archive、`.sig` 与 `latest.json`。手动触发 `release` workflow 只上传 7 天保留的临时验证产物;只有推送与 `package.json`、`tauri.conf.json` 版本都一致的 `v*` tag 才可能发布 Release。
## 🏗️ 架构
```
src-tauri/src/ Rust 后端
├── lib.rs 插件注册、毛玻璃、失焦隐藏、setup
├── commands.rs 暴露给前端的 Tauri 命令
├── config.rs 配置读写(tauri-plugin-store)
├── shortcut.rs 全局快捷键注册与分发(含冲突校验)
├── window.rs 窗口显示/隐藏/居中/聚焦/固定
├── tray.rs 系统托盘菜单
├── screenshot.rs screencapture -i 区域截图
├── ocr/image.rs ImageIO 解码 / 8-bit sRGB 规范化 / 小截图边距
├── ocr/vision.rs Apple Vision OCR(有效语言组合 / 旧模型回退)
├── selection/macos.rs 划词捕获(Accessibility + 剪贴板回退)
└── translate/
├── mod.rs 引擎分发 + 共享 HTTP 客户端
├── auto.rs 免费服务轮询 / 故障切换 / 冷却 / 长文本分块
├── google.rs 谷歌免费端接口
├── bing.rs 必应免费端接口(令牌抓取)
└── openai.rs OpenAI 兼容 SSE 流式(翻译 + 对话)
src/ Svelte 5 前端(runes)
├── App.svelte 翻译 / 对话 / 设置 路由
├── lib/components/ TranslatePanel / EngineTabs / LangSelector / ResultView
├── lib/views/ ChatPanel / Settings
├── lib/stores/ translation / chat(.svelte.ts runes)/ languages
├── lib/shortcuts/ 快捷键录制工具(shortcutRecorder)
└── lib/api/tauri.ts invoke / event 封装
```
## ✅ 测试与检查
```bash
# Rust
cd src-tauri && cargo test # 翻译解析 / SSE / 快捷键 / 划词 / 真实 PNG Vision OCR
cd src-tauri && cargo clippy -- -D warnings
# 前端
npm test # Vitest(快捷键录制器)
npm run check # svelte-check 类型检查
```
macOS OCR 回归测试会通过 Xcode Command Line Tools 的 Swift 在临时目录生成样例,并真正执行 ImageIO 和 Vision;覆盖单行、灰度、16-bit、透明图片以及中英混排。
## ⚠️ 说明与限制
- 免费翻译自动在谷歌 / 微软必应之间分流和切换;两者都是**非官方免费端点**,同时不可用时会提示稍后重试。OpenAI 兼容接口需自备 API Key。
- OCR 保留 Apple 原生自由框选;截图期间临时隐藏主窗口,重复触发会被忽略。中文识别只与英文组合;截图经 ImageIO 解码后绘制成 8-bit sRGB 位图,小截图加边距。识别失败或无文字时用旧版 Vision 模型在 CPU 上重试,取消框选不会显示为识别失败。
- API Key 存储在 macOS Keychain,不写入明文 Tauri store。
- 仅支持 **macOS 11.0+**(依赖 Vision、Accessibility、screencapture 等系统框架)。
- GitHub Release 是社区签名、非 Apple 公证包;首次安装需要在“隐私与安全性”中手动选择“仍要打开”。
## 📄 许可证
MIT
<sub>免费翻译依赖非官方端点,可用性取决于服务。离线模型需单独下载,并满足对应硬件与模型许可条件。配置与对话历史保存在本机;当前 API Key 随本机 JSON 配置保存,请勿分享配置文件。纯 Rust 版本目前通过下载 Release 手动升级;发布保留旧版更新客户端所需的签名附件。macOS 包声明最低系统版本为 11.0;Windows 提供 x64 构建。MIT 开源,第三方图标许可见 assets/licenses。</sub>
+10
View File
@@ -0,0 +1,10 @@
# macOS
继承根 AGENTS.md。这里只放 AppKit 窗口、控件、菜单和 Mac UI 状态。
- `app.rs` 负责入口、事件分发、窗口和菜单;`state.rs` 负责控制器;`ui_helpers.rs` 负责原生控件和 splitter。
- 系统划词放 `crates/ttree-platform/src/macos/`,截图/Vision 放 `crates/ttree-ocr/src/macos/`。
- 使用 objc2 的主线程约束;后台任务经 UI 事件回到主线程更新控件。
- 保留 Accessory 模式、LSUIElement、隐藏而非销毁、置顶与失焦行为。
- 发布必须沿用固定社区证书;详见根规则和发布指南。不要将开发运行成功等同于打包后的 TCC 升级验证。
- `cargo run -p ttree-macos -- --show` 开发;`scripts/macos/package.sh` 打包。
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "ttree-macos"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[[bin]]
name = "ttree"
path = "src/main.rs"
[target.'cfg(target_os = "macos")'.dependencies]
ttree-core = { path = "../../crates/ttree-core" }
ttree-storage = { path = "../../crates/ttree-storage" }
ttree-platform = { path = "../../crates/ttree-platform" }
ttree-ocr = { path = "../../crates/ttree-ocr" }
ttree-translate = { path = "../../crates/ttree-translate" }
ttree-ai = { path = "../../crates/ttree-ai" }
anyhow.workspace = true
global-hotkey.workspace = true
tokio.workspace = true
objc2.workspace = true
objc2-app-kit.workspace = true
objc2-core-foundation.workspace = true
objc2-foundation.workspace = true
objc2-quartz-core.workspace = true
+12
View File
@@ -0,0 +1,12 @@
# TTREE for macOS
AppKit 原生界面,翻译、AI 和设置使用同一控制器。共享业务库位于根 `crates/`。
```sh
cargo run -p ttree-macos -- --show
# Universal 发行包;签名材料沿用本机既有配置
source scripts/macos/signing-env.sh
scripts/macos/package.sh
```
在仓库根目录执行。开发构建不会证明发行包的录屏权限可延续。签名身份、首次安装和验证步骤见 [发布指南](../../docs/releasing.md)。
+351
View File
@@ -0,0 +1,351 @@
use std::cell::RefCell;
use std::sync::Arc;
use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, Bool, Sel};
use objc2::{define_class, msg_send, sel, MainThreadMarker, MainThreadOnly};
use objc2_app_kit::{
NSApplication, NSApplicationActivationPolicy, NSBackingStoreType, NSColor, NSEvent,
NSEventModifierFlags, NSMenu, NSMenuItem, NSStatusBar, NSStatusItem, NSTextView,
NSVisualEffectBlendingMode, NSVisualEffectMaterial, NSVisualEffectState, NSVisualEffectView,
NSWindow, NSWindowButton, NSWindowStyleMask, NSWindowTitleVisibility,
};
use objc2_foundation::{
NSObject, NSObjectProtocol, NSPoint, NSRect, NSRunLoop, NSRunLoopCommonModes, NSSize, NSString,
NSTimer,
};
use tokio::sync::mpsc::{self, UnboundedReceiver};
use ttree_core::{WINDOW_MAX_HEIGHT, WINDOW_MAX_WIDTH, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH};
use ttree_storage::{HistoryStore, SettingsStore};
use crate::state::{ActionTag, AppController, TabMode, UiEvent};
struct GlobalUiContext {
ctrl: AppController,
ui_rx: UnboundedReceiver<UiEvent>,
_status_item: Retained<NSStatusItem>,
}
thread_local! {
static UI_CTX: RefCell<Option<GlobalUiContext>> = const { RefCell::new(None) };
}
fn with_ctx<F: FnOnce(&mut GlobalUiContext)>(f: F) {
UI_CTX.with(|cell| {
if let Ok(mut borrow) = cell.try_borrow_mut() {
if let Some(ctx) = borrow.as_mut() {
f(ctx);
}
}
});
}
pub fn on_splitter_drag(splitter_id: u8, delta_down: f64) {
with_ctx(|ctx| ctx.ctrl.on_splitter_drag(splitter_id, delta_down));
}
pub fn on_splitter_drag_end() {
with_ctx(|ctx| ctx.ctrl.on_splitter_drag_end());
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "TTreeActionDispatcher"]
pub struct ActionDispatcher;
unsafe impl NSObjectProtocol for ActionDispatcher {}
impl ActionDispatcher {
#[unsafe(method(handleControl:))]
fn handle_control(&self, sender: &AnyObject) {
let raw_tag: isize = unsafe { msg_send![sender, tag] };
if let Some(tag) = ActionTag::from_raw(raw_tag) {
with_ctx(|ctx| ctx.ctrl.on_action(tag));
}
}
#[unsafe(method(handleTimer:))]
fn handle_timer(&self, _timer: &AnyObject) {
with_ctx(|ctx| {
while let Ok(hk_ev) = GlobalHotKeyEvent::receiver().try_recv() {
if hk_ev.state == HotKeyState::Pressed {
if let Some(action) = ctx.ctrl.parsed_shortcuts.action_for_id(hk_ev.id)
{
ctx.ctrl.handle_hotkey_action(action);
}
}
}
while let Ok(ui_ev) = ctx.ui_rx.try_recv() {
ctx.ctrl.handle_ui_event(ui_ev);
}
ctx.ctrl.poll_tick();
});
}
#[unsafe(method(textView:doCommandBySelector:))]
fn text_view_do_command(&self, _text_view: &NSTextView, command: Sel) -> Bool {
if command == sel!(cancelOperation:) {
with_ctx(|ctx| ctx.ctrl.on_action(ActionTag::HideOrEscape));
return Bool::YES;
}
if command == sel!(insertNewline:) {
let flags = NSEvent::modifierFlags_class();
if flags.contains(NSEventModifierFlags::Shift) {
return Bool::NO;
}
with_ctx(|ctx| match ctx.ctrl.active_tab {
TabMode::Translate => ctx.ctrl.start_translation(),
TabMode::Chat => ctx.ctrl.send_chat_from_input(),
TabMode::Settings => {}
});
return Bool::YES;
}
Bool::NO
}
#[unsafe(method(windowDidResignKey:))]
fn window_did_resign_key(&self, _notification: &AnyObject) {
with_ctx(|ctx| ctx.ctrl.on_window_did_resign_key());
}
#[unsafe(method(windowDidResize:))]
fn window_did_resize(&self, _notification: &AnyObject) {
with_ctx(|ctx| ctx.ctrl.on_window_did_resize());
}
#[unsafe(method(applicationShouldHandleReopen:hasVisibleWindows:))]
fn app_should_handle_reopen(&self, _app: &AnyObject, _has_visible: Bool) -> Bool {
with_ctx(|ctx| {
ctx.ctrl.switch_tab(TabMode::Translate);
ctx.ctrl.show_and_focus_window();
});
Bool::YES
}
}
);
impl ActionDispatcher {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
unsafe { msg_send![mtm.alloc::<Self>(), init] }
}
}
pub fn run() {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("初始化 Tokio 异步运行时失败");
let mtm = MainThreadMarker::new().expect("必须在 macOS 主线程启动 AppKit UI");
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
let dispatcher = ActionDispatcher::new(mtm);
unsafe {
let _: () = msg_send![&*app, setDelegate: Some(&*dispatcher)];
}
install_standard_main_menu(mtm, &app);
let settings = Arc::new(SettingsStore::open_default());
let history = Arc::new(HistoryStore::open_default());
let hotkey_mgr = GlobalHotKeyManager::new().expect("初始化全局快捷键管理器失败");
let pane_layout = settings.pane_layout();
let ai_enabled = settings.ai_translation_for_request().enabled;
let win_w = pane_layout.width;
let win_h = pane_layout.translate_window_height(ai_enabled);
let appearance = settings.appearance();
let (window, effect_view) =
build_main_window(mtm, win_w, win_h, appearance.panel_opacity, &dispatcher);
let status_item = build_status_bar_item(mtm, &dispatcher);
let (ui_tx, ui_rx) = mpsc::unbounded_channel();
let mut ctrl = AppController::build(
mtm,
rt.handle().clone(),
ui_tx,
settings,
history,
hotkey_mgr,
window,
effect_view,
&dispatcher,
);
if std::env::args().any(|a| a == "--show") {
ctrl.show_and_focus_window();
}
UI_CTX.with(|cell| {
*cell.borrow_mut() = Some(GlobalUiContext {
ctrl,
ui_rx,
_status_item: status_item,
});
});
unsafe {
let timer = NSTimer::timerWithTimeInterval_target_selector_userInfo_repeats(
0.025,
&dispatcher,
sel!(handleTimer:),
None,
true,
);
let run_loop = NSRunLoop::mainRunLoop();
run_loop.addTimer_forMode(&timer, NSRunLoopCommonModes);
}
app.run();
}
fn build_main_window(
mtm: MainThreadMarker,
width: f64,
height: f64,
opacity: f64,
dispatcher: &ActionDispatcher,
) -> (Retained<NSWindow>, Retained<NSVisualEffectView>) {
unsafe {
let frame = NSRect::new(NSPoint::new(240.0, 240.0), NSSize::new(width, height));
let style = NSWindowStyleMask::Titled
| NSWindowStyleMask::Closable
| NSWindowStyleMask::Resizable
| NSWindowStyleMask::FullSizeContentView;
let window = NSWindow::initWithContentRect_styleMask_backing_defer(
mtm.alloc(),
frame,
style,
NSBackingStoreType::Buffered,
false,
);
window.setTitle(&NSString::from_str("TTREE"));
window.setTitleVisibility(NSWindowTitleVisibility::Hidden);
window.setTitlebarAppearsTransparent(true);
window.setMovableByWindowBackground(true);
window.setReleasedWhenClosed(false);
window.setOpaque(false);
window.setBackgroundColor(Some(&NSColor::clearColor()));
window.setAlphaValue(opacity);
window.setMinSize(NSSize::new(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT));
window.setMaxSize(NSSize::new(WINDOW_MAX_WIDTH, WINDOW_MAX_HEIGHT));
for btn_kind in [
NSWindowButton::CloseButton,
NSWindowButton::MiniaturizeButton,
NSWindowButton::ZoomButton,
] {
if let Some(btn) = window.standardWindowButton(btn_kind) {
btn.setHidden(true);
}
}
let _: () = msg_send![&*window, setDelegate: Some(dispatcher)];
let effect_view = NSVisualEffectView::initWithFrame(mtm.alloc(), frame);
effect_view.setMaterial(NSVisualEffectMaterial::Popover);
effect_view.setBlendingMode(NSVisualEffectBlendingMode::BehindWindow);
effect_view.setState(NSVisualEffectState::Active);
window.setContentView(Some(&effect_view));
(window, effect_view)
}
}
fn build_status_bar_item(
mtm: MainThreadMarker,
dispatcher: &ActionDispatcher,
) -> Retained<NSStatusItem> {
unsafe {
let status_bar = NSStatusBar::systemStatusBar();
let item = status_bar.statusItemWithLength(-1.0);
if let Some(btn) = item.button(mtm) {
btn.setTitle(&NSString::from_str("译"));
}
let menu = NSMenu::new(mtm);
let action_sel = sel!(handleControl:);
let add_item = |title: &str, tag: ActionTag, key: &str| {
let mi = NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str(title),
Some(action_sel),
&NSString::from_str(key),
);
mi.setTag(tag as isize);
mi.setTarget(Some(dispatcher));
menu.addItem(&mi);
};
add_item("翻译窗口", ActionTag::TabTranslate, "");
add_item("截图 OCR 翻译", ActionTag::TriggerOcr, "");
add_item("AI 对话", ActionTag::TabChat, "");
menu.addItem(&NSMenuItem::separatorItem(mtm));
add_item("偏好设置…", ActionTag::TabSettings, ",");
menu.addItem(&NSMenuItem::separatorItem(mtm));
add_item("退出 TTREE", ActionTag::TrayQuit, "q");
// 点击菜单项时如果窗口尚未显示则自动唤起(除了截图 OCR 与退出)
item.setMenu(Some(&menu));
item
}
}
/// 为 Accessory 模式应用安装标准 Edit 菜单,使 NSTextView / NSTextField 原生支持 Cmd+C / V / X / A / Z。
fn install_standard_main_menu(mtm: MainThreadMarker, app: &NSApplication) {
unsafe {
let main_menu = NSMenu::new(mtm);
// 1. App 子菜单
let app_item = NSMenuItem::new(mtm);
let app_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("TTREE"));
let close_item = NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str("隐藏窗口"),
Some(sel!(orderOut:)),
&NSString::from_str("w"),
);
let quit_item = NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str("退出 TTREE"),
Some(sel!(terminate:)),
&NSString::from_str("q"),
);
app_menu.addItem(&close_item);
app_menu.addItem(&NSMenuItem::separatorItem(mtm));
app_menu.addItem(&quit_item);
app_item.setSubmenu(Some(&app_menu));
main_menu.addItem(&app_item);
// 2. Edit 子菜单 (支持 Cmd+Z / Cmd+X / Cmd+C / Cmd+V / Cmd+A)
let edit_item = NSMenuItem::new(mtm);
let edit_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("编辑"));
let edit_entries: [(&str, Sel, &str); 5] = [
("撤销", sel!(undo:), "z"),
("剪切", sel!(cut:), "x"),
("复制", sel!(copy:), "c"),
("粘贴", sel!(paste:), "v"),
("全选", sel!(selectAll:), "a"),
];
for (title, action, key) in edit_entries {
let mi = NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str(title),
Some(action),
&NSString::from_str(key),
);
edit_menu.addItem(&mi);
}
edit_item.setSubmenu(Some(&edit_menu));
main_menu.addItem(&edit_item);
app.setMainMenu(Some(&main_menu));
}
}
+25
View File
@@ -0,0 +1,25 @@
//! TTREE macOS AppKit entry point.
#[cfg(target_os = "macos")]
mod app;
#[cfg(target_os = "macos")]
mod state;
#[cfg(target_os = "macos")]
mod ui_helpers;
#[cfg(target_os = "macos")]
fn main() {
app::run();
}
#[cfg(not(target_os = "macos"))]
fn main() {
eprintln!("Build ttree-macos on macOS.");
}
#[cfg(target_os = "macos")]
pub(crate) fn dispatch_splitter_drag(id: u8, delta: f64) {
app::on_splitter_drag(id, delta);
}
#[cfg(target_os = "macos")]
pub(crate) fn dispatch_splitter_drag_end() {
app::on_splitter_drag_end();
}
@@ -1,4 +1,4 @@
//! `ttree-app` 核心控制器:复刻原版极简上下分栏排版(右上角功能区 + 上方翻译输入框 + 语言选择条 + Google/Bing 负载均衡译文框 + AI 译文框)。
//! `TTREE` 核心控制器:复刻原版极简上下分栏排版(右上角功能区 + 上方翻译输入框 + 语言选择条 + Google/Bing 负载均衡译文框 + AI 译文框)。
#![cfg(target_os = "macos")]
#![allow(clippy::too_many_arguments)]
@@ -29,7 +29,7 @@ use ttree_core::{
WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH,
};
use ttree_ocr::OcrOutcome;
use ttree_platform_macos::hotkey::{parse_shortcut_config, ParsedShortcuts, ShortcutAction};
use ttree_platform::hotkey::{parse_shortcut_config, ParsedShortcuts, ShortcutAction};
use ttree_storage::{HistoryStore, SettingsStore};
use crate::ui_helpers::{
@@ -1723,7 +1723,7 @@ impl AppController {
mtm,
dummy,
"开机自动启动",
ttree_platform_macos::autostart::is_enabled(),
ttree_platform::autostart::is_enabled(),
ActionTag::AutostartToggled as isize,
dispatcher,
action_sel,
@@ -1740,7 +1740,7 @@ impl AppController {
let ver_label = make_label(
mtm,
dummy,
"版本:0.2.0 (100% Pure Rust Native AppKit)",
concat!("版本:", env!("CARGO_PKG_VERSION"), " (Native AppKit)"),
12.0,
false,
true,
@@ -2254,7 +2254,7 @@ impl AppController {
let tx = self.ui_tx.clone();
self.rt.spawn(async move {
let captured = tokio::task::spawn_blocking(
ttree_platform_macos::selection::capture_selected_text,
ttree_platform::selection::capture_selected_text,
)
.await
.ok()
@@ -2279,7 +2279,7 @@ impl AppController {
let tx = self.ui_tx.clone();
self.rt.spawn(async move {
let captured = tokio::task::spawn_blocking(
ttree_platform_macos::selection::capture_selected_text,
ttree_platform::selection::capture_selected_text,
)
.await
.ok()
@@ -2635,7 +2635,7 @@ impl AppController {
}
ActionTag::AutostartToggled => {
let want = self.settings_ui.autostart_chk.state() == NSControlStateValueOn;
let _ = ttree_platform_macos::autostart::set_enabled(want);
let _ = ttree_platform::autostart::set_enabled(want);
}
ActionTag::OcrStripNewlinesToggled => {
let want = self.settings_ui.ocr_strip_newlines_chk.state() == NSControlStateValueOn;
File renamed without changes.
+10
View File
@@ -0,0 +1,10 @@
# Windows
继承根 AGENTS.md。这里只放 Win32 UI、DWM、对话框及 Windows 交互测试。
- `app.rs` 是控制器与消息循环;`app/ui.rs` 负责绘制/控件;`app/dialogs.rs` 负责系统对话框;`design.rs` 保存可独立测试的布局规则。
- 业务逻辑复用共享 crates。系统划词放 `crates/ttree-platform/src/windows/`,截图/OCR 放 `crates/ttree-ocr/src/windows/`。
- 保持 DPI 缩放、主题、滚动、hover 重绘和启动隐藏行为;不要用强制重绘掩盖残影。
- Mac 交叉 `cargo check` 不等于 Windows 原生验证。发布必须在 Windows MSVC runner 上编译、测试、启动便携包。
- 发行时 ZIP 内为 `TTREE/TTREE.exe`。不把用户配置、下载模型或托管运行时放进 ZIP。
- `tests/repaint-regression.py` 用于锁定具体重绘回归,需交互桌面、pywinauto 与 Pillow,按需运行。
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "ttree-windows"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[[bin]]
name = "ttree-windows"
path = "src/main.rs"
[target.'cfg(target_os = "windows")'.dependencies]
ttree-core = { path = "../../crates/ttree-core" }
ttree-storage = { path = "../../crates/ttree-storage" }
ttree-platform = { path = "../../crates/ttree-platform" }
ttree-ocr = { path = "../../crates/ttree-ocr" }
ttree-translate = { path = "../../crates/ttree-translate" }
ttree-ai = { path = "../../crates/ttree-ai" }
anyhow.workspace = true
global-hotkey.workspace = true
tokio.workspace = true
lucide-icons = { version = "=1.39.0", default-features = false }
windows = { workspace = true, features = ["Win32_UI_Shell", "Win32_System_Com", "Win32_UI_WindowsAndMessaging"] }
[build-dependencies]
winresource = { version = "=0.1.34", default-features = false }
+24
View File
@@ -0,0 +1,24 @@
# TTREE for Windows
Windows 10/11 x64 原生 Win32 界面,支持托盘、快捷键、截图 OCR、AI 翻译和多轮对话。
## 便携版
将整个 TTREE 文件夹解压到固定目录,运行 `TTREE.exe`。默认隐藏窗口,使用 `Ctrl+Shift+Space` 呼出,`Ctrl+Shift+S` 截图翻译。也可从托盘菜单打开。
开机自启记录当前可执行路径;移动目录后应重新设置。OCR 使用系统文字识别组件,缺少语言时在 Windows 中补装对应的 OCR 可选功能。本包未使用商业代码签名证书,可能触发 SmartScreen;请只从项目官方 Releases 下载并校验 SHA256SUMS。
配置位于 `%APPDATA%/com.samwstu.ttree/`;API Key 当前随 JSON 配置保存。模型和推理运行时首次使用时独立下载,不在安装包内。不要分享本机配置文件。
## 开发
安装 Rust stable MSVC 与 Visual Studio C++ Build Tools 后,在仓库根运行:
```powershell
cargo run -p ttree-windows -- --show
cargo test --workspace --locked
./scripts/windows/package.ps1
./scripts/windows/smoke.ps1 -Exe target/package/windows/TTREE/TTREE.exe -Output target/windows-smoke
```
产物位于 `dist/windows/`。Mac 上的交叉类型检查不能替代此平台的链接与运行验证。
+17
View File
@@ -0,0 +1,17 @@
fn main() {
println!("cargo:rerun-if-changed=../../assets/icons/icon.ico");
// Resource compilation is a native Windows gate. Cross cargo check on Mac
// still checks Rust types without requiring an entire Windows resource SDK.
#[cfg(windows)]
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
winresource::WindowsResource::new()
.set_icon("../../assets/icons/icon.ico")
.set("ProductName", "TTREE")
.set("FileDescription", "TTREE — Translation and OCR")
.set("OriginalFilename", "TTREE.exe")
.set("ProductVersion", env!("CARGO_PKG_VERSION"))
.set("FileVersion", env!("CARGO_PKG_VERSION"))
.compile()
.expect("compile Windows icon and version resources");
}
}
@@ -1,7 +1,7 @@
//! `ttree-app` Windows 100% 纯 Rust 原生 Win32 + DWM 桌面端实现。
//! `TTREE` Windows 100% 纯 Rust 原生 Win32 + DWM 桌面端实现。
//!
//! 架构与 macOS AppKit 端完全对称:
//! - 复用 `ttree-core`、`ttree-storage`、`ttree-translate`、`ttree-ai`、`ttree-ocr`、`ttree-platform-macos`;
//! - 复用 `ttree-core`、`ttree-storage`、`ttree-translate`、`ttree-ai`、`ttree-ocr`、`ttree-platform`;
//! - 原生 Win32 控件树 + DWM 深色模式 & Mica/Acrylic 云母材质 + 分层窗口透明度 (`SetLayeredWindowAttributes`);
//! - 系统托盘 (`Shell_NotifyIconW`) + 25ms `SetTimer` 驱动 `global-hotkey` 与 Tokio `mpsc` 流式 UI 事件;
//! - 完整支持四段式翻译面板(含可拖拽高度分隔条)、AI 多轮流式对话、内置 `HY-MT1.5-1.8B` 一键下载托管、
@@ -13,7 +13,7 @@
mod dialogs;
mod ui;
use crate::windows_design::{BoxRect, TranslationLayout, SIDEBAR, TOOLBAR};
use crate::design::{BoxRect, TranslationLayout, SIDEBAR, TOOLBAR};
use std::cell::RefCell;
use std::ffi::c_void;
use std::ptr;
@@ -35,9 +35,9 @@ use ttree_core::{
WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH,
};
use ttree_ocr::OcrOutcome;
use ttree_platform_macos::autostart;
use ttree_platform_macos::hotkey::{parse_shortcut_config, ParsedShortcuts, ShortcutAction};
use ttree_platform_macos::selection::capture_selected_text;
use ttree_platform::autostart;
use ttree_platform::hotkey::{parse_shortcut_config, ParsedShortcuts, ShortcutAction};
use ttree_platform::selection::capture_selected_text;
use ttree_storage::{HistoryStore, SettingsStore};
type HWND = *mut c_void;
@@ -138,7 +138,7 @@ const TPM_BOTTOMALIGN: u32 = 0x0020;
const MF_STRING: u32 = 0x0000;
const MF_SEPARATOR: u32 = 0x0800;
const IDI_APPLICATION: *const u16 = 32512 as *const u16;
const IDI_TTREE: *const u16 = 1 as *const u16;
const IDC_ARROW: *const u16 = 32512 as *const u16;
const IDC_SIZENS: *const u16 = 32645 as *const u16;
@@ -695,12 +695,12 @@ pub fn run() {
cbClsExtra: 0,
cbWndExtra: 0,
hInstance: hinstance,
hIcon: LoadIconW(ptr::null_mut(), IDI_APPLICATION),
hIcon: LoadIconW(GetModuleHandleW(ptr::null()), IDI_TTREE),
hCursor: LoadCursorW(ptr::null_mut(), IDC_ARROW),
hbrBackground: bg_brush,
lpszMenuName: ptr::null(),
lpszClassName: class_name.as_ptr(),
hIconSm: LoadIconW(ptr::null_mut(), IDI_APPLICATION),
hIconSm: LoadIconW(GetModuleHandleW(ptr::null()), IDI_TTREE),
};
RegisterClassExW(&wc);
@@ -952,7 +952,7 @@ unsafe fn install_tray_icon(hwnd: HWND) {
nid.uID = 1;
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP;
nid.uCallbackMessage = WM_TRAYICON;
nid.hIcon = LoadIconW(ptr::null_mut(), IDI_APPLICATION);
nid.hIcon = LoadIconW(GetModuleHandleW(ptr::null()), IDI_TTREE);
let tip = wide_null("TTREE AI 翻译与 OCR");
let copy_len = tip.len().min(nid.szTip.len() - 1);
nid.szTip[..copy_len].copy_from_slice(&tip[..copy_len]);
File renamed without changes.
@@ -1,7 +1,7 @@
//! Native presentation only. Keeps Win32 edits/combos/buttons for IME, selection,
//! keyboard access and accessibility, with the same restrained surfaces as AppKit.
use super::*;
use crate::windows_design::{logical, physical, BoxRect};
use crate::design::{logical, physical, BoxRect};
use lucide_icons::{Icon, LUCIDE_FONT_BYTES};
use std::collections::HashMap;
File renamed without changes.
+14
View File
@@ -0,0 +1,14 @@
//! TTREE native Win32 entry point.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[cfg(target_os = "windows")]
mod app;
#[cfg(any(target_os = "windows", test))]
mod design;
#[cfg(target_os = "windows")]
fn main() {
app::run();
}
#[cfg(not(target_os = "windows"))]
fn main() {
eprintln!("Build ttree-windows on Windows.");
}
@@ -1,7 +1,7 @@
"""Reproduce hover flicker and scroll ghosts using naturally presented screen pixels.
Run on an unlocked Windows desktop, for example:
python scripts/tests/windows-repaint-regression.py --exe target/release/ttree.exe --artifacts target/repaint-regression
python apps/windows/tests/repaint-regression.py --exe target/release/ttree-windows.exe --artifacts target/repaint-regression
Starts and closes only its own TTREE process. It changes unsaved controls, never
saves settings, and verifies the original settings file remains byte-for-byte intact.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-labelledby="title">
<title id="title">TTREE · T 与 E 融合单色标识</title>
<rect x="64" y="64" width="896" height="896" rx="202" fill="#191919"/>
<g fill="#fff">
<rect x="242" y="278" width="322" height="82" rx="12"/>
<rect x="362" y="278" width="82" height="420" rx="12"/>
<rect x="484" y="414" width="298" height="82" rx="12"/>
<rect x="592" y="414" width="82" height="340" rx="12"/>
<rect x="592" y="544" width="170" height="76" rx="12"/>
<rect x="592" y="672" width="190" height="82" rx="12"/>
</g></svg>

After

Width:  |  Height:  |  Size: 623 B

-33
View File
@@ -1,33 +0,0 @@
[package]
name = "ttree-app"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[[bin]]
name = "ttree"
path = "src/main.rs"
[dependencies]
ttree-core = { path = "../ttree-core" }
ttree-storage = { path = "../ttree-storage" }
ttree-platform-macos = { path = "../ttree-platform-macos" }
ttree-ocr = { path = "../ttree-ocr" }
ttree-translate = { path = "../ttree-translate" }
ttree-ai = { path = "../ttree-ai" }
anyhow.workspace = true
global-hotkey.workspace = true
tokio.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
objc2.workspace = true
objc2-app-kit.workspace = true
objc2-core-foundation.workspace = true
objc2-foundation.workspace = true
objc2-quartz-core.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
lucide-icons = { version = "=1.39.0", default-features = false }
windows = { workspace = true, features = ["Win32_UI_Shell", "Win32_System_Com", "Win32_UI_WindowsAndMessaging"] }
-394
View File
@@ -1,394 +0,0 @@
//! `ttree` —— 100% 纯 Rust 原生跨平台 (macOS AppKit / Windows Win32+DWM) AI 翻译与 OCR 桌面端入口。
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[cfg(target_os = "macos")]
mod state;
#[cfg(target_os = "macos")]
mod ui_helpers;
#[cfg(target_os = "windows")]
mod windows_app;
#[cfg(any(target_os = "windows", test))]
mod windows_design;
#[cfg(target_os = "macos")]
fn main() {
macos_app::run();
}
#[cfg(target_os = "windows")]
fn main() {
windows_app::run();
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
fn main() {
eprintln!("TTREE 纯 Rust 原生客户端目前仅支持 macOS 与 Windows 运行。");
}
#[cfg(target_os = "macos")]
pub(crate) fn dispatch_splitter_drag(splitter_id: u8, delta_down: f64) {
macos_app::on_splitter_drag(splitter_id, delta_down);
}
#[cfg(target_os = "macos")]
pub(crate) fn dispatch_splitter_drag_end() {
macos_app::on_splitter_drag_end();
}
#[cfg(target_os = "macos")]
mod macos_app {
use std::cell::RefCell;
use std::sync::Arc;
use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, Bool, Sel};
use objc2::{define_class, msg_send, sel, MainThreadMarker, MainThreadOnly};
use objc2_app_kit::{
NSApplication, NSApplicationActivationPolicy, NSBackingStoreType, NSColor, NSEvent,
NSEventModifierFlags, NSMenu, NSMenuItem, NSStatusBar, NSStatusItem, NSTextView,
NSVisualEffectBlendingMode, NSVisualEffectMaterial, NSVisualEffectState,
NSVisualEffectView, NSWindow, NSWindowButton, NSWindowStyleMask, NSWindowTitleVisibility,
};
use objc2_foundation::{
NSObject, NSObjectProtocol, NSPoint, NSRect, NSRunLoop, NSRunLoopCommonModes, NSSize,
NSString, NSTimer,
};
use tokio::sync::mpsc::{self, UnboundedReceiver};
use ttree_core::{WINDOW_MAX_HEIGHT, WINDOW_MAX_WIDTH, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH};
use ttree_storage::{HistoryStore, SettingsStore};
use crate::state::{ActionTag, AppController, TabMode, UiEvent};
struct GlobalUiContext {
ctrl: AppController,
ui_rx: UnboundedReceiver<UiEvent>,
_status_item: Retained<NSStatusItem>,
}
thread_local! {
static UI_CTX: RefCell<Option<GlobalUiContext>> = const { RefCell::new(None) };
}
fn with_ctx<F: FnOnce(&mut GlobalUiContext)>(f: F) {
UI_CTX.with(|cell| {
if let Ok(mut borrow) = cell.try_borrow_mut() {
if let Some(ctx) = borrow.as_mut() {
f(ctx);
}
}
});
}
pub fn on_splitter_drag(splitter_id: u8, delta_down: f64) {
with_ctx(|ctx| ctx.ctrl.on_splitter_drag(splitter_id, delta_down));
}
pub fn on_splitter_drag_end() {
with_ctx(|ctx| ctx.ctrl.on_splitter_drag_end());
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "TTreeActionDispatcher"]
pub struct ActionDispatcher;
unsafe impl NSObjectProtocol for ActionDispatcher {}
impl ActionDispatcher {
#[unsafe(method(handleControl:))]
fn handle_control(&self, sender: &AnyObject) {
let raw_tag: isize = unsafe { msg_send![sender, tag] };
if let Some(tag) = ActionTag::from_raw(raw_tag) {
with_ctx(|ctx| ctx.ctrl.on_action(tag));
}
}
#[unsafe(method(handleTimer:))]
fn handle_timer(&self, _timer: &AnyObject) {
with_ctx(|ctx| {
while let Ok(hk_ev) = GlobalHotKeyEvent::receiver().try_recv() {
if hk_ev.state == HotKeyState::Pressed {
if let Some(action) = ctx.ctrl.parsed_shortcuts.action_for_id(hk_ev.id)
{
ctx.ctrl.handle_hotkey_action(action);
}
}
}
while let Ok(ui_ev) = ctx.ui_rx.try_recv() {
ctx.ctrl.handle_ui_event(ui_ev);
}
ctx.ctrl.poll_tick();
});
}
#[unsafe(method(textView:doCommandBySelector:))]
fn text_view_do_command(&self, _text_view: &NSTextView, command: Sel) -> Bool {
if command == sel!(cancelOperation:) {
with_ctx(|ctx| ctx.ctrl.on_action(ActionTag::HideOrEscape));
return Bool::YES;
}
if command == sel!(insertNewline:) {
let flags = NSEvent::modifierFlags_class();
if flags.contains(NSEventModifierFlags::Shift) {
return Bool::NO;
}
with_ctx(|ctx| match ctx.ctrl.active_tab {
TabMode::Translate => ctx.ctrl.start_translation(),
TabMode::Chat => ctx.ctrl.send_chat_from_input(),
TabMode::Settings => {}
});
return Bool::YES;
}
Bool::NO
}
#[unsafe(method(windowDidResignKey:))]
fn window_did_resign_key(&self, _notification: &AnyObject) {
with_ctx(|ctx| ctx.ctrl.on_window_did_resign_key());
}
#[unsafe(method(windowDidResize:))]
fn window_did_resize(&self, _notification: &AnyObject) {
with_ctx(|ctx| ctx.ctrl.on_window_did_resize());
}
#[unsafe(method(applicationShouldHandleReopen:hasVisibleWindows:))]
fn app_should_handle_reopen(&self, _app: &AnyObject, _has_visible: Bool) -> Bool {
with_ctx(|ctx| {
ctx.ctrl.switch_tab(TabMode::Translate);
ctx.ctrl.show_and_focus_window();
});
Bool::YES
}
}
);
impl ActionDispatcher {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
unsafe { msg_send![mtm.alloc::<Self>(), init] }
}
}
pub fn run() {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("初始化 Tokio 异步运行时失败");
let mtm = MainThreadMarker::new().expect("必须在 macOS 主线程启动 AppKit UI");
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
let dispatcher = ActionDispatcher::new(mtm);
unsafe {
let _: () = msg_send![&*app, setDelegate: Some(&*dispatcher)];
}
install_standard_main_menu(mtm, &app);
let settings = Arc::new(SettingsStore::open_default());
let history = Arc::new(HistoryStore::open_default());
let hotkey_mgr = GlobalHotKeyManager::new().expect("初始化全局快捷键管理器失败");
let pane_layout = settings.pane_layout();
let ai_enabled = settings.ai_translation_for_request().enabled;
let win_w = pane_layout.width;
let win_h = pane_layout.translate_window_height(ai_enabled);
let appearance = settings.appearance();
let (window, effect_view) =
build_main_window(mtm, win_w, win_h, appearance.panel_opacity, &dispatcher);
let status_item = build_status_bar_item(mtm, &dispatcher);
let (ui_tx, ui_rx) = mpsc::unbounded_channel();
let mut ctrl = AppController::build(
mtm,
rt.handle().clone(),
ui_tx,
settings,
history,
hotkey_mgr,
window,
effect_view,
&dispatcher,
);
if std::env::args().any(|a| a == "--show") {
ctrl.show_and_focus_window();
}
UI_CTX.with(|cell| {
*cell.borrow_mut() = Some(GlobalUiContext {
ctrl,
ui_rx,
_status_item: status_item,
});
});
unsafe {
let timer = NSTimer::timerWithTimeInterval_target_selector_userInfo_repeats(
0.025,
&dispatcher,
sel!(handleTimer:),
None,
true,
);
let run_loop = NSRunLoop::mainRunLoop();
run_loop.addTimer_forMode(&timer, NSRunLoopCommonModes);
}
app.run();
}
fn build_main_window(
mtm: MainThreadMarker,
width: f64,
height: f64,
opacity: f64,
dispatcher: &ActionDispatcher,
) -> (Retained<NSWindow>, Retained<NSVisualEffectView>) {
unsafe {
let frame = NSRect::new(NSPoint::new(240.0, 240.0), NSSize::new(width, height));
let style = NSWindowStyleMask::Titled
| NSWindowStyleMask::Closable
| NSWindowStyleMask::Resizable
| NSWindowStyleMask::FullSizeContentView;
let window = NSWindow::initWithContentRect_styleMask_backing_defer(
mtm.alloc(),
frame,
style,
NSBackingStoreType::Buffered,
false,
);
window.setTitle(&NSString::from_str("TTREE"));
window.setTitleVisibility(NSWindowTitleVisibility::Hidden);
window.setTitlebarAppearsTransparent(true);
window.setMovableByWindowBackground(true);
window.setReleasedWhenClosed(false);
window.setOpaque(false);
window.setBackgroundColor(Some(&NSColor::clearColor()));
window.setAlphaValue(opacity);
window.setMinSize(NSSize::new(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT));
window.setMaxSize(NSSize::new(WINDOW_MAX_WIDTH, WINDOW_MAX_HEIGHT));
for btn_kind in [
NSWindowButton::CloseButton,
NSWindowButton::MiniaturizeButton,
NSWindowButton::ZoomButton,
] {
if let Some(btn) = window.standardWindowButton(btn_kind) {
btn.setHidden(true);
}
}
let _: () = msg_send![&*window, setDelegate: Some(dispatcher)];
let effect_view = NSVisualEffectView::initWithFrame(mtm.alloc(), frame);
effect_view.setMaterial(NSVisualEffectMaterial::Popover);
effect_view.setBlendingMode(NSVisualEffectBlendingMode::BehindWindow);
effect_view.setState(NSVisualEffectState::Active);
window.setContentView(Some(&effect_view));
(window, effect_view)
}
}
fn build_status_bar_item(
mtm: MainThreadMarker,
dispatcher: &ActionDispatcher,
) -> Retained<NSStatusItem> {
unsafe {
let status_bar = NSStatusBar::systemStatusBar();
let item = status_bar.statusItemWithLength(-1.0);
if let Some(btn) = item.button(mtm) {
btn.setTitle(&NSString::from_str("译"));
}
let menu = NSMenu::new(mtm);
let action_sel = sel!(handleControl:);
let add_item = |title: &str, tag: ActionTag, key: &str| {
let mi = NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str(title),
Some(action_sel),
&NSString::from_str(key),
);
mi.setTag(tag as isize);
mi.setTarget(Some(dispatcher));
menu.addItem(&mi);
};
add_item("翻译窗口", ActionTag::TabTranslate, "");
add_item("截图 OCR 翻译", ActionTag::TriggerOcr, "");
add_item("AI 对话", ActionTag::TabChat, "");
menu.addItem(&NSMenuItem::separatorItem(mtm));
add_item("偏好设置…", ActionTag::TabSettings, ",");
menu.addItem(&NSMenuItem::separatorItem(mtm));
add_item("退出 TTREE", ActionTag::TrayQuit, "q");
// 点击菜单项时如果窗口尚未显示则自动唤起(除了截图 OCR 与退出)
item.setMenu(Some(&menu));
item
}
}
/// 为 Accessory 模式应用安装标准 Edit 菜单,使 NSTextView / NSTextField 原生支持 Cmd+C / V / X / A / Z。
fn install_standard_main_menu(mtm: MainThreadMarker, app: &NSApplication) {
unsafe {
let main_menu = NSMenu::new(mtm);
// 1. App 子菜单
let app_item = NSMenuItem::new(mtm);
let app_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("TTREE"));
let close_item = NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str("隐藏窗口"),
Some(sel!(orderOut:)),
&NSString::from_str("w"),
);
let quit_item = NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str("退出 TTREE"),
Some(sel!(terminate:)),
&NSString::from_str("q"),
);
app_menu.addItem(&close_item);
app_menu.addItem(&NSMenuItem::separatorItem(mtm));
app_menu.addItem(&quit_item);
app_item.setSubmenu(Some(&app_menu));
main_menu.addItem(&app_item);
// 2. Edit 子菜单 (支持 Cmd+Z / Cmd+X / Cmd+C / Cmd+V / Cmd+A)
let edit_item = NSMenuItem::new(mtm);
let edit_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("编辑"));
let edit_entries: [(&str, Sel, &str); 5] = [
("撤销", sel!(undo:), "z"),
("剪切", sel!(cut:), "x"),
("复制", sel!(copy:), "c"),
("粘贴", sel!(paste:), "v"),
("全选", sel!(selectAll:), "a"),
];
for (title, action, key) in edit_entries {
let mi = NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str(title),
Some(action),
&NSString::from_str(key),
);
edit_menu.addItem(&mi);
}
edit_item.setSubmenu(Some(&edit_menu));
main_menu.addItem(&edit_item);
app.setMainMenu(Some(&main_menu));
}
}
}
+6 -1
View File
@@ -8,16 +8,22 @@
pub mod postprocess;
pub mod preprocess;
#[cfg(any(target_os = "macos", test))]
#[path = "macos/screenshot.rs"]
pub mod screenshot;
#[cfg(target_os = "macos")]
#[path = "macos/image.rs"]
pub mod image;
#[cfg(target_os = "macos")]
#[path = "macos/vision.rs"]
pub mod vision;
#[cfg(target_os = "windows")]
#[path = "windows/windows_capture.rs"]
pub mod windows_capture;
#[cfg(target_os = "windows")]
#[path = "windows/windows_ocr.rs"]
pub mod windows_ocr;
use serde::{Deserialize, Serialize};
@@ -108,4 +114,3 @@ pub fn recognize_file(path: &str) -> Result<OcrOutcome, String> {
pub fn recognize_file(_path: &str) -> Result<OcrOutcome, String> {
Err("当前平台暂不支持 OCR".to_string())
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
+7 -2
View File
@@ -133,7 +133,10 @@ pub fn score_ocr_text(text: &str) -> i64 {
score += 3;
} else if ch.is_alphabetic() {
score += 3;
} else if matches!(ch, '.' | ',' | '-' | '_' | '/' | ':' | ';' | '?' | '!' | '\'' | '"') {
} else if matches!(
ch,
'.' | ',' | '-' | '_' | '/' | ':' | ';' | '?' | '!' | '\'' | '"'
) {
score += 1;
} else if ch.is_whitespace() {
// 不加分也不扣分
@@ -151,7 +154,9 @@ mod tests {
#[test]
fn strips_spaces_between_cjk_words_while_preserving_english_spaces() {
let words = vec!["这", "是", "Windows", "原", "生", "OCR", "Test", "engine", "!"];
let words = vec![
"这", "是", "Windows", "原", "生", "OCR", "Test", "engine", "!",
];
assert_eq!(join_ocr_words(&words), "这是Windows原生OCR Test engine!");
}
+11 -17
View File
@@ -127,16 +127,14 @@ pub fn prepare_primary_for_ocr(img: &RawBgraImage) -> Result<RawBgraImage, Strin
pub fn prepare_high_contrast_for_ocr(img: &RawBgraImage) -> Result<RawBgraImage, String> {
let defringed = neutralize_cleartype_fringes(img);
let factor = recommended_upscale_factor(defringed.width, defringed.height).max(2);
let factor = if (defringed.width as u64)
* (defringed.height as u64)
* (factor as u64)
* (factor as u64)
<= MAX_OCR_PIXELS
{
factor
} else {
1
};
let factor =
if (defringed.width as u64) * (defringed.height as u64) * (factor as u64) * (factor as u64)
<= MAX_OCR_PIXELS
{
factor
} else {
1
};
let scaled = if factor > 1 {
upscale_bilinear(&defringed, factor)
} else {
@@ -309,12 +307,7 @@ pub fn sample_border_bgr(img: &RawBgraImage) -> (u8, u8, u8) {
)
}
fn pad_with_bg(
img: &RawBgraImage,
bg_b: u8,
bg_g: u8,
bg_r: u8,
) -> Result<RawBgraImage, String> {
fn pad_with_bg(img: &RawBgraImage, bg_b: u8, bg_g: u8, bg_r: u8) -> Result<RawBgraImage, String> {
let canvas_w = img
.width
.checked_add(BORDER_PADDING * 2)
@@ -389,7 +382,8 @@ mod tests {
// 外圈填充应为纯白背景 (255, 255, 255)
assert_eq!(&hc.bgra[0..3], &[255, 255, 255]);
// 中心文字像素应被反相并拉伸为深黑色 (0, 0, 0)
let center_idx = ((hc.height as usize / 2) * hc.width as usize + (hc.width as usize / 2)) * 4;
let center_idx =
((hc.height as usize / 2) * hc.width as usize + (hc.width as usize / 2)) * 4;
assert_eq!(&hc.bgra[center_idx..center_idx + 3], &[0, 0, 0]);
}
}
@@ -256,7 +256,12 @@ fn normalized_rect(
let right = x1.max(x2).clamp(0, max_w as i32) as u32;
let top = y1.min(y2).clamp(0, max_h as i32) as u32;
let bottom = y1.max(y2).clamp(0, max_h as i32) as u32;
(left, top, right.saturating_sub(left), bottom.saturating_sub(top))
(
left,
top,
right.saturating_sub(left),
bottom.saturating_sub(top),
)
}
/// 执行一次原生全屏框选截图,返回内存中的 `RawBgraImage`。
@@ -575,8 +580,9 @@ unsafe fn render_overlay_frame(hdc: HDC, st: &mut OverlayState) {
st.frame_bgra.copy_from_slice(&st.dimmed_bgra);
if st.dragging {
let (rx, ry, rw, rh) =
normalized_rect(st.start_x, st.start_y, st.curr_x, st.curr_y, st.width, st.height);
let (rx, ry, rw, rh) = normalized_rect(
st.start_x, st.start_y, st.curr_x, st.curr_y, st.width, st.height,
);
if rw > 0 && rh > 0 {
let stride = (st.width as usize) * 4;
let row_bytes = (rw as usize) * 4;
@@ -24,9 +24,7 @@ use windows::Storage::Streams::{DataWriter, InMemoryRandomAccessStream};
use windows::Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED};
use crate::postprocess::{clean_cjk_ocr_spaces, join_ocr_words, score_ocr_text};
use crate::preprocess::{
prepare_high_contrast_for_ocr, prepare_primary_for_ocr, RawBgraImage,
};
use crate::preprocess::{prepare_high_contrast_for_ocr, prepare_primary_for_ocr, RawBgraImage};
use crate::OcrOutcome;
const MISSING_LANG_PACK_HINT: &str = "未检测到可用的 Windows OCR 识别引擎。\n\
@@ -134,8 +132,7 @@ fn recognize_encoded_bytes_on_mta(bytes: &[u8]) -> Result<OcrOutcome, String> {
));
}
let stream =
InMemoryRandomAccessStream::new().map_err(|e| format!("创建内存流失败: {e}"))?;
let stream = InMemoryRandomAccessStream::new().map_err(|e| format!("创建内存流失败: {e}"))?;
let output = stream
.GetOutputStreamAt(0)
.map_err(|e| format!("获取输出流失败: {e}"))?;
@@ -149,9 +146,7 @@ fn recognize_encoded_bytes_on_mta(bytes: &[u8]) -> Result<OcrOutcome, String> {
.map_err(|e| format!("存储流数据失败: {e}"))?
.get()
.map_err(|e| format!("等待流存储完成失败: {e}"))?;
stream
.Seek(0)
.map_err(|e| format!("重置流偏移失败: {e}"))?;
stream.Seek(0).map_err(|e| format!("重置流偏移失败: {e}"))?;
let decoder = BitmapDecoder::CreateAsync(&stream)
.map_err(|e| format!("创建图片解码器失败: {e}"))?
-8
View File
@@ -1,8 +0,0 @@
//! `ttree-platform-macos` —— macOS 原生系统能力封装:
//! - Accessibility + 剪贴板回退的划词捕获
//! - 全局热键解析、冲突校验与内存级 O(1) 分发
//! - LaunchAgent 开机自启管理
pub mod autostart;
pub mod hotkey;
pub mod selection;
@@ -1,497 +0,0 @@
//! macOS 选中文本捕获:优先走 Accessibility API (`AXSelectedText`),
//! 仅当辅助功能未授权或焦点控件不支持 AX 属性时才回退到临时剪贴板复制。
use anyhow::Result;
pub fn normalize_selected_text(text: impl AsRef<str>) -> Option<String> {
let trimmed = text.as_ref().trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[cfg(target_os = "macos")]
mod imp {
use std::ptr;
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use core_foundation::base::{Boolean, CFRelease, CFTypeRef, TCFType};
use core_foundation::string::{CFString, CFStringRef};
use objc2_app_kit::{NSPasteboard, NSPasteboardType, NSPasteboardTypeString};
use objc2_core_graphics::{CGEvent, CGEventFlags, CGEventTapLocation};
use objc2_foundation::{NSData, NSInteger, NSString};
use super::normalize_selected_text;
const AX_ERROR_SUCCESS: i32 = 0;
const AX_ERROR_NO_VALUE: i32 = -25212;
const COPY_KEY_CODE: u16 = 8;
const COPY_TIMEOUT: Duration = Duration::from_millis(320);
const COPY_POLL_INTERVAL: Duration = Duration::from_millis(16);
#[repr(C)]
struct __AXUIElement {
_private: [u8; 0],
}
type AXUIElementRef = *const __AXUIElement;
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> Boolean;
fn AXUIElementCreateSystemWide() -> AXUIElementRef;
fn AXUIElementCopyAttributeValue(
element: AXUIElementRef,
attribute: CFStringRef,
value: *mut CFTypeRef,
) -> i32;
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AxOutcome {
Selected(String),
EmptySelection,
Unavailable,
}
#[derive(Debug, Clone)]
struct PasteboardSnapshot {
entries: Vec<PasteboardEntry>,
}
#[derive(Debug, Clone)]
struct PasteboardEntry {
type_name: String,
data: Vec<u8>,
}
pub fn is_accessibility_trusted() -> bool {
unsafe { AXIsProcessTrusted() != 0 }
}
pub fn capture_selected_text() -> Result<Option<String>> {
match capture_accessibility() {
AxOutcome::Selected(text) => Ok(Some(text)),
// Accessibility 已明确告知当前焦点控件为空选区:不合成 Cmd+C,避免误触蜂鸣音与 320ms 轮询等待
AxOutcome::EmptySelection => Ok(None),
AxOutcome::Unavailable => capture_clipboard_selected_text(),
}
}
fn capture_accessibility() -> AxOutcome {
unsafe {
if AXIsProcessTrusted() == 0 {
return AxOutcome::Unavailable;
}
let system_element = AXUIElementCreateSystemWide();
if system_element.is_null() {
return AxOutcome::Unavailable;
}
let focused_attr = CFString::new("AXFocusedUIElement");
let mut focused_value: CFTypeRef = ptr::null();
let focused_error = AXUIElementCopyAttributeValue(
system_element,
focused_attr.as_concrete_TypeRef(),
&mut focused_value,
);
CFRelease(system_element as CFTypeRef);
if focused_error != AX_ERROR_SUCCESS || focused_value.is_null() {
return AxOutcome::Unavailable;
}
let selected_attr = CFString::new("AXSelectedText");
let mut selected_value: CFTypeRef = ptr::null();
let selected_error = AXUIElementCopyAttributeValue(
focused_value as AXUIElementRef,
selected_attr.as_concrete_TypeRef(),
&mut selected_value,
);
CFRelease(focused_value);
if selected_error == AX_ERROR_NO_VALUE {
return AxOutcome::EmptySelection;
}
if selected_error != AX_ERROR_SUCCESS || selected_value.is_null() {
return AxOutcome::Unavailable;
}
let selected =
CFString::wrap_under_create_rule(selected_value as CFStringRef).to_string();
match normalize_selected_text(selected) {
Some(text) => AxOutcome::Selected(text),
None => AxOutcome::EmptySelection,
}
}
}
fn capture_clipboard_selected_text() -> Result<Option<String>> {
let pasteboard = NSPasteboard::generalPasteboard();
let snapshot = snapshot_pasteboard(&pasteboard);
let before_copy = pasteboard.changeCount();
post_copy_keypress()?;
let Some(copy_change_count) = wait_for_pasteboard_change(&pasteboard, before_copy) else {
return Ok(None);
};
let copied_text = read_plain_text(&pasteboard).and_then(normalize_selected_text);
if pasteboard.changeCount() == copy_change_count {
restore_pasteboard(&pasteboard, &snapshot);
}
Ok(copied_text)
}
fn snapshot_pasteboard(pasteboard: &NSPasteboard) -> PasteboardSnapshot {
let entries = pasteboard
.types()
.map(|types| {
types
.to_vec()
.into_iter()
.filter_map(|type_name| {
pasteboard
.dataForType(&type_name)
.map(|data| PasteboardEntry {
type_name: type_name.to_string(),
data: data.to_vec(),
})
})
.collect()
})
.unwrap_or_default();
PasteboardSnapshot { entries }
}
fn restore_pasteboard(pasteboard: &NSPasteboard, snapshot: &PasteboardSnapshot) {
let _ = pasteboard.clearContents();
for entry in &snapshot.entries {
let type_name = NSString::from_str(&entry.type_name);
let data = NSData::with_bytes(&entry.data);
let _ = pasteboard.setData_forType(Some(&data), &type_name);
}
}
fn wait_for_pasteboard_change(
pasteboard: &NSPasteboard,
before_copy: NSInteger,
) -> Option<NSInteger> {
let deadline = Instant::now() + COPY_TIMEOUT;
while Instant::now() < deadline {
let current = pasteboard.changeCount();
if current != before_copy {
return Some(current);
}
thread::sleep(COPY_POLL_INTERVAL);
}
None
}
fn read_plain_text(pasteboard: &NSPasteboard) -> Option<String> {
let pb_type: &'static NSPasteboardType = unsafe { NSPasteboardTypeString };
pasteboard
.stringForType(pb_type)
.map(|text| text.to_string())
}
fn post_copy_keypress() -> Result<()> {
let key_down = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, true)
.ok_or_else(|| anyhow!("创建复制按下事件失败"))?;
let key_up = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, false)
.ok_or_else(|| anyhow!("创建复制释放事件失败"))?;
CGEvent::set_flags(Some(&key_down), CGEventFlags::MaskCommand);
CGEvent::set_flags(Some(&key_up), CGEventFlags::MaskCommand);
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_down));
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_up));
Ok(())
}
}
#[cfg(target_os = "windows")]
#[allow(non_snake_case)]
mod win_imp {
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use super::normalize_selected_text;
const COPY_TIMEOUT: Duration = Duration::from_millis(280);
const COPY_POLL_INTERVAL: Duration = Duration::from_millis(12);
type HANDLE = *mut core::ffi::c_void;
type BOOL = i32;
const NULL_HANDLE: HANDLE = std::ptr::null_mut();
const INPUT_KEYBOARD: u32 = 1;
const KEYEVENTF_KEYUP: u32 = 2;
const VK_SHIFT: u16 = 0x10;
const VK_CONTROL: u16 = 0x11;
const VK_MENU: u16 = 0x12;
const VK_C: u16 = 0x43;
const VK_LWIN: u16 = 0x5B;
const VK_RWIN: u16 = 0x5C;
const VK_LSHIFT: u16 = 0xA0;
const VK_RSHIFT: u16 = 0xA1;
const VK_LMENU: u16 = 0xA4;
const VK_RMENU: u16 = 0xA5;
const CF_UNICODETEXT: u32 = 13;
const GMEM_MOVEABLE: u32 = 0x0002;
#[repr(C)]
#[derive(Copy, Clone)]
struct KEYBDINPUT {
wVk: u16,
wScan: u16,
dwFlags: u32,
time: u32,
dwExtraInfo: usize,
}
#[repr(C)]
#[derive(Copy, Clone)]
union INPUT_0 {
ki: KEYBDINPUT,
_padding: [u8; 24],
}
#[repr(C)]
#[derive(Copy, Clone)]
struct INPUT {
r#type: u32,
Anonymous: INPUT_0,
}
extern "system" {
fn OpenClipboard(hWndNewOwner: HANDLE) -> BOOL;
fn CloseClipboard() -> BOOL;
fn EmptyClipboard() -> BOOL;
fn GetClipboardData(uFormat: u32) -> HANDLE;
fn SetClipboardData(uFormat: u32, hMem: HANDLE) -> HANDLE;
fn EnumClipboardFormats(format: u32) -> u32;
fn GetClipboardSequenceNumber() -> u32;
fn GlobalAlloc(uFlags: u32, dwBytes: usize) -> HANDLE;
fn GlobalLock(hMem: HANDLE) -> *mut core::ffi::c_void;
fn GlobalUnlock(hMem: HANDLE) -> BOOL;
fn GlobalSize(hMem: HANDLE) -> usize;
fn GlobalFree(hMem: HANDLE) -> HANDLE;
fn SendInput(cInputs: u32, pInputs: *const INPUT, cbSize: i32) -> u32;
}
struct ClipboardSnapshot {
sequence: u32,
unicode_bytes: Option<Vec<u8>>,
}
pub fn capture_selected_text() -> Result<Option<String>> {
unsafe {
let snapshot = snapshot_clipboard();
post_copy_keypress()?;
let Some(_new_seq) = wait_for_clipboard_change(snapshot.sequence) else {
return Ok(None);
};
let copied_text = read_clipboard_text().and_then(normalize_selected_text);
// 若剪贴板仅发生本次 Ctrl+C 一次更新,无感还原用户原剪贴板文本
if OpenClipboard(NULL_HANDLE) != 0 {
let current_seq = GetClipboardSequenceNumber();
if current_seq == snapshot.sequence.wrapping_add(1) {
EmptyClipboard();
if let Some(ref data) = snapshot.unicode_bytes {
let hmem = GlobalAlloc(GMEM_MOVEABLE, data.len());
if !hmem.is_null() {
let ptr = GlobalLock(hmem);
if !ptr.is_null() {
std::ptr::copy_nonoverlapping(
data.as_ptr(),
ptr as *mut u8,
data.len(),
);
GlobalUnlock(hmem);
SetClipboardData(CF_UNICODETEXT, hmem);
} else {
GlobalFree(hmem);
}
}
}
}
CloseClipboard();
}
Ok(copied_text)
}
}
unsafe fn snapshot_clipboard() -> ClipboardSnapshot {
let sequence = GetClipboardSequenceNumber();
let mut unicode_bytes = None;
if OpenClipboard(NULL_HANDLE) != 0 {
let mut format = 0u32;
loop {
format = EnumClipboardFormats(format);
if format == 0 {
break;
}
if format == CF_UNICODETEXT {
let handle = GetClipboardData(CF_UNICODETEXT);
if !handle.is_null() {
let size = GlobalSize(handle);
let ptr = GlobalLock(handle);
if !ptr.is_null() && size > 0 {
unicode_bytes =
Some(std::slice::from_raw_parts(ptr as *const u8, size).to_vec());
GlobalUnlock(handle);
}
}
break;
}
}
CloseClipboard();
}
ClipboardSnapshot {
sequence,
unicode_bytes,
}
}
unsafe fn read_clipboard_text() -> Option<String> {
if OpenClipboard(NULL_HANDLE) == 0 {
return None;
}
let mut text = None;
let handle = GetClipboardData(CF_UNICODETEXT);
if !handle.is_null() {
let ptr = GlobalLock(handle);
if !ptr.is_null() {
let mut len = 0usize;
let base = ptr as *const u16;
let max_u16 = GlobalSize(handle) / 2;
while len < max_u16 && *base.add(len) != 0 {
len += 1;
}
let slice = std::slice::from_raw_parts(base, len);
text = String::from_utf16(slice).ok();
GlobalUnlock(handle);
}
}
CloseClipboard();
text
}
fn key_input(vk: u16, flags: u32) -> INPUT {
INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: vk,
wScan: 0,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
}
}
fn post_copy_keypress() -> Result<()> {
unsafe {
// 先释放触发全局热键时用户可能仍按住的 Alt / Shift / Win 修饰键,防止合成出 Ctrl+Alt+C
let inputs = [
key_input(VK_MENU, KEYEVENTF_KEYUP),
key_input(VK_LMENU, KEYEVENTF_KEYUP),
key_input(VK_RMENU, KEYEVENTF_KEYUP),
key_input(VK_SHIFT, KEYEVENTF_KEYUP),
key_input(VK_LSHIFT, KEYEVENTF_KEYUP),
key_input(VK_RSHIFT, KEYEVENTF_KEYUP),
key_input(VK_LWIN, KEYEVENTF_KEYUP),
key_input(VK_RWIN, KEYEVENTF_KEYUP),
key_input(VK_CONTROL, 0),
key_input(VK_C, 0),
key_input(VK_C, KEYEVENTF_KEYUP),
key_input(VK_CONTROL, KEYEVENTF_KEYUP),
];
let sent = SendInput(
inputs.len() as u32,
inputs.as_ptr(),
std::mem::size_of::<INPUT>() as i32,
);
if sent != inputs.len() as u32 {
return Err(anyhow!("SendInput 发送 Ctrl+C 不完整"));
}
}
thread::sleep(Duration::from_millis(35));
Ok(())
}
fn wait_for_clipboard_change(before: u32) -> Option<u32> {
let deadline = Instant::now() + COPY_TIMEOUT;
while Instant::now() < deadline {
let current = unsafe { GetClipboardSequenceNumber() };
if current != before {
return Some(current);
}
thread::sleep(COPY_POLL_INTERVAL);
}
None
}
}
#[cfg(target_os = "macos")]
pub fn capture_selected_text() -> Result<Option<String>> {
imp::capture_selected_text()
}
#[cfg(target_os = "macos")]
pub fn is_accessibility_trusted() -> bool {
imp::is_accessibility_trusted()
}
#[cfg(target_os = "windows")]
pub fn capture_selected_text() -> Result<Option<String>> {
win_imp::capture_selected_text()
}
#[cfg(target_os = "windows")]
pub fn is_accessibility_trusted() -> bool {
true
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
pub fn capture_selected_text() -> Result<Option<String>> {
Ok(None)
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
pub fn is_accessibility_trusted() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_whitespace_only_to_none() {
assert_eq!(normalize_selected_text(" \n\t "), None);
assert_eq!(
normalize_selected_text(" hello world \n"),
Some("hello world".to_string())
);
}
}
@@ -1,5 +1,5 @@
[package]
name = "ttree-platform-macos"
name = "ttree-platform"
version.workspace = true
edition.workspace = true
authors.workspace = true
+4
View File
@@ -0,0 +1,4 @@
//! Shared system adapters with isolated macOS and Windows implementations.
pub mod autostart;
pub mod hotkey;
pub mod selection;
@@ -0,0 +1,201 @@
use std::ptr;
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use core_foundation::base::{Boolean, CFRelease, CFTypeRef, TCFType};
use core_foundation::string::{CFString, CFStringRef};
use objc2_app_kit::{NSPasteboard, NSPasteboardType, NSPasteboardTypeString};
use objc2_core_graphics::{CGEvent, CGEventFlags, CGEventTapLocation};
use objc2_foundation::{NSData, NSInteger, NSString};
use crate::selection::normalize_selected_text;
const AX_ERROR_SUCCESS: i32 = 0;
const AX_ERROR_NO_VALUE: i32 = -25212;
const COPY_KEY_CODE: u16 = 8;
const COPY_TIMEOUT: Duration = Duration::from_millis(320);
const COPY_POLL_INTERVAL: Duration = Duration::from_millis(16);
#[repr(C)]
struct __AXUIElement {
_private: [u8; 0],
}
type AXUIElementRef = *const __AXUIElement;
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> Boolean;
fn AXUIElementCreateSystemWide() -> AXUIElementRef;
fn AXUIElementCopyAttributeValue(
element: AXUIElementRef,
attribute: CFStringRef,
value: *mut CFTypeRef,
) -> i32;
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AxOutcome {
Selected(String),
EmptySelection,
Unavailable,
}
#[derive(Debug, Clone)]
struct PasteboardSnapshot {
entries: Vec<PasteboardEntry>,
}
#[derive(Debug, Clone)]
struct PasteboardEntry {
type_name: String,
data: Vec<u8>,
}
pub fn is_accessibility_trusted() -> bool {
unsafe { AXIsProcessTrusted() != 0 }
}
pub fn capture_selected_text() -> Result<Option<String>> {
match capture_accessibility() {
AxOutcome::Selected(text) => Ok(Some(text)),
// Accessibility 已明确告知当前焦点控件为空选区:不合成 Cmd+C,避免误触蜂鸣音与 320ms 轮询等待
AxOutcome::EmptySelection => Ok(None),
AxOutcome::Unavailable => capture_clipboard_selected_text(),
}
}
fn capture_accessibility() -> AxOutcome {
unsafe {
if AXIsProcessTrusted() == 0 {
return AxOutcome::Unavailable;
}
let system_element = AXUIElementCreateSystemWide();
if system_element.is_null() {
return AxOutcome::Unavailable;
}
let focused_attr = CFString::new("AXFocusedUIElement");
let mut focused_value: CFTypeRef = ptr::null();
let focused_error = AXUIElementCopyAttributeValue(
system_element,
focused_attr.as_concrete_TypeRef(),
&mut focused_value,
);
CFRelease(system_element as CFTypeRef);
if focused_error != AX_ERROR_SUCCESS || focused_value.is_null() {
return AxOutcome::Unavailable;
}
let selected_attr = CFString::new("AXSelectedText");
let mut selected_value: CFTypeRef = ptr::null();
let selected_error = AXUIElementCopyAttributeValue(
focused_value as AXUIElementRef,
selected_attr.as_concrete_TypeRef(),
&mut selected_value,
);
CFRelease(focused_value);
if selected_error == AX_ERROR_NO_VALUE {
return AxOutcome::EmptySelection;
}
if selected_error != AX_ERROR_SUCCESS || selected_value.is_null() {
return AxOutcome::Unavailable;
}
let selected = CFString::wrap_under_create_rule(selected_value as CFStringRef).to_string();
match normalize_selected_text(selected) {
Some(text) => AxOutcome::Selected(text),
None => AxOutcome::EmptySelection,
}
}
}
fn capture_clipboard_selected_text() -> Result<Option<String>> {
let pasteboard = NSPasteboard::generalPasteboard();
let snapshot = snapshot_pasteboard(&pasteboard);
let before_copy = pasteboard.changeCount();
post_copy_keypress()?;
let Some(copy_change_count) = wait_for_pasteboard_change(&pasteboard, before_copy) else {
return Ok(None);
};
let copied_text = read_plain_text(&pasteboard).and_then(normalize_selected_text);
if pasteboard.changeCount() == copy_change_count {
restore_pasteboard(&pasteboard, &snapshot);
}
Ok(copied_text)
}
fn snapshot_pasteboard(pasteboard: &NSPasteboard) -> PasteboardSnapshot {
let entries = pasteboard
.types()
.map(|types| {
types
.to_vec()
.into_iter()
.filter_map(|type_name| {
pasteboard
.dataForType(&type_name)
.map(|data| PasteboardEntry {
type_name: type_name.to_string(),
data: data.to_vec(),
})
})
.collect()
})
.unwrap_or_default();
PasteboardSnapshot { entries }
}
fn restore_pasteboard(pasteboard: &NSPasteboard, snapshot: &PasteboardSnapshot) {
let _ = pasteboard.clearContents();
for entry in &snapshot.entries {
let type_name = NSString::from_str(&entry.type_name);
let data = NSData::with_bytes(&entry.data);
let _ = pasteboard.setData_forType(Some(&data), &type_name);
}
}
fn wait_for_pasteboard_change(
pasteboard: &NSPasteboard,
before_copy: NSInteger,
) -> Option<NSInteger> {
let deadline = Instant::now() + COPY_TIMEOUT;
while Instant::now() < deadline {
let current = pasteboard.changeCount();
if current != before_copy {
return Some(current);
}
thread::sleep(COPY_POLL_INTERVAL);
}
None
}
fn read_plain_text(pasteboard: &NSPasteboard) -> Option<String> {
let pb_type: &'static NSPasteboardType = unsafe { NSPasteboardTypeString };
pasteboard
.stringForType(pb_type)
.map(|text| text.to_string())
}
fn post_copy_keypress() -> Result<()> {
let key_down = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, true)
.ok_or_else(|| anyhow!("创建复制按下事件失败"))?;
let key_up = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, false)
.ok_or_else(|| anyhow!("创建复制释放事件失败"))?;
CGEvent::set_flags(Some(&key_down), CGEventFlags::MaskCommand);
CGEvent::set_flags(Some(&key_up), CGEventFlags::MaskCommand);
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_down));
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_up));
Ok(())
}
+64
View File
@@ -0,0 +1,64 @@
//! macOS 选中文本捕获:优先走 Accessibility API (`AXSelectedText`),
//! 仅当辅助功能未授权或焦点控件不支持 AX 属性时才回退到临时剪贴板复制。
use anyhow::Result;
pub fn normalize_selected_text(text: impl AsRef<str>) -> Option<String> {
let trimmed = text.as_ref().trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[cfg(target_os = "macos")]
#[path = "macos/selection.rs"]
mod imp;
#[cfg(target_os = "windows")]
#[path = "windows/selection.rs"]
mod win_imp;
#[cfg(target_os = "macos")]
pub fn capture_selected_text() -> Result<Option<String>> {
imp::capture_selected_text()
}
#[cfg(target_os = "macos")]
pub fn is_accessibility_trusted() -> bool {
imp::is_accessibility_trusted()
}
#[cfg(target_os = "windows")]
pub fn capture_selected_text() -> Result<Option<String>> {
win_imp::capture_selected_text()
}
#[cfg(target_os = "windows")]
pub fn is_accessibility_trusted() -> bool {
true
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
pub fn capture_selected_text() -> Result<Option<String>> {
Ok(None)
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
pub fn is_accessibility_trusted() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_whitespace_only_to_none() {
assert_eq!(normalize_selected_text(" \n\t "), None);
assert_eq!(
normalize_selected_text(" hello world \n"),
Some("hello world".to_string())
);
}
}
@@ -0,0 +1,230 @@
#![allow(non_snake_case)]
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use crate::selection::normalize_selected_text;
const COPY_TIMEOUT: Duration = Duration::from_millis(280);
const COPY_POLL_INTERVAL: Duration = Duration::from_millis(12);
type HANDLE = *mut core::ffi::c_void;
type BOOL = i32;
const NULL_HANDLE: HANDLE = std::ptr::null_mut();
const INPUT_KEYBOARD: u32 = 1;
const KEYEVENTF_KEYUP: u32 = 2;
const VK_SHIFT: u16 = 0x10;
const VK_CONTROL: u16 = 0x11;
const VK_MENU: u16 = 0x12;
const VK_C: u16 = 0x43;
const VK_LWIN: u16 = 0x5B;
const VK_RWIN: u16 = 0x5C;
const VK_LSHIFT: u16 = 0xA0;
const VK_RSHIFT: u16 = 0xA1;
const VK_LMENU: u16 = 0xA4;
const VK_RMENU: u16 = 0xA5;
const CF_UNICODETEXT: u32 = 13;
const GMEM_MOVEABLE: u32 = 0x0002;
#[repr(C)]
#[derive(Copy, Clone)]
struct KEYBDINPUT {
wVk: u16,
wScan: u16,
dwFlags: u32,
time: u32,
dwExtraInfo: usize,
}
#[repr(C)]
#[derive(Copy, Clone)]
union INPUT_0 {
ki: KEYBDINPUT,
_padding: [u8; 24],
}
#[repr(C)]
#[derive(Copy, Clone)]
struct INPUT {
r#type: u32,
Anonymous: INPUT_0,
}
extern "system" {
fn OpenClipboard(hWndNewOwner: HANDLE) -> BOOL;
fn CloseClipboard() -> BOOL;
fn EmptyClipboard() -> BOOL;
fn GetClipboardData(uFormat: u32) -> HANDLE;
fn SetClipboardData(uFormat: u32, hMem: HANDLE) -> HANDLE;
fn EnumClipboardFormats(format: u32) -> u32;
fn GetClipboardSequenceNumber() -> u32;
fn GlobalAlloc(uFlags: u32, dwBytes: usize) -> HANDLE;
fn GlobalLock(hMem: HANDLE) -> *mut core::ffi::c_void;
fn GlobalUnlock(hMem: HANDLE) -> BOOL;
fn GlobalSize(hMem: HANDLE) -> usize;
fn GlobalFree(hMem: HANDLE) -> HANDLE;
fn SendInput(cInputs: u32, pInputs: *const INPUT, cbSize: i32) -> u32;
}
struct ClipboardSnapshot {
sequence: u32,
unicode_bytes: Option<Vec<u8>>,
}
pub fn capture_selected_text() -> Result<Option<String>> {
unsafe {
let snapshot = snapshot_clipboard();
post_copy_keypress()?;
let Some(_new_seq) = wait_for_clipboard_change(snapshot.sequence) else {
return Ok(None);
};
let copied_text = read_clipboard_text().and_then(normalize_selected_text);
// 若剪贴板仅发生本次 Ctrl+C 一次更新,无感还原用户原剪贴板文本
if OpenClipboard(NULL_HANDLE) != 0 {
let current_seq = GetClipboardSequenceNumber();
if current_seq == snapshot.sequence.wrapping_add(1) {
EmptyClipboard();
if let Some(ref data) = snapshot.unicode_bytes {
let hmem = GlobalAlloc(GMEM_MOVEABLE, data.len());
if !hmem.is_null() {
let ptr = GlobalLock(hmem);
if !ptr.is_null() {
std::ptr::copy_nonoverlapping(
data.as_ptr(),
ptr as *mut u8,
data.len(),
);
GlobalUnlock(hmem);
SetClipboardData(CF_UNICODETEXT, hmem);
} else {
GlobalFree(hmem);
}
}
}
}
CloseClipboard();
}
Ok(copied_text)
}
}
unsafe fn snapshot_clipboard() -> ClipboardSnapshot {
let sequence = GetClipboardSequenceNumber();
let mut unicode_bytes = None;
if OpenClipboard(NULL_HANDLE) != 0 {
let mut format = 0u32;
loop {
format = EnumClipboardFormats(format);
if format == 0 {
break;
}
if format == CF_UNICODETEXT {
let handle = GetClipboardData(CF_UNICODETEXT);
if !handle.is_null() {
let size = GlobalSize(handle);
let ptr = GlobalLock(handle);
if !ptr.is_null() && size > 0 {
unicode_bytes =
Some(std::slice::from_raw_parts(ptr as *const u8, size).to_vec());
GlobalUnlock(handle);
}
}
break;
}
}
CloseClipboard();
}
ClipboardSnapshot {
sequence,
unicode_bytes,
}
}
unsafe fn read_clipboard_text() -> Option<String> {
if OpenClipboard(NULL_HANDLE) == 0 {
return None;
}
let mut text = None;
let handle = GetClipboardData(CF_UNICODETEXT);
if !handle.is_null() {
let ptr = GlobalLock(handle);
if !ptr.is_null() {
let mut len = 0usize;
let base = ptr as *const u16;
let max_u16 = GlobalSize(handle) / 2;
while len < max_u16 && *base.add(len) != 0 {
len += 1;
}
let slice = std::slice::from_raw_parts(base, len);
text = String::from_utf16(slice).ok();
GlobalUnlock(handle);
}
}
CloseClipboard();
text
}
fn key_input(vk: u16, flags: u32) -> INPUT {
INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: vk,
wScan: 0,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
}
}
fn post_copy_keypress() -> Result<()> {
unsafe {
// 先释放触发全局热键时用户可能仍按住的 Alt / Shift / Win 修饰键,防止合成出 Ctrl+Alt+C
let inputs = [
key_input(VK_MENU, KEYEVENTF_KEYUP),
key_input(VK_LMENU, KEYEVENTF_KEYUP),
key_input(VK_RMENU, KEYEVENTF_KEYUP),
key_input(VK_SHIFT, KEYEVENTF_KEYUP),
key_input(VK_LSHIFT, KEYEVENTF_KEYUP),
key_input(VK_RSHIFT, KEYEVENTF_KEYUP),
key_input(VK_LWIN, KEYEVENTF_KEYUP),
key_input(VK_RWIN, KEYEVENTF_KEYUP),
key_input(VK_CONTROL, 0),
key_input(VK_C, 0),
key_input(VK_C, KEYEVENTF_KEYUP),
key_input(VK_CONTROL, KEYEVENTF_KEYUP),
];
let sent = SendInput(
inputs.len() as u32,
inputs.as_ptr(),
std::mem::size_of::<INPUT>() as i32,
);
if sent != inputs.len() as u32 {
return Err(anyhow!("SendInput 发送 Ctrl+C 不完整"));
}
}
thread::sleep(Duration::from_millis(35));
Ok(())
}
fn wait_for_clipboard_change(before: u32) -> Option<u32> {
let deadline = Instant::now() + COPY_TIMEOUT;
while Instant::now() < deadline {
let current = unsafe { GetClipboardSequenceNumber() };
if current != before {
return Some(current);
}
thread::sleep(COPY_POLL_INTERVAL);
}
None
}
+9
View File
@@ -0,0 +1,9 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=1600"><title>TTREE · 产品界面渲染图</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#f5f5f7;color:#1d1d1f;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}main{width:1600px;height:1080px;padding:64px 96px;overflow:hidden}.intro{display:flex;align-items:center;gap:20px}.intro img{width:68px;height:68px}.wordmark{font-size:24px;font-weight:650;letter-spacing:2px}.tagline{margin-left:auto;font-size:21px;color:#6e6e73}.devices{display:flex;gap:52px;align-items:flex-start;margin-top:45px}.device{flex:1;min-width:0}.label{font-size:14px;color:#6e6e73;letter-spacing:.4px;margin:0 0 16px 4px;display:flex;align-items:center;gap:9px}.label b{color:#1d1d1f;font-size:18px;letter-spacing:0}.label span{font-size:13px}.window{height:722px;border:1px solid #d5d5d8;background:#fcfcfd;box-shadow:0 24px 50px #00000015,0 4px 12px #00000008;overflow:hidden}.mac{border-radius:22px;background:linear-gradient(135deg,#fafafc,#efeff2)}.win{border-radius:12px;position:relative}.toolbar{height:64px;display:flex;align-items:center;gap:22px;padding:0 22px;border-bottom:1px solid #dfdfe3}.tabs{display:flex;gap:2px;background:#e9e9ed;padding:3px;border-radius:10px;font-size:14px}.tab{padding:7px 24px;color:#86868b}.tab.selected{background:#fff;color:#1d1d1f;border-radius:8px;box-shadow:0 1px 3px #00000010;font-weight:600}.actions{margin-left:auto;display:flex;gap:18px;color:#6e6e73}svg{width:19px;height:19px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}.pane{padding:23px 25px;position:relative}.source{height:193px}.source p{font-size:25px;line-height:1.45;letter-spacing:-.45px;margin:4px 0 0;max-width:480px;font-weight:450}.languages{height:55px;border-top:1px solid #dcdce1;border-bottom:1px solid #dcdce1;display:flex;align-items:center;padding:0 25px;gap:22px;font-size:14px;color:#545458}.languages .arrow{color:#a0a0a6;font-size:18px}.chevron{color:#a0a0a6;margin-left:9px}.result{height:190px;border-bottom:1px solid #dcdce1}.result.last{border:0;height:218px}.kicker{display:flex;align-items:center;font-size:11px;letter-spacing:.8px;color:#86868b;font-weight:600}.copy{margin-left:auto;color:#86868b;font-size:12px;letter-spacing:0;font-weight:400}.result p{font-size:23px;line-height:1.65;margin:22px 0;color:#303033;max-width:480px;font-weight:500}.grip{position:absolute;width:32px;height:3px;border-radius:4px;background:#c8c8ce;left:calc(50% - 16px);bottom:-2px}.win-title{height:38px;display:flex;align-items:center;padding:0 16px;font-size:11px;color:#6e6e73;background:#f5f5f7}.win-title img{width:17px;height:17px;margin-right:8px}.window-btns{margin-left:auto;display:flex;gap:27px;font-size:15px;color:#6e6e73}.win-shell{display:flex;height:calc(100% - 38px)}.rail{width:55px;background:#f0f0f3;border-right:1px solid #e2e2e6;display:flex;flex-direction:column;align-items:center;padding:20px 7px;gap:23px;color:#77777e}.rail .active{padding:8px;background:#fff;border-radius:6px;box-shadow:0 1px 4px #00000009;color:#1d1d1f}.rail .settings{margin-top:auto}.win-body{flex:1;min-width:0;background:#fcfcfd}.win .toolbar{height:48px;font-size:14px;font-weight:600;padding:0 21px}.win .source{height:177px;padding:21px}.win .source p{font-size:23px}.win .languages{height:48px;padding:0 21px;font-size:13px;gap:17px}.win .result{height:183px;padding:21px}.win .last{height:226px}.win .result p{font-size:21px;margin-top:22px}.footer{display:flex;justify-content:center;align-items:center;gap:28px;color:#86868b;font-size:13px;margin-top:37px}.key{font-size:12px;border:1px solid #d7d7dc;border-radius:5px;padding:4px 8px;background:#fff;box-shadow:0 1px 1px #00000007;color:#5c5c63}.disclaimer{text-align:center;color:#a1a1a7;font-size:11px;margin-top:27px;letter-spacing:.2px}
</style>
<svg style="position:absolute;width:0;height:0"><defs><symbol id="pin" viewBox="0 0 24 24"><path d="M8 3h8l-1 7 4 4H5l4-4-1-7M12 14v7"/></symbol><symbol id="gear" viewBox="0 0 24 24"><path d="m9 3-1 3-3 1 1 3-2 2 2 2-1 3 3 1 1 3h6l1-3 3-1-1-3 2-2-2-2 1-3-3-1-1-3Z"/><circle cx="12" cy="12" r="3"/></symbol><symbol id="chat" viewBox="0 0 24 24"><path d="M5 4h14v12H9l-4 4V4Z"/><path d="M9 8h6M9 12h4"/></symbol><symbol id="translate" viewBox="0 0 24 24"><path d="M3 5h10M8 2v3M5 5c0 5 4 9 8 10M11 5c0 5-4 9-8 10M13 21l4-11 4 11M15 17h4"/></symbol></defs></svg>
<main><div class="intro"><img src="../../assets/icons/icon.svg" alt="TTREE"><span class="wordmark">TTREE</span><span class="tagline">让语言退后。让想法向前。</span></div><div class="devices">
<section class="device"><div class="label"><b>Mac</b><span>原生 AppKit</span></div><div class="window mac"><div class="toolbar"><div class="tabs"><div class="tab selected">翻译</div><div class="tab">AI</div></div><div class="actions"><svg><use href="#pin"/></svg><svg><use href="#gear"/></svg></div></div><div class="pane source"><p>Ideas travel further when<br>language gets out of the way.</p><i class="grip"></i></div><div class="languages"><span>自动检测 <small class="chevron">⌃</small></span><span class="arrow">→</span><span>中 ⇄ 英(自适应) <small class="chevron">⌃</small></span></div><div class="pane result"><div class="kicker">翻译 · GOOGLE / BING <span class="copy">复制</span></div><p>当语言不再成为障碍时,<br>思想就会传播得更远。</p><i class="grip"></i></div><div class="pane result last"><div class="kicker">AI · HY-MT1.5 <span class="copy">复制</span></div><p>当语言不再成为障碍时,<br>想法就能传播得更远。</p></div></div></section>
<section class="device"><div class="label"><b>Windows</b><span>原生 Win32</span></div><div class="window win"><div class="win-title"><img src="../../assets/icons/icon.svg" alt="">TTREE<div class="window-btns"><span>−</span><span>□</span><span>×</span></div></div><div class="win-shell"><div class="rail"><div class="active"><svg><use href="#translate"/></svg></div><svg><use href="#chat"/></svg><svg class="settings"><use href="#gear"/></svg></div><div class="win-body"><div class="toolbar">翻译<div class="actions"><svg><use href="#pin"/></svg></div></div><div class="pane source"><p>Ideas travel further when<br>language gets out of the way.</p><i class="grip"></i></div><div class="languages"><span>自动检测 <small class="chevron">⌄</small></span><span class="arrow">→</span><span>中 ⇄ 英(自适应) <small class="chevron">⌄</small></span></div><div class="pane result"><div class="kicker">翻译 · GOOGLE / BING <span class="copy">复制</span></div><p>当语言不再成为障碍时,<br>思想就会传播得更远。</p><i class="grip"></i></div><div class="pane result last"><div class="kicker">AI · HY-MT1.5 <span class="copy">复制</span></div><p>当语言不再成为障碍时,<br>想法就能传播得更远。</p></div></div></div></div></section></div>
<div class="footer"><span><span class="key">⌘ ⇧ Space</span> Mac 呼出</span><span>轻轻唤起,接着思考。</span><span><span class="key">Ctrl ⇧ Space</span> Windows 呼出</span></div><div class="disclaimer">界面渲染示意 · 示例译文仅作展示,实际外观随系统主题与设置变化</div></main></html>
+62
View File
@@ -0,0 +1,62 @@
# 开发指南
## 一次修改,两个平台
`apps/macos` 和 `apps/windows` 各自拥有独立 Cargo package,只维护原生界面与交互。共享能力统一位于 `crates`,使用一个根 Cargo.lock 和工作区版本号。
| 需要修改的内容 | 位置 | 联动范围 |
| --- | --- | --- |
| 语言、提示词输入、布局数据、消息校验 | `crates/ttree-core` | 两端 |
| 免费翻译、分流、回退 | `crates/ttree-translate` | 两端 |
| 流式 AI、HY-MT、本地服务 | `crates/ttree-ai` | 两端;Candle/Metal 实现限 Mac |
| 配置和历史 | `crates/ttree-storage` | 两端;保持旧格式 |
| 快捷键、自启和划词入口 | `crates/ttree-platform` | 两端,系统划词实现分目录 |
| OCR 公共处理/结果类型 | `crates/ttree-ocr/src` | 两端 |
| AppKit / Win32 控件 | `apps/macos` / `apps/windows` | 对应平台 |
| 平台截图/OCR | `crates/ttree-ocr/src/macos` / `windows` | 对应平台 |
![目录与依赖示意图](images/workspace.svg)
不要复制两份配置模型或翻译策略。改变公共接口后同步更新两个调用端,保持平台专用依赖受 `cfg(target_os)` 保护。
## 环境与运行
Mac 需要 Rust stable、Xcode Command Line Tools,以及 Metal 编译工具。打 Universal 包还需 `rustup target add aarch64-apple-darwin x86_64-apple-darwin`。如果 `xcrun -f metal` 失败,在完整 Xcode 环境安装 Metal Toolchain。
Windows 需要 Rust stable MSVC、Visual Studio C++ Build Tools 和 Windows SDK。平台包使用 `x86_64-pc-windows-msvc`。CI 的 Windows 2022 runner 已具备这些条件。
```sh
# 在对应系统任选一个入口;--show 显式显示默认隐藏的窗口
cargo run -p ttree-macos -- --show
cargo run -p ttree-windows -- --show
# 编译和单元测试(workspace 成员在非对应平台使用空入口)
cargo check --workspace --locked
cargo test --workspace --locked
# 发布脚本、签名失败场景和发布策略合约
bash scripts/tests/run-release-tests.sh
# Mac 上只做 Windows 类型检查
cargo check -p ttree-windows --target x86_64-pc-windows-msvc --locked
```
不需要 Node 前端工具链。Mac 发布阶段使用固定版本的 Tauri 独立 signer,仅用于兼容旧客户端的更新附件签名。
## 验证边界
共享代码修改需检查两端。Mac 本地测试与 Windows 交叉检查通过后,仍需 Windows runner 的原生构建、单元测试、便携 ZIP 校验与启动验证。CI 保存 Windows 启动结果和截图作为 artifact;这只是启动检查,不能代替完整桌面交互回归。
Windows 重绘问题可按需运行 `apps/windows/tests/repaint-regression.py`(需 unlocked desktop、Pillow、pywinauto);输出放到 `target/repaint-regression`。Mac 签名和录屏权限的跨升级实测是不同证据,见[发布指南](releasing.md)。
## 工作区整洁
源码在 `apps/` 与 `crates/`;文档在 `docs/`;脚本按 `macos`、`windows`、`release` 分类。日志、截图测试证据和中间构建放 `target/`,对外附件放 `dist/`,均不提交。
README 图片放 `docs/images/`,只能提交确认可公开的界面。API Key 当前保存在用户配置目录 JSON 内,Mac 文件权限设为 0600;不要把它描述成 Keychain 存储,也不要将配置或聊天历史用于测试样本。
## 品牌与渲染图
单色 Logo 源文件为 `assets/icons/icon.svg`。Mac 安装 librsvg 后运行 `python3 scripts/branding/generate-icons.py`,统一生成 PNG、ICO、ICNS。Windows 可生成 PNG/ICO;发布前仍须在 Mac 更新 ICNS。Windows build.rs 将图标及版本编译进 EXE,窗口与托盘均读取内嵌图标。
README 产品图源文件为 `docs/design/product.html`,按 1600 × 1080 CSS 像素渲染,导出 `docs/images/product.png`(当前为 2x)。这是基于现有布局的界面示意,不是实机截图;不要用它作为交互验收证据。
Binary file not shown.

After

Width:  |  Height:  |  Size: 950 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="600" viewBox="0 0 1200 600" role="img" aria-labelledby="title desc">
<title id="title">两个原生客户端,一个共享内核</title><desc id="desc">apps/macos 和 apps/windows 分开维护界面,共同使用 crates 内的翻译、AI、配置、历史记录、OCR 与系统适配。此图为目录示意。</desc>
<rect width="1200" height="600" rx="32" fill="#f5f5f7"/>
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Microsoft YaHei,sans-serif" text-anchor="middle">
<text x="600" y="72" font-size="32" font-weight="700" fill="#1d1d1f">各有风格。共享内核。</text>
<text x="600" y="108" font-size="17" fill="#6e6e73">平台交互,分开打磨。共同能力,一起向前。</text>
<rect x="100" y="153" width="450" height="143" rx="24" fill="white"/>
<rect x="650" y="153" width="450" height="143" rx="24" fill="white"/>
<text x="325" y="202" font-size="26" font-weight="600" fill="#1d1d1f">macOS</text>
<text x="325" y="237" font-size="18" fill="#6e6e73">AppKit · Apple Vision</text>
<text x="325" y="272" font-size="15" fill="#86868b">apps/macos</text>
<text x="875" y="202" font-size="26" font-weight="600" fill="#1d1d1f">Windows</text>
<text x="875" y="237" font-size="18" fill="#6e6e73">Win32 · Windows OCR</text>
<text x="875" y="272" font-size="15" fill="#86868b">apps/windows</text>
<path d="M325 296v40h550v-40M600 336v35" fill="none" stroke="#b8b8bd" stroke-width="3"/>
<rect x="100" y="371" width="1000" height="157" rx="24" fill="#e8e8ed"/>
<text x="600" y="415" font-size="25" font-weight="600" fill="#1d1d1f">共享 Rust 能力</text>
<text x="600" y="455" font-size="18" fill="#424245">翻译与 AI · 配置与历史 · 语言与消息 · OCR 与系统适配</text>
<text x="600" y="496" font-size="15" fill="#6e6e73">crates /  一份业务逻辑,两端同步演进</text>
<text x="600" y="570" font-size="13" fill="#86868b">工作区结构示意 · 非应用界面</text>
</g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+74
View File
@@ -0,0 +1,74 @@
# 构建与发布
## 触发策略
| 事件 | 构建验证 | GitHub Release |
| --- | --- | --- |
| 推送 main | Mac Universal + Windows x64 | `vX.Y.Z-dev.RUN.ATTEMPT`,预发布,不改变 Latest |
| 推送其他分支 | 同上 | 不发布 |
| PR | 两端编译/测试、Windows 打包启动;Mac 无签名编译 | 不发布,不读取签名 Secrets |
| 推送 vX.Y.Z 标签 | 同上并执行发行验证 | 正式版,更新 Latest |
| 手动 workflow_dispatch | 构建与临时 artifacts | 不发布 |
唯一工作流为 `.github/workflows/build.yml`。Mac 和 Windows 同时成功是发布前提,任一失败都不会发布单边版本。
## 正式发布
版本唯一来源为根 `Cargo.toml` 的 `[workspace.package].version`,所有 package 继承它。先更新版本并刷新 Cargo.lock,通过验证后提交至 main,再推送对应标签:
```sh
cargo check --workspace --locked
cargo test --workspace --locked
bash scripts/tests/run-release-tests.sh
git tag vX.Y.Z
git push origin vX.Y.Z
```
标签与版本不符会失败。不要移动已发布标签或覆盖已有 Release。脚本先创建草稿,上传后重新下载实际附件、校验哈希和更新签名,再公开;失败清理此次创建的草稿。发布正文包含双平台下载说明、权限注意事项、GitHub 自动变更说明及提交列表。
## 附件
| 文件 | 用途 |
| --- | --- |
| `TTREE_X.Y.Z_universal.dmg` | Mac 安装包,包含 arm64 + x86_64 |
| `TTREE_universal.app.tar.gz` | 同一个签名 App 的更新归档 |
| `TTREE_universal.app.tar.gz.sig` | 更新归档 minisign 签名 |
| `latest.json` | 旧更新客户端兼容清单,保留原公钥及平台键 |
| `TTREE_X.Y.Z_windows_x64.zip` | Windows 便携包,内含 `TTREE/TTREE.exe` 与许可/说明 |
| `SHA256SUMS` | 全部五项附件的 SHA-256 |
当前纯 Rust UI 未实现自动更新界面,用户从 Release 下载手动替换。保留旧更新附件协议不代表新 UI 已实现更新功能。
## 两层 Mac 签名
社区证书固定为 `TTREE Open Source Code Signing`,SHA-1 `B8D254561BB6CAD78AA71FA18D62A32D10F50C08`,Bundle ID 为 `com.samwstu.ttree`。指定要求必须始终绑定此证书指纹,以延续录屏 TCC 身份。禁止替换、重建或把指纹改成可由 Secret 覆盖的值。
CI 复用已有四项 Secrets:
- `MACOS_COMMUNITY_CERTIFICATE`:加密 P12 的 base64。
- `MACOS_COMMUNITY_CERTIFICATE_PASSWORD`:P12 强口令。
- `TAURI_SIGNING_PRIVATE_KEY`:加密的 minisign/rsign 更新私钥。
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`:至少 16 字符的更新密钥口令。
旧仓库内其他身份/指纹 Secrets 不作为可信预期来源。公钥位于 `scripts/release/updater.pub`,从原发布配置原样迁移,不生成新密钥。
编译步骤没有上述 Secret 环境变量;编译完成后临时导入固定证书,签署 App,随后仅独立 signer 接收更新私钥。退出时删除临时签名 Keychain。
发布硬门禁包含 deep/strict 验证、Authority、Info.plist、封印资源、指定要求;并解开 tar.gz、挂载 DMG 验证容器内的 App。签名失败、ad-hoc 或证书变化都阻止发布。Mac 包非 Apple 公证,首次安装需“隐私与安全性 → 仍要打开”,随后录屏授权并重启应用。
本机沿用既有加密材料及 Keychain 口令:
```sh
source scripts/macos/signing-env.sh
scripts/macos/package.sh universal-apple-darwin 1
# 仅编译,不读取签名材料
scripts/macos/package.sh universal-apple-darwin 1 --build-only
```
Mac 发行输出为 `dist/macos/`,Windows 为 `dist/windows/`;中间 App 在 `target/universal-apple-darwin/release/bundle/macos/TTREE.app`。Windows 本机打包命令为 `./scripts/windows/package.ps1`。
## 验证与证据
Actions 保存每次 Mac/Windows 产物七天;Windows 另保存原生启动检查结果与界面截图。正式附件从这些已验证 artifact 合并产生,再下载草稿中的附件复验。
TCC 权限延续需另做物理机 A→B 实测:授权 A,安装不同 CDHash 但同证书/指定要求的 B,不重置 TCC,执行 OCR 不应再要求授权。签名验证通过只能证明身份一致,不能宣称已完成这项交互验证。
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Rebuild app icons from the monochrome SVG. Needs librsvg; ICNS needs macOS."""
from pathlib import Path
import shutil
import struct
import subprocess
import sys
root = Path(__file__).resolve().parents[2]
icons = root / 'assets/icons'
work = root / 'target/branding'
work.mkdir(parents=True, exist_ok=True)
def render(size, destination):
subprocess.run(['rsvg-convert', '-w', str(size), '-h', str(size),
'-o', str(destination), str(icons / 'icon.svg')], check=True)
for size, filename in [(1024, 'icon.png'), (128, '128x128.png'), (32, '32x32.png')]:
render(size, icons / filename)
frames = []
for size in (16, 24, 32, 48, 64, 128, 256):
path = work / f'{size}.png'
render(size, path)
frames.append((size, path.read_bytes()))
offset = 6 + 16 * len(frames)
entries = []
for size, data in frames:
entries.append(struct.pack('<BBBBHHII', size % 256, size % 256, 0, 0, 1, 32, len(data), offset))
offset += len(data)
(icons / 'icon.ico').write_bytes(struct.pack('<HHH', 0, 1, len(frames)) + b''.join(entries) + b''.join(data for _, data in frames))
if sys.platform == 'darwin':
iconset = work / 'TTREE.iconset'
iconset.mkdir(exist_ok=True)
for size in (16, 32, 128, 256, 512):
render(size, iconset / f'icon_{size}x{size}.png')
render(size*2, iconset / f'icon_{size}x{size}@2x.png')
subprocess.run(['iconutil', '-c', 'icns', str(iconset), '-o', str(icons / 'icon.icns')], check=True)
else:
print('ICO/PNG ready; regenerate ICNS on macOS before committing.')
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Install the locked tree without running arbitrary dependency lifecycle hooks,
# then enable only the two native packages the application actually needs.
npm ci --ignore-scripts
npm rebuild esbuild fsevents
-10
View File
@@ -1,10 +0,0 @@
## macOS installation
TTREE is community-signed with the project's pinned open-source certificate. It is not notarized by Apple.
1. Download the DMG and drag TTREE to Applications.
2. If first launch is blocked, open System Settings > Privacy & Security and choose Open Anyway.
3. Trigger screenshot OCR and allow Screen & System Audio Recording.
4. Fully quit and reopen TTREE after granting permission.
All releases use the same certificate-pinned designated requirement so Screen Recording permission can persist across updates. Install TTREE only from this repository's Releases.
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Print only the temporary keychain path; never print signing material.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
PINNED_IDENTITY="TTREE Open Source Code Signing"
PINNED_SHA1="B8D254561BB6CAD78AA71FA18D62A32D10F50C08"
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ttree-signing.XXXXXX")"
KEYCHAIN="$WORK_DIR/signing.keychain-db"
cleanup_failed() { security delete-keychain "$KEYCHAIN" >/dev/null 2>&1 || true; rm -rf "$WORK_DIR"; }
trap cleanup_failed ERR
umask 077
if [[ -n "${MACOS_COMMUNITY_CERTIFICATE:-}" ]]; then
"$ROOT_DIR/scripts/release/validate-secrets.sh" certificate >&2
printf '%s' "$MACOS_COMMUNITY_CERTIFICATE" | openssl base64 -d -A -out "$WORK_DIR/cert.p12"
CERT_PASSWORD="$MACOS_COMMUNITY_CERTIFICATE_PASSWORD"
else
cp "$HOME/.config/ttree-code-signing/community-signing.p12" "$WORK_DIR/cert.p12"
CERT_PASSWORD="$(security find-generic-password -s com.samwstu.ttree -a community-code-signing-password -w)"
fi
KEYCHAIN_PASSWORD="$(openssl rand -hex 24)"
if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then
echo "::add-mask::$KEYCHAIN_PASSWORD" >&2
fi
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >&2
security set-keychain-settings -lut 21600 "$KEYCHAIN" >&2
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >&2
security import "$WORK_DIR/cert.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" -T /usr/bin/codesign >/dev/null
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
security find-certificate -c "$PINNED_IDENTITY" -p "$KEYCHAIN" > "$WORK_DIR/cert.pem"
ACTUAL_SHA1="$(openssl x509 -in "$WORK_DIR/cert.pem" -noout -fingerprint -sha1 | sed 's/.*=//; s/://g' | tr '[:lower:]' '[:upper:]')"
[[ "$ACTUAL_SHA1" == "$PINNED_SHA1" ]] || { echo "Wrong community certificate" >&2; false; }
if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then
sudo security add-trusted-cert -d -r trustRoot -p codeSign -k /Library/Keychains/System.keychain "$WORK_DIR/cert.pem" >&2
fi
rm -f "$WORK_DIR/cert.p12" "$WORK_DIR/cert.pem"
printf '%s\n' "$KEYCHAIN"
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Build first without secrets; sign and verify the exact containers distributed.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
TARGET="${1:-universal-apple-darwin}"
BUILD_NUMBER="${2:-1}"
MODE="${3:-all}"
VERSION="$(python3 "$ROOT_DIR/scripts/release/metadata.py")"
TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/target}"
APP_DIR="$TARGET_ROOT/$TARGET/release/bundle/macos"
APP_PATH="$APP_DIR/TTREE.app"
RELEASE_DIR="$ROOT_DIR/dist/macos"
UPDATER_KEY="${TAURI_SIGNING_PRIVATE_KEY:-}"
UPDATER_PATH="${TAURI_SIGNING_PRIVATE_KEY_PATH:-}"
UPDATER_PASSWORD="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}"
unset TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PATH TAURI_SIGNING_PRIVATE_KEY_PASSWORD
unset TAURI_PRIVATE_KEY TAURI_PRIVATE_KEY_PATH TAURI_PRIVATE_KEY_PASSWORD
if [[ -z "$UPDATER_KEY" && -n "$UPDATER_PATH" ]]; then UPDATER_KEY="$(cat "$UPDATER_PATH")"; fi
if printf '%s' "$UPDATER_KEY" | grep -Eqi '(minisign|rsign) encrypted secret key'; then
UPDATER_KEY="$(printf '%s' "$UPDATER_KEY" | openssl base64 -A)"
fi
case "$BUILD_NUMBER" in ''|*[!0-9]*) echo 'Invalid build number' >&2; exit 2;; esac
case "$MODE" in all|--build-only|--package-only) ;; *) exit 2;; esac
case "$TARGET" in universal-apple-darwin|aarch64-apple-darwin|x86_64-apple-darwin) ;; *) exit 2;; esac
if [[ "$MODE" != "--build-only" && "$TARGET" != universal-apple-darwin ]]; then
echo 'Distributed macOS assets must be universal' >&2; exit 2
fi
cd "$ROOT_DIR"
if [[ "$MODE" != "--package-only" ]]; then
if [[ "$TARGET" == universal-apple-darwin ]]; then
env -u MACOS_COMMUNITY_CERTIFICATE -u MACOS_COMMUNITY_CERTIFICATE_PASSWORD cargo build --locked --release -p ttree-macos --target aarch64-apple-darwin
env -u MACOS_COMMUNITY_CERTIFICATE -u MACOS_COMMUNITY_CERTIFICATE_PASSWORD cargo build --locked --release -p ttree-macos --target x86_64-apple-darwin
mkdir -p "$TARGET_ROOT/$TARGET/release"
lipo -create "$TARGET_ROOT/aarch64-apple-darwin/release/ttree" "$TARGET_ROOT/x86_64-apple-darwin/release/ttree" -output "$TARGET_ROOT/$TARGET/release/ttree"
else
env -u MACOS_COMMUNITY_CERTIFICATE -u MACOS_COMMUNITY_CERTIFICATE_PASSWORD cargo build --locked --release -p ttree-macos --target "$TARGET"
fi
rm -rf "$APP_PATH"
mkdir -p "$APP_PATH/Contents/MacOS" "$APP_PATH/Contents/Resources"
cp "$TARGET_ROOT/$TARGET/release/ttree" "$APP_PATH/Contents/MacOS/ttree"
cp assets/icons/icon.icns "$APP_PATH/Contents/Resources/icon.icns"
/usr/libexec/PlistBuddy -c 'Clear dict' "$APP_PATH/Contents/Info.plist" >/dev/null
PLIST="$APP_PATH/Contents/Info.plist"
for pair in 'CFBundleName TTREE' 'CFBundleDisplayName TTREE' 'CFBundleExecutable ttree' 'CFBundleIdentifier com.samwstu.ttree' 'CFBundlePackageType APPL' 'CFBundleIconFile icon.icns' 'NSPrincipalClass NSApplication' 'LSMinimumSystemVersion 11.0'; do
/usr/libexec/PlistBuddy -c "Add :${pair%% *} string ${pair#* }" "$PLIST"
done
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $VERSION" "$PLIST"
/usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $BUILD_NUMBER" "$PLIST"
/usr/libexec/PlistBuddy -c 'Add :LSUIElement bool true' "$PLIST"
/usr/libexec/PlistBuddy -c 'Add :NSHighResolutionCapable bool true' "$PLIST"
/usr/libexec/PlistBuddy -c 'Add :NSScreenCaptureUsageDescription string TTREE 需要屏幕录制权限以完成自由框选截图 OCR 翻译。' "$PLIST"
fi
test -x "$APP_PATH/Contents/MacOS/ttree"
if [[ "$MODE" == "--build-only" ]]; then
echo "Unsigned verification bundle: $APP_PATH"
exit 0
fi
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ttree-package.XXXXXX")"
OWN_KEYCHAIN=""
MOUNTED=0
cleanup() {
if [[ "$MOUNTED" == 1 ]]; then hdiutil detach "$WORK_DIR/mount" >/dev/null 2>&1 || true; fi
if [[ -n "$OWN_KEYCHAIN" ]]; then
security delete-keychain "$OWN_KEYCHAIN" >/dev/null 2>&1 || true
rm -rf "$(dirname "$OWN_KEYCHAIN")"
fi
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
SIGNING_KEYCHAIN="${MACOS_COMMUNITY_SIGNING_KEYCHAIN:-}"
if [[ -z "$SIGNING_KEYCHAIN" ]]; then
OWN_KEYCHAIN="$("$ROOT_DIR/scripts/macos/import-certificate.sh")"
SIGNING_KEYCHAIN="$OWN_KEYCHAIN"
fi
REQUIREMENT='=designated => identifier "com.samwstu.ttree" and certificate leaf = H"B8D254561BB6CAD78AA71FA18D62A32D10F50C08"'
codesign --force --options runtime --timestamp=none --sign B8D254561BB6CAD78AA71FA18D62A32D10F50C08 \
--identifier com.samwstu.ttree --requirements "$REQUIREMENT" --keychain "$SIGNING_KEYCHAIN" "$APP_PATH"
"$ROOT_DIR/scripts/macos/verify-signature.sh" "$APP_PATH"
security lock-keychain "$SIGNING_KEYCHAIN"
mkdir -p "$RELEASE_DIR"
tar -czf "$WORK_DIR/TTREE_universal.app.tar.gz" -C "$APP_DIR" TTREE.app
TAURI_SIGNING_PRIVATE_KEY="$UPDATER_KEY" TAURI_SIGNING_PRIVATE_KEY_PATH="$UPDATER_PATH" \
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PASSWORD" "$ROOT_DIR/scripts/release/validate-secrets.sh" updater
# Only the pinned standalone signer receives updater credentials (no frontend build).
TAURI_PRIVATE_KEY="$UPDATER_KEY" \
TAURI_PRIVATE_KEY_PASSWORD="$UPDATER_PASSWORD" \
npx --yes --package @tauri-apps/cli@2.8.4 tauri signer sign "$WORK_DIR/TTREE_universal.app.tar.gz" >/dev/null
mkdir -p "$WORK_DIR/stage"
ditto "$APP_PATH" "$WORK_DIR/stage/TTREE.app"
ln -s /Applications "$WORK_DIR/stage/Applications"
hdiutil create -volname TTREE -srcfolder "$WORK_DIR/stage" -format UDZO -ov "$WORK_DIR/TTREE_${VERSION}_universal.dmg" >/dev/null
cp "$WORK_DIR/TTREE_${VERSION}_universal.dmg" "$WORK_DIR/TTREE_universal.app.tar.gz" "$WORK_DIR/TTREE_universal.app.tar.gz.sig" "$RELEASE_DIR/"
if [[ "$TARGET" == universal-apple-darwin ]]; then
ARCHS="$(lipo -archs "$APP_PATH/Contents/MacOS/ttree")"
[[ " $ARCHS " == *" arm64 "* && " $ARCHS " == *" x86_64 "* ]] || { echo 'Universal binary is missing an architecture' >&2; exit 1; }
fi
echo "Signed macOS assets: $RELEASE_DIR"
@@ -3,8 +3,8 @@
#
# 私钥本身不在仓库内,统一存放在本机固定路径(见 PRIV_KEY_PATH)。
# 私钥口令从 macOS Keychain 读取(account 见 PASS_KEYCHAIN_ACCOUNT),不在脚本里硬编码。
# 用法:source ./scripts/signing-env.sh && ./scripts/package-macos-community.sh
# 或: . ./scripts/signing-env.sh && ./scripts/package-macos-community.sh
# 用法:source ./scripts/macos/signing-env.sh && ./scripts/macos/package.sh
# 或: . ./scripts/macos/signing-env.sh && ./scripts/macos/package.sh
#
# 密钥、加密标记或强口令缺失时给出明确提示并退出(非零),避免产出弱签名更新包。
-242
View File
@@ -1,242 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
TARGET="${1:-universal-apple-darwin}"
BUILD_NUMBER="${2:-1}"
MODE="${3:-all}"
APP_NAME="TTREE"
BUNDLE_IDENTIFIER="com.samwstu.ttree"
PINNED_SIGNING_IDENTITY="TTREE Open Source Code Signing"
PINNED_CERT_SHA1="B8D254561BB6CAD78AA71FA18D62A32D10F50C08"
SIGNING_IDENTITY="${MACOS_COMMUNITY_SIGNING_IDENTITY:-$PINNED_SIGNING_IDENTITY}"
CONFIGURED_CERT_SHA1="$(printf '%s' "${MACOS_COMMUNITY_CERT_SHA1:-$PINNED_CERT_SHA1}" | tr -d ':' | tr '[:lower:]' '[:upper:]')"
SIGNING_KEYCHAIN="${MACOS_COMMUNITY_SIGNING_KEYCHAIN:-}"
UPDATER_PRIVATE_KEY="${TAURI_SIGNING_PRIVATE_KEY:-}"
UPDATER_PRIVATE_KEY_PATH="${TAURI_SIGNING_PRIVATE_KEY_PATH:-}"
UPDATER_KEY_PASSWORD="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}"
unset TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PATH TAURI_SIGNING_PRIVATE_KEY_PASSWORD
contains_encrypted_key_marker() {
grep -Eqi '(minisign|rsign) encrypted secret key'
}
updater_key_is_encrypted() {
if [[ -n "$UPDATER_PRIVATE_KEY" ]]; then
if printf '%s' "$UPDATER_PRIVATE_KEY" | contains_encrypted_key_marker; then
return 0
fi
printf '%s' "$UPDATER_PRIVATE_KEY" |
openssl base64 -d -A 2>/dev/null |
contains_encrypted_key_marker
return
fi
if [[ ! -f "$UPDATER_PRIVATE_KEY_PATH" ]]; then
return 1
fi
if contains_encrypted_key_marker < "$UPDATER_PRIVATE_KEY_PATH"; then
return 0
fi
openssl base64 -d -A -in "$UPDATER_PRIVATE_KEY_PATH" 2>/dev/null |
contains_encrypted_key_marker
}
if [[ "$SIGNING_IDENTITY" != "$PINNED_SIGNING_IDENTITY" ]]; then
echo "community signing identity must remain $PINNED_SIGNING_IDENTITY" >&2
exit 2
fi
if [[ "$CONFIGURED_CERT_SHA1" != "$PINNED_CERT_SHA1" ]]; then
echo "community certificate fingerprint does not match the pinned TTREE identity" >&2
exit 2
fi
case "$MODE" in
all) ;;
build-only | --build-only) MODE="build-only" ;;
package-only | --package-only) MODE="package-only" ;;
*)
echo "mode must be all, build-only, or package-only" >&2
exit 2
;;
esac
if [[ "$MODE" != "build-only" ]]; then
if [[ -z "$UPDATER_PRIVATE_KEY" && -z "$UPDATER_PRIVATE_KEY_PATH" ]]; then
echo "TAURI_SIGNING_PRIVATE_KEY or TAURI_SIGNING_PRIVATE_KEY_PATH is required" >&2
exit 2
fi
if [[ "${#UPDATER_KEY_PASSWORD}" -lt 16 ]]; then
echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD must contain at least 16 characters" >&2
exit 2
fi
if ! updater_key_is_encrypted; then
echo "updater key must be a passphrase-encrypted rsign/minisign private key" >&2
exit 2
fi
fi
case "$BUILD_NUMBER" in
'' | *[!0-9]*)
echo "build number must contain digits only" >&2
exit 2
;;
esac
case "$TARGET" in
universal-apple-darwin)
ARCH_LABEL="universal"
;;
aarch64-apple-darwin)
ARCH_LABEL="aarch64"
;;
x86_64-apple-darwin)
ARCH_LABEL="x64"
;;
*)
echo "unsupported macOS target: $TARGET" >&2
exit 2
;;
esac
VERSION="$(node -p "require('$ROOT_DIR/package.json').version")"
TAURI_VERSION="$(node -p "require('$ROOT_DIR/src-tauri/tauri.conf.json').version")"
if [[ "$VERSION" != "$TAURI_VERSION" ]]; then
echo "package.json and tauri.conf.json versions must match" >&2
exit 2
fi
TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/src-tauri/target}"
BUNDLE_ROOT="$TARGET_ROOT/$TARGET/release/bundle"
MACOS_DIR="$BUNDLE_ROOT/macos"
DMG_DIR="$BUNDLE_ROOT/dmg"
APP_PATH="$MACOS_DIR/$APP_NAME.app"
ARCHIVE_PATH="$MACOS_DIR/$APP_NAME.app.tar.gz"
RELEASE_DIR="$TARGET_ROOT/community-release"
CONFIG_JSON="$(printf '{"bundle":{"createUpdaterArtifacts":false,"macOS":{"bundleVersion":"%s"}}}' "$BUILD_NUMBER")"
WORK_DIR="$(mktemp -d /tmp/ttree-community-package.XXXXXX)"
MOUNT_DIR="$WORK_DIR/mount"
MOUNTED=0
cleanup() {
if [[ "$MOUNTED" -eq 1 ]]; then
hdiutil detach "$MOUNT_DIR" >/dev/null 2>&1 || true
fi
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
build_unsigned_app() {
cd "$ROOT_DIR"
npm run tauri build -- \
--target "$TARGET" \
--bundles app,dmg \
--no-sign \
--config "$CONFIG_JSON"
}
if [[ "$MODE" == "all" || "$MODE" == "build-only" ]]; then
build_unsigned_app
fi
if [[ ! -d "$APP_PATH" || ! -x "$APP_PATH/Contents/MacOS/ttree" ]]; then
echo "Tauri did not produce the expected app bundle: $APP_PATH" >&2
exit 1
fi
if [[ "$MODE" == "build-only" ]]; then
printf 'Unsigned macOS app ready: %s\n' "$APP_PATH"
exit 0
fi
cd "$ROOT_DIR"
REQUIREMENT="=designated => identifier \"$BUNDLE_IDENTIFIER\" and certificate leaf = H\"$PINNED_CERT_SHA1\""
CODESIGN_ARGS=(
--force
--options runtime
--timestamp=none
--sign "$PINNED_CERT_SHA1"
--identifier "$BUNDLE_IDENTIFIER"
--requirements "$REQUIREMENT"
)
if [[ -n "$SIGNING_KEYCHAIN" ]]; then
CODESIGN_ARGS+=(--keychain "$SIGNING_KEYCHAIN")
fi
/usr/bin/codesign "${CODESIGN_ARGS[@]}" "$APP_PATH"
if [[ -n "$SIGNING_KEYCHAIN" ]]; then
/usr/bin/security lock-keychain "$SIGNING_KEYCHAIN"
fi
"$ROOT_DIR/scripts/verify-macos-community-signature.sh" "$APP_PATH"
rm -f "$ARCHIVE_PATH" "$ARCHIVE_PATH.sig"
tar -czf "$ARCHIVE_PATH" -C "$MACOS_DIR" "$APP_NAME.app"
if [[ -n "$UPDATER_PRIVATE_KEY" ]]; then
TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY" \
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_KEY_PASSWORD" \
npx --no-install tauri signer sign "$ARCHIVE_PATH"
else
TAURI_SIGNING_PRIVATE_KEY_PATH="$UPDATER_PRIVATE_KEY_PATH" \
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_KEY_PASSWORD" \
npx --no-install tauri signer sign "$ARCHIVE_PATH"
fi
test -s "$ARCHIVE_PATH.sig"
EXTRACT_DIR="$WORK_DIR/archive"
mkdir -p "$EXTRACT_DIR"
tar -xzf "$ARCHIVE_PATH" -C "$EXTRACT_DIR"
"$ROOT_DIR/scripts/verify-macos-community-signature.sh" "$EXTRACT_DIR/$APP_NAME.app"
DMG_SCRIPT="$DMG_DIR/bundle_dmg.sh"
DMG_PATH="$DMG_DIR/${APP_NAME}_${VERSION}_${ARCH_LABEL}.dmg"
DMG_STAGE="$WORK_DIR/dmg-stage"
if [[ ! -x "$DMG_SCRIPT" ]]; then
echo "Tauri did not produce the expected DMG bundling script: $DMG_SCRIPT" >&2
exit 1
fi
mkdir -p "$DMG_STAGE"
ditto "$APP_PATH" "$DMG_STAGE/$APP_NAME.app"
rm -f "$DMG_DIR"/*.dmg
"$DMG_SCRIPT" \
--volname "$APP_NAME" \
--volicon "$DMG_DIR/icon.icns" \
--window-size 500 350 \
--icon-size 128 \
--icon "$APP_NAME.app" 140 170 \
--hide-extension "$APP_NAME.app" \
--app-drop-link 360 170 \
--no-internet-enable \
"$DMG_PATH" "$DMG_STAGE"
mkdir -p "$MOUNT_DIR"
hdiutil attach "$DMG_PATH" -nobrowse -readonly -mountpoint "$MOUNT_DIR" >/dev/null
MOUNTED=1
"$ROOT_DIR/scripts/verify-macos-community-signature.sh" "$MOUNT_DIR/$APP_NAME.app"
hdiutil detach "$MOUNT_DIR" >/dev/null
MOUNTED=0
rm -rf "$RELEASE_DIR"
mkdir -p "$RELEASE_DIR"
cp "$DMG_PATH" "$RELEASE_DIR/"
cp "$ARCHIVE_PATH" "$RELEASE_DIR/${APP_NAME}_${ARCH_LABEL}.app.tar.gz"
cp "$ARCHIVE_PATH.sig" "$RELEASE_DIR/${APP_NAME}_${ARCH_LABEL}.app.tar.gz.sig"
CDHASH="$(codesign -d --verbose=4 "$APP_PATH" 2>&1 | sed -n 's/^CDHash=//p' | head -n 1)"
DESIGNATED_REQUIREMENT="$(codesign -d -r- "$APP_PATH" 2>&1 | sed -n 's/^designated => //p')"
{
printf 'version=%s\n' "$VERSION"
printf 'build_number=%s\n' "$BUILD_NUMBER"
printf 'target=%s\n' "$TARGET"
printf 'certificate_sha1=%s\n' "$PINNED_CERT_SHA1"
printf 'cdhash=%s\n' "$CDHASH"
printf 'designated_requirement=%s\n' "$DESIGNATED_REQUIREMENT"
} > "$RELEASE_DIR/signature-metadata.txt"
printf 'Community release artifacts: %s\n' "$RELEASE_DIR"
ls -lh "$RELEASE_DIR"
-117
View File
@@ -1,117 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:$HOME/.cargo/bin:$PATH"
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
APP_NAME="TTREE"
BUNDLE_IDENTIFIER="com.samwstu.ttree"
PINNED_SIGNING_IDENTITY="TTREE Open Source Code Signing"
PINNED_CERT_SHA1="B8D254561BB6CAD78AA71FA18D62A32D10F50C08"
VERSION="0.2.0"
BUILD_NUMBER="${1:-1}"
TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT_DIR/target}"
BUNDLE_DIR="$TARGET_DIR/release/bundle/macos"
APP_PATH="$BUNDLE_DIR/$APP_NAME.app"
CONTENTS_DIR="$APP_PATH/Contents"
MACOS_BIN_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
echo "[1/4] Compiling 100% pure-Rust TTREE release binary..."
cd "$ROOT_DIR"
cargo build --release -p ttree-app
echo "[2/4] Assembling macOS App Bundle at $APP_PATH..."
rm -rf "$APP_PATH"
mkdir -p "$MACOS_BIN_DIR" "$RESOURCES_DIR"
cp "$TARGET_DIR/release/ttree" "$MACOS_BIN_DIR/ttree"
chmod +x "$MACOS_BIN_DIR/ttree"
if [[ -f "$ROOT_DIR/assets/icons/icon.icns" ]]; then
cp "$ROOT_DIR/assets/icons/icon.icns" "$RESOURCES_DIR/icon.icns"
fi
cat > "$CONTENTS_DIR/Info.plist" <<EOF
<?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>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<key>CFBundleDisplayName</key>
<string>${APP_NAME}</string>
<key>CFBundleExecutable</key>
<string>ttree</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>CFBundleIdentifier</key>
<string>${BUNDLE_IDENTIFIER}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>${APP_NAME}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundleVersion</key>
<string>${BUILD_NUMBER}</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSScreenCaptureUsageDescription</key>
<string>TTREE 需要屏幕录制权限以完成自由框选截图 OCR 翻译。</string>
</dict>
</plist>
EOF
echo "[3/4] Signing $APP_NAME.app with pinned community certificate ($PINNED_CERT_SHA1)..."
TEMP_KEYCHAIN=""
cleanup() {
if [[ -n "$TEMP_KEYCHAIN" && -f "$TEMP_KEYCHAIN" ]]; then
security delete-keychain "$TEMP_KEYCHAIN" >/dev/null 2>&1 || rm -f "$TEMP_KEYCHAIN"
fi
}
trap cleanup EXIT
SIGNING_KEYCHAIN="${MACOS_COMMUNITY_SIGNING_KEYCHAIN:-}"
if [[ -z "$SIGNING_KEYCHAIN" ]]; then
P12_PATH="$HOME/.config/ttree-code-signing/community-signing.p12"
if [[ -f "$P12_PATH" ]]; then
P12_PASS="$(security find-generic-password -s "com.samwstu.ttree" -a "community-code-signing-password" -w)"
TEMP_KEYCHAIN="$(mktemp -u /tmp/ttree-signing.XXXXXX).keychain-db"
KC_PASS="$(uuidgen)"
security create-keychain -p "$KC_PASS" "$TEMP_KEYCHAIN"
security set-keychain-settings -lut 300 "$TEMP_KEYCHAIN"
security unlock-keychain -p "$KC_PASS" "$TEMP_KEYCHAIN"
security import "$P12_PATH" -k "$TEMP_KEYCHAIN" -P "$P12_PASS" -T /usr/bin/codesign -T /usr/bin/security >/dev/null
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "$TEMP_KEYCHAIN" >/dev/null
SIGNING_KEYCHAIN="$TEMP_KEYCHAIN"
fi
fi
REQUIREMENT="=designated => identifier \"$BUNDLE_IDENTIFIER\" and certificate leaf = H\"$PINNED_CERT_SHA1\""
CODESIGN_ARGS=(
--force
--options runtime
--timestamp=none
--sign "$PINNED_CERT_SHA1"
--identifier "$BUNDLE_IDENTIFIER"
--requirements "$REQUIREMENT"
)
if [[ -n "$SIGNING_KEYCHAIN" ]]; then
CODESIGN_ARGS+=(--keychain "$SIGNING_KEYCHAIN")
fi
/usr/bin/codesign "${CODESIGN_ARGS[@]}" "$APP_PATH"
echo "[4/4] Verifying macOS community signature & TCC designated requirement..."
"$ROOT_DIR/scripts/verify-macos-community-signature.sh" "$APP_PATH"
printf '\nPure-Rust TTREE.app packaged and verified at: %s\n' "$APP_PATH"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Portable SHA-256 manifests shared by Windows, macOS and publication."""
import argparse
import hashlib
from pathlib import Path
def digest(path):
with path.open("rb") as source:
return hashlib.file_digest(source, "sha256").hexdigest()
def write(directory, name):
paths = sorted(p for p in directory.iterdir()
if p.is_file() and p.name != name and not p.name.endswith(".sha256"))
if not paths:
raise ValueError("no assets to checksum")
(directory / name).write_text("".join(f"{digest(p)} {p.name}\n" for p in paths))
def check(directory, name):
lines = (directory / name).read_text().splitlines()
if not lines:
raise ValueError("empty checksum manifest")
for line in lines:
expected, filename = line.split(" ", 1)
if Path(filename).name != filename or filename in (".", ".."):
raise ValueError("unsafe checksum path")
if digest(directory / filename) != expected:
raise ValueError(f"checksum mismatch: {filename}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("directory", type=Path)
parser.add_argument("--write")
parser.add_argument("--check", action="append", default=[])
args = parser.parse_args()
for name in args.check:
check(args.directory, name)
if args.write:
write(args.directory, args.write)
@@ -2,7 +2,7 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
RELEASE_DIR="${1:-}"
TAG_NAME="${2:-}"
REPOSITORY="${3:-ZASENJC/ttree}"
@@ -22,12 +22,7 @@ if [[ ! "$REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
exit 2
fi
VERSION="$(node -p "require('$ROOT_DIR/package.json').version")"
TAURI_VERSION="$(node -p "require('$ROOT_DIR/src-tauri/tauri.conf.json').version")"
if [[ "$VERSION" != "$TAURI_VERSION" ]]; then
echo "package.json and tauri.conf.json versions must match" >&2
exit 1
fi
VERSION="$(python3 "$ROOT_DIR/scripts/release/metadata.py")"
ARCHIVE_NAME="TTREE_universal.app.tar.gz"
ARCHIVE_PATH="$RELEASE_DIR/$ARCHIVE_NAME"
+7
View File
@@ -0,0 +1,7 @@
## 安装与使用
**Mac:** 打开 Universal DMG,将 TTREE 拖入“应用程序”。若首次打开被阻止,请在“系统设置 → 隐私与安全性”选择“仍要打开”。截图 OCR 需授予“屏幕与系统音频录制”权限,授权后完全退出并重启 TTREE。包使用固定社区证书签名,未经过 Apple 公证;同一证书用于维持更新前后的录屏权限身份。
**Windows:** 解压 x64 ZIP 到固定目录,运行 `TTREE/TTREE.exe`,按 `Ctrl+Shift+Space` 呼出窗口。包未使用商业代码签名证书,可能触发 SmartScreen,请核对官方来源及 SHA256SUMS。OCR 依赖系统对应语言的文字识别组件。
翻译和对话共用业务库,分别提供 AppKit / Win32 原生界面。内置模型首次使用时独立下载。当前纯 Rust 版本使用手动下载替换升级;更新归档及签名为旧客户端保留。配置中的 API Key 当前保存在本机 JSON,请勿分享配置文件。
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Single source of release metadata; no dependencies or signing material."""
import argparse
import os
from pathlib import Path
import re
import tomllib
ROOT = Path(__file__).resolve().parents[2]
def version():
value = tomllib.loads((ROOT / "Cargo.toml").read_text())["workspace"]["package"]["version"]
if not re.fullmatch(r"\d+\.\d+\.\d+", value):
raise ValueError("workspace version must be X.Y.Z")
return value
def resolve(event, ref_type, ref_name, run_number, attempt):
app_version = version()
stable_tag = f"v{app_version}"
publish = "false"
prerelease = "true"
release_tag = f"{stable_tag}-dev.{run_number}.{attempt}"
if event == "push" and ref_type == "tag":
if ref_name != stable_tag:
raise ValueError(f"tag {ref_name} must match {stable_tag}")
release_tag, publish, prerelease = stable_tag, "true", "false"
elif event == "push" and ref_type == "branch" and ref_name == "main":
publish = "true"
return dict(app_version=app_version, release_tag=release_tag,
publish=publish, prerelease=prerelease)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--github-output", type=Path)
args = parser.parse_args()
if args.github_output:
values = resolve(os.environ["GITHUB_EVENT_NAME"], os.getenv("GITHUB_REF_TYPE", ""),
os.getenv("GITHUB_REF_NAME", ""), os.environ["GITHUB_RUN_NUMBER"],
os.getenv("GITHUB_RUN_ATTEMPT", "1"))
with args.github_output.open("a") as output:
output.write("".join(f"{key}={value}\n" for key, value in values.items()))
else:
print(version())
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT_DIR"
: "${APP_VERSION:?}" "${RELEASE_TAG:?}" "${PRERELEASE:?}" "${GITHUB_SHA:?}" "${GITHUB_REPOSITORY:?}"
RELEASE_DIR="$ROOT_DIR/dist/release"
NOTES="$RUNNER_TEMP/release-notes.md"
DOWNLOADED="$RUNNER_TEMP/published-assets"
DRAFT_CREATED=0
PUBLISHED=0
cleanup() {
if [[ "$DRAFT_CREATED" == 1 && "$PUBLISHED" == 0 ]]; then
gh release delete "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --yes || true
fi
}
trap cleanup EXIT
python3 scripts/release/verify-assets.py "$RELEASE_DIR" "$APP_VERSION"
python3 scripts/release/checksums.py "$RELEASE_DIR" --check SHA256SUMS
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $RELEASE_TAG already exists; refusing to replace it" >&2
exit 1
fi
{
printf '# TTREE %s\n\n' "$APP_VERSION"
if [[ "$PRERELEASE" == true ]]; then printf '> main 自动构建预发布版。正式使用请下载 Latest 稳定版。\n\n'; fi
printf '## 下载\n\n| 平台 | 文件 |\n| --- | --- |\n'
printf '| macOS · Apple 芯片与 Intel | `TTREE_%s_universal.dmg` |\n' "$APP_VERSION"
printf '| Windows · x64 便携版 | `TTREE_%s_windows_x64.zip` |\n\n' "$APP_VERSION"
cat scripts/release/installation-notes.md
printf '\n## 本次更新\n\n'
gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" -f tag_name="$RELEASE_TAG" -f target_commitish="$GITHUB_SHA" --jq '.body'
PREVIOUS="$(git describe --tags --match 'v[0-9]*' --exclude '*-dev.*' --abbrev=0 "$GITHUB_SHA^" 2>/dev/null || true)"
if [[ -n "$PREVIOUS" ]]; then
printf '\n### 提交记录\n\n'
git log --no-merges --format='- %s (%h)' "$PREVIOUS..$GITHUB_SHA" -- . ':!Cargo.lock'
fi
printf '\n构建提交:`%s`。附件附有 `SHA256SUMS` 校验值。\n' "$GITHUB_SHA"
} > "$NOTES"
FLAGS=(--draft --title "TTREE $RELEASE_TAG" --notes-file "$NOTES" --target "$GITHUB_SHA")
if [[ "$PRERELEASE" == true ]]; then FLAGS+=(--prerelease --latest=false); else FLAGS+=(--verify-tag --latest); fi
gh release create "$RELEASE_TAG" "$RELEASE_DIR"/* --repo "$GITHUB_REPOSITORY" "${FLAGS[@]}"
DRAFT_CREATED=1
mkdir -p "$DOWNLOADED"
gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir "$DOWNLOADED"
python3 scripts/release/verify-assets.py "$DOWNLOADED" "$APP_VERSION"
python3 scripts/release/checksums.py "$DOWNLOADED" --check SHA256SUMS
VERIFY_MACOS_CONTAINERS=0 scripts/release/verify-macos-assets.sh "$DOWNLOADED" "$RELEASE_TAG" "$GITHUB_REPOSITORY" "$APP_VERSION"
if [[ "$PRERELEASE" == true ]]; then
gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest=false
else
gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest
fi
PUBLISHED=1
+2
View File
@@ -0,0 +1,2 @@
untrusted comment: minisign public key 8FBFCC04F00CA414
RWQUpAzwBMy/j3GFuJBQJ5enRuHFy0q2HJbXd0/cDxNxnPqagCVtt+yP
File renamed without changes.
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Validate the release asset set and the portable Windows executable."""
from pathlib import Path, PurePosixPath
import sys
import zipfile
def verify(directory, version):
required = {f"TTREE_{version}_universal.dmg", "TTREE_universal.app.tar.gz",
"TTREE_universal.app.tar.gz.sig", "latest.json", f"TTREE_{version}_windows_x64.zip"}
allowed = required | {"macos.sha256", "windows.sha256", "SHA256SUMS"}
actual = {p.name for p in directory.iterdir()}
if not required <= actual or actual - allowed:
raise ValueError(f"unexpected release files: missing={required-actual}, extra={actual-allowed}")
for name in required:
if not (directory / name).is_file() or (directory / name).stat().st_size == 0:
raise ValueError(f"empty release asset: {name}")
with zipfile.ZipFile(directory / f"TTREE_{version}_windows_x64.zip") as archive:
if archive.testzip():
raise ValueError("damaged Windows ZIP")
for entry in archive.namelist():
if ".." in PurePosixPath(entry).parts or entry.startswith(("/", "\\")) or ":" in entry:
raise ValueError("unsafe Windows ZIP path")
with archive.open("TTREE/TTREE.exe") as executable:
if executable.read(2) != b"MZ":
raise ValueError("Windows executable is missing its PE header")
if __name__ == "__main__":
verify(Path(sys.argv[1]), sys.argv[2])
@@ -2,7 +2,7 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
RELEASE_DIR="${1:-}"
TAG_NAME="${2:-}"
REPOSITORY="${3:-}"
@@ -70,8 +70,7 @@ cleanup() {
}
trap cleanup EXIT
PUBKEY_B64="$(jq -er '.plugins.updater.pubkey' "$ROOT_DIR/src-tauri/tauri.conf.json")"
printf '%s' "$PUBKEY_B64" | openssl base64 -d -A -out "$WORK_DIR/updater.pub"
cp "$ROOT_DIR/scripts/release/updater.pub" "$WORK_DIR/updater.pub"
printf '%s' "$SIGNATURE" | openssl base64 -d -A -out "$WORK_DIR/archive.sig"
minisign -V -q \
-m "$ARCHIVE_PATH" \
@@ -87,12 +86,12 @@ if [[ "$VERIFY_CONTAINERS" -eq 1 ]]; then
EXTRACT_DIR="$WORK_DIR/archive"
mkdir -p "$EXTRACT_DIR"
tar -xzf "$ARCHIVE_PATH" -C "$EXTRACT_DIR"
"$ROOT_DIR/scripts/verify-macos-community-signature.sh" "$EXTRACT_DIR/TTREE.app"
"$ROOT_DIR/scripts/macos/verify-signature.sh" "$EXTRACT_DIR/TTREE.app"
mkdir -p "$MOUNT_DIR"
hdiutil attach "$DMG_PATH" -nobrowse -readonly -mountpoint "$MOUNT_DIR" >/dev/null
MOUNTED=1
"$ROOT_DIR/scripts/verify-macos-community-signature.sh" "$MOUNT_DIR/TTREE.app"
"$ROOT_DIR/scripts/macos/verify-signature.sh" "$MOUNT_DIR/TTREE.app"
hdiutil detach "$MOUNT_DIR" >/dev/null
MOUNTED=0
fi
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
INSTALL_SCRIPT="$ROOT_DIR/scripts/ci-install.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
if [[ "$(jq -r '.scripts["ci:install"] // empty' "$ROOT_DIR/package.json")" != "bash scripts/ci-install.sh" ]]; then
echo "package.json must expose ci:install through scripts/ci-install.sh" >&2
exit 1
fi
if jq -e 'has("allowScripts")' "$ROOT_DIR/package.json" >/dev/null; then
echo "package.json must not rely on npm-ignored allowScripts" >&2
exit 1
fi
mkdir -p "$TMP_DIR/bin"
cat > "$TMP_DIR/bin/npm" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$MOCK_NPM_LOG"
EOF
chmod +x "$TMP_DIR/bin/npm"
MOCK_NPM_LOG="$TMP_DIR/npm.log" PATH="$TMP_DIR/bin:$PATH" bash "$INSTALL_SCRIPT"
EXPECTED="$(printf '%s\n%s\n' 'ci --ignore-scripts' 'rebuild esbuild fsevents')"
ACTUAL="$(cat "$TMP_DIR/npm.log")"
if [[ "$ACTUAL" != "$EXPECTED" ]]; then
echo "ci-install.sh ran unexpected npm commands" >&2
exit 1
fi
echo "ci-install tests passed"
@@ -1,71 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
PACKAGE_SCRIPT="$ROOT_DIR/scripts/package-macos-community.sh"
PINNED_SHA1="B8D254561BB6CAD78AA71FA18D62A32D10F50C08"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
require_text() {
local text="$1"
if ! grep -Fq -- "$text" "$PACKAGE_SCRIPT"; then
echo "package script is missing: $text" >&2
exit 1
fi
}
require_text "PINNED_CERT_SHA1=\"$PINNED_SHA1\""
require_text 'UPDATER_PRIVATE_KEY="${TAURI_SIGNING_PRIVATE_KEY:-}"'
require_text 'UPDATER_PRIVATE_KEY_PATH="${TAURI_SIGNING_PRIVATE_KEY_PATH:-}"'
require_text 'UPDATER_KEY_PASSWORD="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}"'
require_text 'unset TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PATH TAURI_SIGNING_PRIVATE_KEY_PASSWORD'
require_text 'updater key must be a passphrase-encrypted rsign/minisign private key'
require_text '--sign "$PINNED_CERT_SHA1"'
require_text 'certificate leaf = H\"$PINNED_CERT_SHA1\"'
require_text 'TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY"'
require_text 'TAURI_SIGNING_PRIVATE_KEY_PATH="$UPDATER_PRIVATE_KEY_PATH"'
require_text 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_KEY_PASSWORD"'
require_text 'MODE="${3:-all}"'
require_text 'build-only'
require_text 'package-only'
require_text '/usr/bin/security lock-keychain "$SIGNING_KEYCHAIN"'
capture_line="$(grep -Fnm1 'UPDATER_PRIVATE_KEY="${TAURI_SIGNING_PRIVATE_KEY:-}"' "$PACKAGE_SCRIPT" | cut -d: -f1)"
unset_line="$(grep -Fnm1 'unset TAURI_SIGNING_PRIVATE_KEY' "$PACKAGE_SCRIPT" | cut -d: -f1)"
build_line="$(grep -Fnm1 'npm run tauri build' "$PACKAGE_SCRIPT" | cut -d: -f1)"
sign_line="$(grep -Fnm1 'npx --no-install tauri signer sign' "$PACKAGE_SCRIPT" | cut -d: -f1)"
lock_line="$(grep -Fnm1 '/usr/bin/security lock-keychain' "$PACKAGE_SCRIPT" | cut -d: -f1)"
if [[ ! "$capture_line" -lt "$unset_line" || ! "$unset_line" -lt "$build_line" || ! "$build_line" -lt "$lock_line" || ! "$lock_line" -lt "$sign_line" ]]; then
echo "updater secrets must be captured, unset, withheld from build, then scoped to signer" >&2
exit 1
fi
mkdir -p "$TMP_DIR/bin"
cat > "$TMP_DIR/bin/npm" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$MOCK_NPM_LOG"
app="$CARGO_TARGET_DIR/universal-apple-darwin/release/bundle/macos/TTREE.app"
mkdir -p "$app/Contents/MacOS"
printf '#!/usr/bin/env sh\n' > "$app/Contents/MacOS/ttree"
chmod +x "$app/Contents/MacOS/ttree"
EOF
chmod +x "$TMP_DIR/bin/npm"
env -u TAURI_SIGNING_PRIVATE_KEY \
-u TAURI_SIGNING_PRIVATE_KEY_PATH \
-u TAURI_SIGNING_PRIVATE_KEY_PASSWORD \
PATH="$TMP_DIR/bin:$PATH" \
CARGO_TARGET_DIR="$TMP_DIR/target" \
MOCK_NPM_LOG="$TMP_DIR/npm.log" \
"$PACKAGE_SCRIPT" universal-apple-darwin 1 --build-only >/dev/null
grep -Fq 'run tauri build' "$TMP_DIR/npm.log" || {
echo "build-only mode did not run the unsigned Tauri build" >&2
exit 1
}
echo "package-macos-community tests passed"
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
DOC="$ROOT_DIR/CLAUDE.md"
MACOS_INFO_PLIST="$ROOT_DIR/src-tauri/Info.plist"
TAURI_ENTRYPOINT="$ROOT_DIR/src-tauri/src/lib.rs"
grep -Fq '/usr/sbin/screencapture -i -x -r' "$DOC" || {
echo "CLAUDE.md must preserve the native screencapture architecture" >&2
exit 1
}
for forbidden in 'CoreGraphics' 'SelectionOverlay' 'selection overlay' 'source ./scripts/signing-env.sh && npm run tauri build'; do
if grep -Fq "$forbidden" "$DOC"; then
echo "CLAUDE.md contains obsolete release/screenshot guidance: $forbidden" >&2
exit 1
fi
done
grep -Fq 'scripts/package-macos-community.sh' "$DOC" || {
echo "CLAUDE.md must point local signed builds at the community packaging gate" >&2
exit 1
}
if [[ ! -f "$MACOS_INFO_PLIST" ]] ||
[[ "$(plutil -extract LSUIElement raw -o - "$MACOS_INFO_PLIST" 2>/dev/null || true)" != "true" ]]; then
echo "macOS bundle must set LSUIElement=true so TTREE stays out of the Dock" >&2
exit 1
fi
grep -Fq 'set_activation_policy(tauri::ActivationPolicy::Accessory)' "$TAURI_ENTRYPOINT" || {
echo "macOS startup must use the Accessory activation policy" >&2
exit 1
}
echo "project invariant tests passed"
+1 -1
View File
@@ -3,7 +3,7 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
VALIDATE="$ROOT_DIR/scripts/validate-release-secrets.sh"
VALIDATE="$ROOT_DIR/scripts/release/validate-secrets.sh"
ENCRYPTED_UPDATER_KEY="$(printf '%s\n%s\n' \
'untrusted comment: rsign encrypted secret key' \
'mock-key-material' | openssl base64 -A)"
-130
View File
@@ -1,130 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
WORKFLOW="$ROOT_DIR/.github/workflows/release.yml"
PINNED_CERT_SHA1="B8D254561BB6CAD78AA71FA18D62A32D10F50C08"
fail() {
echo "release workflow contract failed: $*" >&2
exit 1
}
require_text() {
local text="$1"
grep -Fq -- "$text" "$WORKFLOW" || fail "missing: $text"
}
reject_text() {
local text="$1"
if grep -Fq -- "$text" "$WORKFLOW"; then
fail "forbidden text present: $text"
fi
}
line_number() {
local text="$1"
grep -Fnm1 -- "$text" "$WORKFLOW" | cut -d: -f1
}
assert_before() {
local first="$1"
local second="$2"
local first_line second_line
first_line="$(line_number "$first")"
second_line="$(line_number "$second")"
[[ -n "$first_line" && -n "$second_line" && "$first_line" -lt "$second_line" ]] ||
fail "expected '$first' before '$second'"
}
require_text "EXPECTED_MACOS_COMMUNITY_CERT_SHA1: $PINNED_CERT_SHA1"
require_text 'MACOS_COMMUNITY_CERTIFICATE'
require_text 'MACOS_COMMUNITY_CERTIFICATE_PASSWORD'
require_text 'TAURI_SIGNING_PRIVATE_KEY'
require_text 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD'
require_text './scripts/validate-release-secrets.sh'
require_text './scripts/validate-release-secrets.sh certificate'
require_text './scripts/validate-release-secrets.sh updater'
require_text 'package_version="$(jq -r '\''.version'\'' package.json)"'
require_text 'tauri_version="$(jq -r '\''.version'\'' src-tauri/tauri.conf.json)"'
require_text 'expected_tag="v$package_version"'
require_text 'Tag $GITHUB_REF_NAME does not match package version $package_version'
require_text 'package.json version $package_version does not match tauri.conf.json version $tauri_version'
require_text 'npm run ci:install'
reject_text 'run: npm ci'
require_text 'npm run check'
require_text 'npm test'
require_text 'cargo check --manifest-path src-tauri/Cargo.toml'
require_text 'cargo test --manifest-path src-tauri/Cargo.toml'
require_text 'cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings'
require_text './scripts/package-macos-community.sh universal-apple-darwin'
require_text './scripts/package-macos-community.sh universal-apple-darwin "$GITHUB_RUN_NUMBER" --build-only'
require_text './scripts/package-macos-community.sh universal-apple-darwin "$GITHUB_RUN_NUMBER" --package-only'
require_text './scripts/verify-macos-community-signature.sh'
require_text './scripts/verify-macos-release-assets.sh'
require_text 'shasum -a 256 --check SHA256SUMS'
require_text 'trap cleanup_import_files EXIT'
require_text 'workflow_dispatch:'
require_text "if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')"
require_text 'gh release create "$RELEASE_TAG"'
require_text 'gh release delete "$RELEASE_TAG"'
require_text 'existing_tag="$RELEASE_TAG"'
require_text 'gh release delete "$existing_tag"'
require_text '--json isDraft'
require_text 'Release $RELEASE_TAG is already published'
require_text '--yes'
require_text '--draft'
require_text 'gh release download "$RELEASE_TAG"'
require_text 'gh release edit "$RELEASE_TAG"'
require_text '--draft=false'
require_text 'id: draft-release'
require_text 'id: publish-release'
require_text "steps.draft-release.outcome == 'success'"
require_text "steps.publish-release.outcome != 'success'"
require_text 'TTREE_${APP_VERSION}_universal.dmg'
require_text 'TTREE_universal.app.tar.gz'
require_text 'TTREE_universal.app.tar.gz.sig'
require_text 'latest.json'
reject_text 'APPLE_ID'
reject_text 'APPLE_PASSWORD'
reject_text 'APPLE_TEAM_ID'
reject_text 'APPLE_CERTIFICATE'
reject_text 'xcrun stapler'
reject_text 'tauri-apps/tauri-action@'
reject_text 'secrets.MACOS_COMMUNITY_CERT_SHA1'
reject_text '--cleanup-tag'
verify_job_env="$(awk '
/^ env:/ { in_env = 1; next }
/^ steps:/ { exit }
in_env { print }
' "$WORKFLOW")"
if grep -Fq 'secrets.' <<< "$verify_job_env"; then
fail "verify job-level environment must not expose secrets to every step"
fi
action_count=0
while IFS= read -r action; do
action_count=$((action_count + 1))
if [[ ! "$action" =~ @[0-9a-f]{40}$ ]]; then
fail "third-party action is not pinned to a full commit SHA: $action"
fi
done < <(sed -nE 's/^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]*([^[:space:]#]+).*/\2/p' "$WORKFLOW")
[[ "$action_count" -ge 5 ]] || fail "expected pinned checkout/setup/cache/upload/download actions"
assert_before './scripts/verify-macos-release-assets.sh' 'actions/upload-artifact@'
assert_before 'brew list minisign' 'MACOS_COMMUNITY_CERTIFICATE: ${{ secrets.MACOS_COMMUNITY_CERTIFICATE }}'
assert_before '--build-only' 'MACOS_COMMUNITY_CERTIFICATE: ${{ secrets.MACOS_COMMUNITY_CERTIFICATE }}'
assert_before 'MACOS_COMMUNITY_CERTIFICATE: ${{ secrets.MACOS_COMMUNITY_CERTIFICATE }}' '--package-only'
assert_before 'Clean up signing keychain' 'Verify final app signature'
assert_before 'Clean up signing keychain' 'actions/upload-artifact@'
assert_before 'gh release create "$RELEASE_TAG"' 'gh release download "$RELEASE_TAG"'
assert_before 'gh release download "$RELEASE_TAG"' 'gh release edit "$RELEASE_TAG"'
assert_before 'gh release create "$RELEASE_TAG"' 'gh release delete "$RELEASE_TAG"'
echo "release workflow tests passed"
+88
View File
@@ -0,0 +1,88 @@
import importlib.util
from pathlib import Path
import tempfile
import unittest
import zipfile
ROOT = Path(__file__).resolve().parents[2]
def load(name, filename):
spec = importlib.util.spec_from_file_location(name, ROOT / "scripts/release" / filename)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
metadata = load("metadata", "metadata.py")
checksums = load("checksums", "checksums.py")
assets = load("assets", "verify-assets.py")
class ReleaseTests(unittest.TestCase):
def test_only_main_pushes_and_matching_version_tags_publish(self):
for event, kind, ref, publish, prerelease in [
("push", "branch", "main", "true", "true"),
("push", "branch", "feat/test", "false", "true"),
("pull_request", "branch", "main", "false", "true"),
("workflow_dispatch", "tag", f"v{metadata.version()}", "false", "true"),
("push", "tag", f"v{metadata.version()}", "true", "false"),
]:
with self.subTest(event=event, kind=kind, ref=ref):
result = metadata.resolve(event, kind, ref, "10", "2")
self.assertEqual(result["publish"], publish)
self.assertEqual(result["prerelease"], prerelease)
with self.assertRaises(ValueError):
metadata.resolve("push", "tag", "v0.0.0", "10", "1")
def test_reruns_have_unique_prerelease_tags(self):
self.assertNotEqual(metadata.resolve("push", "branch", "main", "10", "1")["release_tag"],
metadata.resolve("push", "branch", "main", "10", "2")["release_tag"])
def test_checksums_reject_tampering_and_path_escape(self):
with tempfile.TemporaryDirectory() as temp:
directory = Path(temp)
(directory / "app.zip").write_bytes(b"original")
checksums.write(directory, "SHA256SUMS")
checksums.check(directory, "SHA256SUMS")
(directory / "app.zip").write_bytes(b"tampered")
with self.assertRaises(ValueError):
checksums.check(directory, "SHA256SUMS")
(directory / "SHA256SUMS").write_text("bad ../outside\n")
with self.assertRaises(ValueError):
checksums.check(directory, "SHA256SUMS")
def test_release_requires_both_platforms_and_real_pe_executable(self):
with tempfile.TemporaryDirectory() as temp:
directory = Path(temp)
for name in ("TTREE_1.0.0_universal.dmg", "TTREE_universal.app.tar.gz",
"TTREE_universal.app.tar.gz.sig", "latest.json"):
(directory / name).write_bytes(b"fixture")
with self.assertRaises(ValueError):
assets.verify(directory, "1.0.0")
zip_path = directory / "TTREE_1.0.0_windows_x64.zip"
with zipfile.ZipFile(zip_path, "w") as archive:
archive.writestr("TTREE/TTREE.exe", b"MZfixture")
assets.verify(directory, "1.0.0")
with zipfile.ZipFile(zip_path, "w") as archive:
archive.writestr("TTREE/TTREE.exe", b"not an executable")
with self.assertRaises(ValueError):
assets.verify(directory, "1.0.0")
def test_workspace_and_workflow_use_current_source(self):
self.assertTrue((ROOT / "apps/macos/src/main.rs").exists())
self.assertTrue((ROOT / "apps/windows/src/main.rs").exists())
self.assertFalse((ROOT / "crates/ttree-app").exists())
workflow = (ROOT / ".github/workflows/build.yml").read_text()
self.assertIn("needs: [metadata, macos, windows]", workflow)
self.assertIn("scripts/release/publish.sh", workflow)
self.assertNotIn("npm run", workflow)
self.assertNotIn("src-tauri/", workflow)
for name in ("AGENTS.md", "README.md", "CLAUDE.md"):
text = (ROOT / name).read_text()
self.assertNotIn("npm run tauri", text)
self.assertNotIn("src-tauri/", text)
if __name__ == "__main__":
unittest.main()
+2 -11
View File
@@ -3,17 +3,8 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TESTS=(
ci-install.test.sh
package-macos-community.test.sh
project-invariants.test.sh
release-workflow.test.sh
release-secrets.test.sh
signing-env.test.sh
verify-macos-community-signature.test.sh
verify-macos-release-assets.test.sh
verify-macos-signature.test.sh
)
python3 "$SCRIPT_DIR/release_test.py"
TESTS=(release-secrets.test.sh signing-env.test.sh verify-macos-community-signature.test.sh verify-macos-release-assets.test.sh)
for test_file in "${TESTS[@]}"; do
bash "$SCRIPT_DIR/$test_file"
+1 -1
View File
@@ -3,7 +3,7 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
SIGNING_ENV="$ROOT_DIR/scripts/signing-env.sh"
SIGNING_ENV="$ROOT_DIR/scripts/macos/signing-env.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
@@ -38,7 +38,7 @@ esac
EOF
chmod +x "$TMP_DIR/bin/codesign"
VERIFY="$ROOT_DIR/scripts/verify-macos-community-signature.sh"
VERIFY="$ROOT_DIR/scripts/macos/verify-signature.sh"
EXPECTED_ID="com.samwstu.ttree"
EXPECTED_AUTHORITY="TTREE Open Source Code Signing"
EXPECTED_SHA1="B8D254561BB6CAD78AA71FA18D62A32D10F50C08"
+1 -1
View File
@@ -3,7 +3,7 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
VERIFY="$ROOT_DIR/scripts/verify-macos-release-assets.sh"
VERIFY="$ROOT_DIR/scripts/release/verify-macos-assets.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
bash "$ROOT_DIR/scripts/tests/verify-macos-community-signature.test.sh" >/dev/null
echo "verify-macos-signature compatibility tests passed"
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "[codesign] verify-macos-signature.sh is deprecated; enforcing the pinned community identity" >&2
exec "$SCRIPT_DIR/verify-macos-community-signature.sh" "${1:-}"
+27
View File
@@ -0,0 +1,27 @@
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path "$PSScriptRoot/../..").Path
Push-Location $Root
try {
$Version = python scripts/release/metadata.py
if ($LASTEXITCODE -ne 0) { throw 'Cannot read workspace version' }
cargo build --locked --release -p ttree-windows --target x86_64-pc-windows-msvc
if ($LASTEXITCODE -ne 0) { throw 'Windows build failed' }
$Stage = Join-Path $Root 'target/package/windows/TTREE'
if (Test-Path $Stage) { Remove-Item -Recurse -Force $Stage }
New-Item -ItemType Directory -Force $Stage, "$Root/dist/windows" | Out-Null
Copy-Item target/x86_64-pc-windows-msvc/release/ttree-windows.exe "$Stage/TTREE.exe"
Copy-Item README.md, LICENSE $Stage
Copy-Item -Recurse assets/licenses "$Stage/licenses"
Copy-Item apps/windows/README.md "$Stage/Windows.md"
$Zip = "$Root/dist/windows/TTREE_${Version}_windows_x64.zip"
Compress-Archive -Path $Stage -DestinationPath $Zip -Force
# Validate the container and executable, not just cargo's exit code.
Add-Type -AssemblyName System.IO.Compression.FileSystem
$Archive = [System.IO.Compression.ZipFile]::OpenRead($Zip)
try {
if (-not ($Archive.Entries | Where-Object { $_.FullName -eq 'TTREE/TTREE.exe' -and $_.Length -gt 0 })) {
throw 'Portable ZIP is missing TTREE/TTREE.exe'
}
} finally { $Archive.Dispose() }
Write-Host "Windows portable build: $Zip"
} finally { Pop-Location }
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more