#!/bin/bash # Aligner .app + .dmg 打包脚本 # 用法: 03-O/C3.tools/package-app.sh # 输入: 03-O/C1.source/ # 输出: # 03-O/C2.builds/Aligner--build/Aligner--build.dmg # 03-O/C2.builds/Z-研发中间产物/current/Aligner.app set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" SOURCE_ROOT="$OUTPUT_ROOT/C1.source" BUILD_ROOT="$OUTPUT_ROOT/C2.builds" # shellcheck source=build-output-paths.sh source "$SCRIPT_DIR/build-output-paths.sh" APP="$BUILD_DEFAULT_CURRENT_APP" INFO_PLIST="$SOURCE_ROOT/Resources/Aligner-Info.plist" ICON_FILE="$SOURCE_ROOT/Resources/AppIcon.icns" LOCK_DIR="$BUILD_LOCK_ROOT/package-app.lock" DMG_NAME="" DMG="" DMG_TMP="" RELEASE_NAME="" RELEASE_DIR="" OPEN_AFTER_BUILD="${ALIGNER_OPEN_AFTER_BUILD:-0}" LOCK_HELD=0 DMG_STAGING="" fail() { echo "package-app.sh failed: $*" >&2 exit 1 } run_capped() { local log log="$(mktemp)" if "$@" >"$log" 2>&1; then head -c 6000 "$log" rm -f "$log" else local code="$?" head -c 6000 "$log" >&2 rm -f "$log" return "$code" fi } read_release_metadata() { local app_version local app_build app_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$INFO_PLIST" 2>/dev/null)" \ || fail "unable to read CFBundleShortVersionString from $INFO_PLIST" app_build="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$INFO_PLIST" 2>/dev/null)" \ || fail "unable to read CFBundleVersion from $INFO_PLIST" if [[ ! "$app_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then fail "CFBundleShortVersionString must be MAJOR.MINOR.PATCH, got: $app_version" fi if [[ ! "$app_build" =~ ^[0-9]{8}\.[0-9]{4}$ ]]; then fail "CFBundleVersion must be YYYYMMDD.HHMM, got: $app_build" fi DMG_NAME="Aligner-${app_version}-build${app_build}.dmg" RELEASE_NAME="Aligner-${app_version}-build${app_build}" RELEASE_DIR="$BUILD_ROOT/$RELEASE_NAME" DMG="$RELEASE_DIR/$DMG_NAME" DMG_TMP="$BUILD_TMP_ROOT/.${DMG_NAME}.tmp.dmg" } read_release_metadata unique_archive_path() { local target_dir="$1" local name="$2" local candidate="$target_dir/$name" local suffix local counter=0 if [ ! -e "$candidate" ]; then printf '%s\n' "$candidate" return fi suffix="$(date +%Y%m%d_%H%M%S)" if [[ "$name" == *.* ]]; then local stem="${name%.*}" local ext="${name##*.}" candidate="$target_dir/${stem}_archived_${suffix}.${ext}" while [ -e "$candidate" ]; do counter=$((counter + 1)) candidate="$target_dir/${stem}_archived_${suffix}_${counter}.${ext}" done else candidate="$target_dir/${name}_archived_${suffix}" while [ -e "$candidate" ]; do counter=$((counter + 1)) candidate="$target_dir/${name}_archived_${suffix}_${counter}" done fi printf '%s\n' "$candidate" } archive_path() { local src="$1" local target_dir="$2" local name local dest [ -e "$src" ] || return mkdir -p "$target_dir" name="$(basename "$src")" if [ -d "$src" ] && [ -e "$target_dir/$name" ]; then local suffix local counter=0 suffix="$(date +%Y%m%d_%H%M%S)" dest="$target_dir/${name}_archived_${suffix}" while [ -e "$dest" ]; do counter=$((counter + 1)) dest="$target_dir/${name}_archived_${suffix}_${counter}" done else dest="$(unique_archive_path "$target_dir" "$name")" fi mv "$src" "$dest" } archive_existing_root_outputs() { local old_dir local old_dmg for old_dir in "$BUILD_ROOT"/Aligner-*-build*; do [ -d "$old_dir" ] || continue archive_path "$old_dir" "$BUILD_OLD_RELEASE_ROOT" done for old_dmg in "$BUILD_ROOT"/Aligner_*.dmg "$BUILD_ROOT"/Aligner-*.dmg; do [ -f "$old_dmg" ] || continue archive_path "$old_dmg" "$BUILD_OLD_RELEASE_ROOT/历史散包" done if [ -d "$BUILD_ROOT/archives" ]; then local archived_dmg for archived_dmg in "$BUILD_ROOT/archives"/*.dmg; do [ -f "$archived_dmg" ] || continue archive_path "$archived_dmg" "$BUILD_OLD_RELEASE_ROOT/历史散包" done rmdir "$BUILD_ROOT/archives" 2>/dev/null || archive_path "$BUILD_ROOT/archives" "$BUILD_OLD_RELEASE_ROOT" fi } clean_legacy_root_clutter() { local item if [ -d "$BUILD_ROOT/Aligner.app" ]; then archive_path "$BUILD_ROOT/Aligner.app" "$BUILD_WORK_ROOT/legacy-root-app" fi for item in "$BUILD_ROOT"/round01-*; do [ -e "$item" ] || continue archive_path "$item" "$BUILD_REPORT_ROOT" done for item in "$BUILD_ROOT"/icon-family-preview.png "$BUILD_ROOT"/icon-size-preview.png "$BUILD_ROOT"/icon-options; do [ -e "$item" ] || continue archive_path "$item" "$BUILD_ICON_WORK_ROOT" done rm -f "$BUILD_ROOT/.DS_Store" } assert_safe_package_paths() { if [ "$APP" != "$BUILD_DEFAULT_CURRENT_APP" ]; then fail "package-app.sh refuses to build outside $BUILD_DEFAULT_CURRENT_APP" fi case "$RELEASE_DIR" in "$BUILD_ROOT"/Aligner-*-build*) ;; *) fail "release directory must be under $BUILD_ROOT, got: $RELEASE_DIR" ;; esac case "$DMG_TMP" in "$BUILD_TMP_ROOT"/.*.tmp.dmg) ;; *) fail "temporary DMG must be under $BUILD_TMP_ROOT, got: $DMG_TMP" ;; esac } assert_clean_build_root() { local item local name local found=0 for item in "$BUILD_ROOT"/* "$BUILD_ROOT"/.[!.]* "$BUILD_ROOT"/..?*; do [ -e "$item" ] || continue name="$(basename "$item")" case "$name" in "$RELEASE_NAME"|X-旧版build成果|Z-研发中间产物) ;; .DS_Store) rm -f "$item" ;; *) echo "unexpected C2.builds root entry: $item" >&2 found=1 ;; esac done [ "$found" -eq 0 ] || fail "C2.builds root must contain only $RELEASE_NAME, X-旧版build成果, Z-研发中间产物" } acquire_lock() { if ! mkdir "$LOCK_DIR" 2>/dev/null; then fail "another package-app.sh appears to be running; run packaging QA scripts serially" fi LOCK_HELD=1 } release_lock() { if [ "$LOCK_HELD" -eq 1 ]; then rmdir "$LOCK_DIR" 2>/dev/null || true LOCK_HELD=0 fi } cleanup() { if [ -n "$DMG_STAGING" ]; then rm -rf "$DMG_STAGING" fi rm -f "$DMG_TMP" release_lock } acquire_lock trap cleanup EXIT assert_safe_package_paths cd "$SOURCE_ROOT" if ! run_capped swift build -c debug; then fail "swift build failed" fi rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp .build/debug/Aligner "$APP/Contents/MacOS/Aligner" cp Resources/Aligner-Info.plist "$APP/Contents/Info.plist" if [ ! -f "$ICON_FILE" ]; then fail "missing AppIcon.icns; run $OUTPUT_ROOT/C3.tools/generate-app-icon.py" fi cp "$ICON_FILE" "$APP/Contents/Resources/AppIcon.icns" plutil -lint "$APP/Contents/Info.plist" IDENTITIES="$(security find-identity -v -p codesigning 2>/dev/null || true)" SIGN_IDENTITY="${ALIGNER_CODE_SIGN_IDENTITY:-}" if [ -z "$SIGN_IDENTITY" ]; then if echo "$IDENTITIES" | grep -Fq '"Aligner Local Code Signing"'; then SIGN_IDENTITY="Aligner Local Code Signing" else SIGN_IDENTITY="$(echo "$IDENTITIES" | sed -n 's/.*"\([^"]*Local Code Signing\)".*/\1/p' | head -n 1)" fi fi if [ -n "$SIGN_IDENTITY" ]; then codesign --force --sign "$SIGN_IDENTITY" "$APP" echo "Signed with: $SIGN_IDENTITY" else codesign --force --sign - "$APP" echo "Signed with: ad-hoc (TCC permissions may reset after rebuilds)" fi codesign -d -r- "$APP" 2>&1 | sed 's/^/Code requirement: /' echo "✅ $APP built successfully" rm -f "$DMG_TMP" DMG_STAGING="$(mktemp -d)" cp -R "$APP" "$DMG_STAGING/" ln -s /Applications "$DMG_STAGING/Applications" run_capped hdiutil create \ -volname "Aligner" \ -srcfolder "$DMG_STAGING" \ -ov \ -format UDZO \ "$DMG_TMP" run_capped hdiutil verify "$DMG_TMP" clean_legacy_root_clutter archive_existing_root_outputs rm -rf "$RELEASE_DIR" mkdir -p "$RELEASE_DIR" mv "$DMG_TMP" "$DMG" echo "✅ $DMG built successfully" (cd "$RELEASE_DIR" && shasum -a 256 "$DMG_NAME" > SHA256SUMS.txt) echo "✅ $RELEASE_DIR/SHA256SUMS.txt written" assert_clean_build_root if [ "$OPEN_AFTER_BUILD" = "1" ]; then open "$APP" else echo "Run with: open \"$APP\"" fi