#!/bin/bash
|
# Aligner .app + .dmg 打包脚本
|
# 用法: 03-O/C3.tools/package-app.sh
|
# 输入: 03-O/C1.source/
|
# 输出:
|
# 03-O/C2.builds/Aligner-<Version>-build<YYYYMMDD.HHMM>/Aligner-<Version>-build<YYYYMMDD.HHMM>.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
|