refactor: separate native platforms and automate signed dual-platform releases
This commit is contained in:
1 parent
8e14266c18
commit
8d4628aeed
101 files changed
+2165
-2611
No files matched your search
@@ -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.')
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Executable
+36
@@ -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"
|
||||
Executable
+97
@@ -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
|
||||
#
|
||||
# 密钥、加密标记或强口令缺失时给出明确提示并退出(非零),避免产出弱签名更新包。
|
||||
|
||||
File renamed without changes.
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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,请勿分享配置文件。
|
||||
Executable
+46
@@ -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())
|
||||
Executable
+53
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
untrusted comment: minisign public key 8FBFCC04F00CA414
|
||||
RWQUpAzwBMy/j3GFuJBQJ5enRuHFy0q2HJbXd0/cDxNxnPqagCVtt+yP
|
||||
File renamed without changes.
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Executable → Regular
+1
-1
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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:-}"
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user