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

+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"
-257
View File
@@ -1,257 +0,0 @@
"""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
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.
The natural screen capture is taken before any forced redraw; a static screenshot
alone cannot detect the stale pixels this regression originally produced.
"""
import os, json, time, traceback, ctypes, ctypes.wintypes as wt, hashlib, subprocess
from pathlib import Path
from PIL import ImageGrab, ImageChops
from pywinauto import Application, mouse
from pywinauto.timings import wait_until
import argparse
parser = argparse.ArgumentParser(
description="Windows native repaint regression; requires an interactive desktop, pywinauto and Pillow."
)
parser.add_argument(
"--exe", required=True, type=Path, help="Native TTREE executable to test"
)
parser.add_argument(
"--artifacts",
required=True,
type=Path,
help="Directory for screen samples and JSON result",
)
args = parser.parse_args()
exe = args.exe.resolve()
out = args.artifacts.resolve()
out.mkdir(exist_ok=True, parents=True)
report = {"checks": []}
proc = None
win = None
root_hwnd = None
u = ctypes.windll.user32
dwm = ctypes.windll.dwmapi
u.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
u.SendMessageW.argtypes = [wt.HWND, wt.UINT, wt.WPARAM, wt.LPARAM]
u.SendMessageW.restype = ctypes.c_ssize_t
class RECT(ctypes.Structure):
_fields_ = [
("left", wt.LONG),
("top", wt.LONG),
("right", wt.LONG),
("bottom", wt.LONG),
]
class SCROLLINFO(ctypes.Structure):
_fields_ = [
("size", wt.UINT),
("mask", wt.UINT),
("min", ctypes.c_int),
("max", ctypes.c_int),
("page", wt.UINT),
("pos", ctypes.c_int),
("track", ctypes.c_int),
]
class SCROLLBARINFO(ctypes.Structure):
_fields_ = [
("size", wt.DWORD),
("rect", RECT),
("line", ctypes.c_int),
("top", ctypes.c_int),
("bottom", ctypes.c_int),
("reserved", ctypes.c_int),
("states", wt.DWORD * 6),
]
profile = Path(os.environ["APPDATA"]) / "com.samwstu.ttree/settings.json"
before = profile.read_bytes() if profile.exists() else None
try:
proc = subprocess.Popen(
[str(exe)],
stdout=(out / "stdout.log").open("w"),
stderr=(out / "stderr.log").open("w"),
)
app = Application(backend="win32").connect(process=proc.pid, timeout=15)
win = app.window(class_name="TTreePureRustWin32MainClass", visible_only=False)
win.wait("exists", timeout=10)
root_hwnd = win.handle
for _ in range(6):
assert not u.IsWindowVisible(win.handle)
time.sleep(0.05)
report["checks"].append("startup_layout_keeps_hidden_window_hidden")
u.SendMessageW(win.handle, 0x111, 901, 0)
win.wait("visible enabled ready", timeout=10)
u.SendMessageW(win.handle, 0x111, 105, 0)
win.set_focus()
def c(i):
return win.child_window(control_id=i, visible_only=False)
def shot():
dwm.DwmFlush()
r = win.rectangle()
return ImageGrab.grab(bbox=(r.left, r.top, r.right, r.bottom))
hover = {}
for ident in [104, 105, 103, 507]:
r = c(ident).rectangle()
u.SetCursorPos(r.left + 8, r.top + 8)
time.sleep(0.08)
frames = []
for i in range(60):
u.SetCursorPos(r.left + 8 + i % 12, r.top + 8 + i % 5)
f = ImageGrab.grab(bbox=(r.left, r.top, r.right, r.bottom))
frames.append(f)
hashes = {hashlib.sha256(f.tobytes()).hexdigest() for f in frames}
hover[str(ident)] = len(hashes)
frames[-1].save(out / f"hover-{ident}.png")
assert len(hashes) == 1, (
f"button {ident} changed while pointer stayed inside: {len(hashes)} frames"
)
report["hover_distinct_frames"] = hover
report["checks"].append("240_hover_frames_remain_stable_inside_each_button")
c(103).click_input()
c(503).click_input()
win.move_window(width=480, height=440)
for i, ident in enumerate([417, 418, 419, 420, 421]):
c(ident).set_edit_text("Ctrl+Shift+" + str(i + 1))
view = c(508)
vr = view.rectangle()
u.SetCursorPos(vr.right - 28, vr.top + 35)
time.sleep(0.1)
def scrollinfo():
s = SCROLLINFO()
s.size = ctypes.sizeof(s)
s.mask = 0x17
assert u.GetScrollInfo(view.handle, 1, ctypes.byref(s))
return s
def canonical_difference():
natural = shot()
u.RedrawWindow(win.handle, None, None, 0x585)
reference = shot()
diff = ImageChops.difference(natural, reference)
return sum(1 for p in diff.getdata() if max(p[:3]) > 8), natural, reference
diffs = []
frames = []
for cycle in range(8):
for i in range(12):
delta = -120 if cycle % 2 == 0 else 120
u.SendMessageW(view.handle, 0x20A, (delta & 0xFFFF) << 16, 0)
if i in [0, 5, 11]:
frames.append(shot())
changed, natural, reference = canonical_difference()
diffs.append(changed)
if changed:
natural.save(out / f"cycle-{cycle}-natural.png")
reference.save(out / f"cycle-{cycle}-reference.png")
assert max(diffs) == 0, f"scroll left stale pixels: {diffs}"
report["scroll_pixels_changed_by_forced_redraw"] = diffs
report["checks"].append("96_wheel_steps_leave_no_stale_pixels")
# A wheel event over a normal field must move the settings viewport, retaining its value.
u.SendMessageW(view.handle, 0x115, 6, 0)
start = scrollinfo().pos
field = c(417)
text = field.window_text()
u.SendMessageW(field.handle, 0x20A, (0xFF88 << 16), 0)
assert scrollinfo().pos > start and field.window_text() == text
report["checks"].append("wheel_over_field_scrolls_page_without_changing_value")
# Drag the actual native scrollbar thumb, exercising WM_VSCROLL/SIF_TRACKPOS.
u.SendMessageW(view.handle, 0x115, 6, 0)
bar = SCROLLBARINFO()
bar.size = ctypes.sizeof(bar)
assert u.GetScrollBarInfo(view.handle, ctypes.c_long(-5), ctypes.byref(bar))
x = (bar.rect.left + bar.rect.right) // 2
y = bar.rect.top + (bar.top + bar.bottom) // 2
target = min(bar.rect.bottom - bar.line - 4, y + 90)
mouse.press(coords=(x, y))
for i in range(1, 11):
u.SetCursorPos(x, y + (target - y) * i // 10)
time.sleep(0.01)
mouse.release(coords=(x, target))
assert scrollinfo().pos > 0
changed, natural, reference = canonical_difference()
report["thumb_drag_stale_pixels"] = changed
natural.save(out / "thumb-drag.png")
assert changed == 0
report["checks"].append("native_thumb_drag_keeps_scene_and_fields_aligned")
# Moving between pages with and without scrollbars must not retain old client widths.
for _ in range(5):
c(504).click_input()
assert c(423).is_visible()
c(503).click_input()
assert c(417).is_visible()
changed, natural, reference = canonical_difference()
report["section_switch_stale_pixels"] = changed
assert changed == 0
report["checks"].append(
"section_switch_and_scrollbar_visibility_do_not_leave_ghosts"
)
# Resize through narrow/wide row breakpoints while scrolled.
for width in [380, 640, 500, 480]:
win.move_window(width=width, height=440)
u.SendMessageW(view.handle, 0x115, 7, 0)
changed, _, _ = canonical_difference()
assert changed == 0
report["checks"].append("responsive_resize_while_scrolled_remains_aligned")
# Exercise a real dropdown list; buffered owner draw must preserve popup item coordinates.
c(504).click_input()
combo = c(423)
combo.select(2)
u.SendMessageW(win.handle, 0x111, (1 << 16) | 423, combo.handle)
assert combo.selected_index() == 2
report["checks"].append("native_dropdown_selection_still_works")
u.SendMessageW(view.handle, 0x115, 6, 0)
shot().save(out / "settings.png")
c(505).click_input()
u.SendMessageW(win.handle, 0x111, 507, 0)
assert not u.IsWindowVisible(win.handle)
# Hidden relayout triggered by a settings command must not display the app unexpectedly.
u.SendMessageW(win.handle, 0x111, 904, 0)
win.wait("visible", timeout=10)
c(504).click_input()
shot().save(out / "final.png")
report["checks"].append("hide_and_reopen_still_work_after_batched_layout")
report["profile_unchanged"] = (
profile.read_bytes() if profile.exists() else None
) == before
assert report["profile_unchanged"]
report["ok"] = True
except Exception:
report["ok"] = False
report["error"] = traceback.format_exc()
if win:
try:
shot().save(out / "failure.png")
except Exception:
pass
finally:
if proc and proc.poll() is None:
if root_hwnd:
u.PostMessageW(root_hwnd, 0x111, 905, 0)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.terminate()
proc.wait(timeout=5)
report["owned_process_exited"] = proc is None or proc.poll() is not None
(out / "report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
raise SystemExit(0 if report.get("ok") else 1)
-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 }
+59
View File
@@ -0,0 +1,59 @@
param([Parameter(Mandatory=$true)][string]$Exe, [Parameter(Mandatory=$true)][string]$Output)
$ErrorActionPreference = 'Stop'
Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class TTreeSmoke {
public delegate bool EnumProc(IntPtr h, IntPtr p);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc proc, IntPtr data);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr h, System.Text.StringBuilder text, int max);
[DllImport("user32.dll", EntryPoint="GetClassLongPtrW")] public static extern IntPtr GetClassLongPtr(IntPtr h, int index);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out Rect rect);
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h, uint msg, IntPtr w, IntPtr l);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
public struct Rect { public int Left, Top, Right, Bottom; }
public static IntPtr Find(uint target) {
IntPtr found = IntPtr.Zero;
EnumWindows((h,p) => { uint pid; GetWindowThreadProcessId(h, out pid);
var name = new System.Text.StringBuilder(256); GetClassName(h, name, 256);
if (pid == target && name.ToString() == "TTreePureRustWin32MainClass") { found = h; return false; } return true;
}, IntPtr.Zero);
return found;
}
}
'@
Add-Type -AssemblyName System.Drawing
New-Item -ItemType Directory -Force $Output | Out-Null
$Process = Start-Process (Resolve-Path $Exe) -PassThru
try {
$Window = [IntPtr]::Zero
for ($i=0; $i -lt 100; $i++) {
if ($Process.HasExited) { throw "App exited with $($Process.ExitCode)" }
$Window = [TTreeSmoke]::Find($Process.Id)
if ($Window -ne [IntPtr]::Zero) { break }
Start-Sleep -Milliseconds 100
}
if ($Window -eq [IntPtr]::Zero) { throw 'Native window was not created' }
if ([TTreeSmoke]::GetClassLongPtr($Window, -14) -eq [IntPtr]::Zero) { throw 'Embedded TTREE icon did not load' }
[TTreeSmoke]::SendMessage($Window, 0x111, [IntPtr]901, [IntPtr]::Zero) | Out-Null
[TTreeSmoke]::SetForegroundWindow($Window) | Out-Null
Start-Sleep -Seconds 1
if (-not [TTreeSmoke]::IsWindowVisible($Window)) { throw 'Translation window did not show' }
$Rect = New-Object TTreeSmoke+Rect
[TTreeSmoke]::GetWindowRect($Window, [ref]$Rect) | Out-Null
$Width = $Rect.Right - $Rect.Left
$Height = $Rect.Bottom - $Rect.Top
if ($Width -lt 300 -or $Height -lt 200) { throw 'Unexpected native window dimensions' }
$Bitmap = New-Object System.Drawing.Bitmap($Width, $Height)
$Graphics = [System.Drawing.Graphics]::FromImage($Bitmap)
try {
$Graphics.CopyFromScreen($Rect.Left, $Rect.Top, 0, 0, $Bitmap.Size)
$Bitmap.Save((Join-Path (Resolve-Path $Output) 'windows.png'))
} finally { $Graphics.Dispose(); $Bitmap.Dispose() }
@{ result='passed'; pid=$Process.Id; width=$Width; height=$Height; checks=@('process alive', 'native window created', 'embedded TTREE icon loaded', 'tray action opens translation') } |
ConvertTo-Json | Set-Content (Join-Path $Output 'result.json')
} finally {
if (-not $Process.HasExited) { Stop-Process -Id $Process.Id -Force }
}