diff --git a/tools/package_release_macos.sh b/tools/package_release_macos.sh new file mode 100755 index 00000000..e978b26d --- /dev/null +++ b/tools/package_release_macos.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +# package_release_macos.sh — shared macOS release packager for psxrecomp games. +# +# macOS counterpart to tools/package_release.ps1. That script packages THIS +# framework's BIOS-only runtime and each game repo carries its own Windows +# packager; this one is generic, so every game repo can call it instead of +# duplicating ~200 lines of bundle/codesign/gate logic per title. +# +# It takes an already-built runtime plus the per-game identity and produces a +# signed, relocatable .app inside a staged release folder, then archives it as +# --macos-.zip with a .sha256 sidecar (the naming the +# game repos' Windows releases already use). +# +# It does NOT build. The game's build script builds, then calls this. +# +# Usage (from a game repo): +# bash psxrecomp/tools/package_release_macos.sh \ +# --repo . --build build-macos-prod \ +# --bin Crash_Bandicoot_Recompiled \ +# --app-name "Crash Bandicoot Recompiled" \ +# --package CrashBandicootRecomp \ +# --bundle-id com.mstan.crashbandicootrecomp \ +# --version v0.0.1 \ +# --game-config game.toml \ +# --require-mod crash-bandicoot.enhancement.adaptive-widescreen/1.0.0/manifest.toml +# +# Required: +# --repo PATH game repository root +# --build PATH build dir holding the built binary and CMake-staged data +# --bin NAME built binary name (the target's OUTPUT_NAME) +# +# Optional: +# --app-name NAME .app display name (default: --bin, _ -> space) +# --package NAME archive name stem (default: basename of --repo) +# --bundle-id ID CFBundleIdentifier (default: com.mstan.) +# --version VER release version, e.g. v0.0.1 (default: dev) +# --out DIR output directory (default: /release-macos) +# --game-config P game.toml to bundle, relative to repo +# --mods-src DIR reviewed catalog source (default: /mods/preloaded) +# --require-mod PKG path under mods/packages that must exist (repeatable) +# --data FILE file the runtime reads BESIDE the executable, relative to +# repo, e.g. keybinds.ini (repeatable) +# --doc FILE extra doc to ship, relative to repo (repeatable) +# --dmg / --no-dmg also build a .dmg (default: no) +# --no-zip stage and sign only, skip the archive +# +# Release gates (any failure aborts, non-zero): +# * the binary is self-contained (links only OS libraries / bundled frameworks) +# * no absolute retail-BIOS path baked into the binary +# * the 512 KiB OpenBIOS image and its MIT notice are present +# * every --require-mod package is present +# * no disc / memory-card / generated / local-state files in the stage +# * saves/ ships empty +# * the .app carries a valid signature +set -euo pipefail + +die() { echo "package_release_macos: ERROR: $*" >&2; exit 1; } +note() { echo " $*"; } + +[ "$(uname -s)" = "Darwin" ] || die "run this on macOS." + +REPO=""; BUILD=""; BIN_NAME=""; APP_NAME=""; PACKAGE=""; BUNDLE_ID="" +VERSION="dev"; OUT=""; GAME_CONFIG=""; MODS_SRC="" +DO_DMG=0; DO_ZIP=1 +REQUIRE_MODS=(); DOCS=(); DATA_FILES=() + +while [ $# -gt 0 ]; do + case "$1" in + --repo) REPO="$2"; shift 2;; + --build) BUILD="$2"; shift 2;; + --bin) BIN_NAME="$2"; shift 2;; + --app-name) APP_NAME="$2"; shift 2;; + --package) PACKAGE="$2"; shift 2;; + --bundle-id) BUNDLE_ID="$2"; shift 2;; + --version) VERSION="$2"; shift 2;; + --out) OUT="$2"; shift 2;; + --game-config) GAME_CONFIG="$2"; shift 2;; + --mods-src) MODS_SRC="$2"; shift 2;; + --require-mod) REQUIRE_MODS+=("$2"); shift 2;; + --data) DATA_FILES+=("$2"); shift 2;; + --doc) DOCS+=("$2"); shift 2;; + --dmg) DO_DMG=1; shift;; + --no-dmg) DO_DMG=0; shift;; + --no-zip) DO_ZIP=0; shift;; + -h|--help) sed -n '2,57p' "$0"; exit 0;; + *) die "unknown arg: $1";; + esac +done + +[ -n "$REPO" ] || die "--repo is required" +[ -n "$BUILD" ] || die "--build is required" +[ -n "$BIN_NAME" ] || die "--bin is required" +REPO="$(cd "$REPO" && pwd)" +[ -d "$BUILD" ] || BUILD="$REPO/$BUILD" +[ -d "$BUILD" ] || die "build dir not found: $BUILD" +BUILD="$(cd "$BUILD" && pwd)" + +BIN="$BUILD/$BIN_NAME" +[ -f "$BIN" ] || die "built binary not found: $BIN (build it before packaging)" + +[ -n "$APP_NAME" ] || APP_NAME="${BIN_NAME//_/ }" +[ -n "$PACKAGE" ] || PACKAGE="$(basename "$REPO")" +[ -n "$BUNDLE_ID" ] || BUNDLE_ID="com.mstan.$(echo "$PACKAGE" | tr '[:upper:]' '[:lower:]')" +[ -n "$OUT" ] || OUT="$REPO/release-macos" +[ -n "$MODS_SRC" ] || MODS_SRC="$REPO/mods/preloaded" + +# Label the archive with what the binary actually contains, not what was asked +# for: a --arch universal build that silently produced a thin slice must not +# ship claiming to be universal. +ARCHS="$(lipo -archs "$BIN" 2>/dev/null || echo unknown)" +case "$ARCHS" in + *arm64*x86_64*|*x86_64*arm64*) ARCH_LABEL="universal";; + *arm64*) ARCH_LABEL="arm64";; + *x86_64*) ARCH_LABEL="x86_64";; + *) ARCH_LABEL="$ARCHS";; +esac + +STAGE_ROOT="$OUT/stage" +STAGE="$STAGE_ROOT/$PACKAGE-macos-$ARCH_LABEL" +APPDIR="$STAGE/$APP_NAME.app" + +echo "==== packaging $APP_NAME $VERSION (macOS $ARCH_LABEL) ====" +note "binary: $BIN ($ARCHS)" + +# The stage is a fixed child of the output dir. Verify that before rm -rf. +case "$STAGE_ROOT" in + "$OUT"/*) ;; + *) die "refusing to clean a stage outside the output dir: $STAGE_ROOT";; +esac +rm -rf "$STAGE_ROOT" +mkdir -p "$APPDIR/Contents/MacOS" "$APPDIR/Contents/Resources" \ + "$APPDIR/Contents/Frameworks" "$STAGE/saves" + +cp "$BIN" "$APPDIR/Contents/MacOS/$BIN_NAME" + +# ---- staged data ------------------------------------------------------------ +# The runtime resolves bios/, assets/ and mods/ beside the REAL EXECUTABLE, so +# they must be reachable from Contents/MacOS. They cannot literally live there: +# codesign treats every non-executable under Contents/MacOS as a nested-code +# candidate and rejects the whole bundle ("bundle format unrecognized, invalid, +# or unsuitable") — mods/packages/// parses as a versioned bundle, +# assets/img/*.tga as stray nested code. So data lives in Contents/Resources and +# Contents/MacOS carries symlinks: codesign seals a symlink as a symlink instead +# of descending into it, and runtime path resolution is unchanged. +stage_data() { # stage_data + local name="$1" src="$2" + [ -e "$src" ] || die "expected staged data missing: $src" + rm -rf "$APPDIR/Contents/Resources/$name" "$APPDIR/Contents/MacOS/$name" + cp -a "$src" "$APPDIR/Contents/Resources/$name" + ln -s "../Resources/$name" "$APPDIR/Contents/MacOS/$name" +} + +stage_data bios "$BUILD/bios" +stage_data assets "$BUILD/assets" + +# Take the mod catalog from SOURCE, never the build tree: a developer build dir +# accumulates mods/state.toml with enhancements switched on, and shipping that +# silently flips a title's default presentation for every player. +if [ -d "$MODS_SRC" ]; then + stage_data mods "$MODS_SRC" +elif [ -d "$BUILD/mods" ]; then + die "refusing to package mods/ from the build tree ($BUILD/mods): pass --mods-src pointing at the reviewed source catalog" +fi + +if [ -n "$GAME_CONFIG" ]; then + stage_data "$(basename "$GAME_CONFIG")" "$REPO/$GAME_CONFIG" +fi + +# Files the runtime reads beside the executable (keybinds.ini and friends) get +# the same Resources+symlink treatment as bios/ and assets/. +for f in "${DATA_FILES[@]}"; do + stage_data "$(basename "$f")" "$REPO/$f" +done + +for doc in "${DOCS[@]}"; do + if [ -f "$REPO/$doc" ]; then + cp "$REPO/$doc" "$APPDIR/Contents/Resources/$(basename "$doc")" + fi +done +# Surface the player-facing docs beside the .app too, so they are readable +# without opening the bundle. Mirrors the flat Windows package layout. +for doc in START_HERE.txt README.md LICENSE RELEASE_NOTES.md; do + if [ -f "$REPO/$doc" ]; then + cp "$REPO/$doc" "$STAGE/$doc" + fi +done + +# ---- launcher shim ---------------------------------------------------------- +# The real binary sits behind a shim so memory cards, settings and overlay +# caches land in the folder holding the .app (user-visible, writable, and the +# same place saves/ ships) rather than inside the bundle, which is signed and +# may be mounted read-only. --game takes an absolute in-bundle path so it +# resolves regardless of that working directory. +GAME_ARG="" +if [ -n "$GAME_CONFIG" ]; then + GAME_ARG="--game \"\$DIR/$(basename "$GAME_CONFIG")\"" +fi +cat > "$APPDIR/Contents/MacOS/$APP_NAME" </dev/null || true +if [ "\$#" -eq 0 ]; then + exec "\$DIR/$BIN_NAME" $GAME_ARG +fi +exec "\$DIR/$BIN_NAME" "\$@" +EOF +chmod +x "$APPDIR/Contents/MacOS/$APP_NAME" + +SHORT_VERSION="${VERSION#v}" +cat > "$APPDIR/Contents/Info.plist" < + + + CFBundleName$APP_NAME + CFBundleDisplayName$APP_NAME + CFBundleIdentifier$BUNDLE_ID + CFBundleExecutable$APP_NAME + CFBundlePackageTypeAPPL + CFBundleVersion$SHORT_VERSION + CFBundleShortVersionString$SHORT_VERSION + LSMinimumSystemVersion11.0 + NSHighResolutionCapable + +EOF + +# SDL3 is fetched and linked statically by runtime.cmake, so a default build has +# no non-system dylib to relocate. Builds pointed at a system SDL +# (-DPSX_SDL3_FETCH=OFF, -DPSX_SDL_BACKEND=SDL2) do; dylibbundler handles those +# and is a no-op otherwise. The self-containment gate below is what actually +# decides whether the result is shippable. +if command -v dylibbundler >/dev/null 2>&1; then + dylibbundler -od -b -x "$APPDIR/Contents/MacOS/$BIN_NAME" \ + -d "$APPDIR/Contents/Frameworks" -p @executable_path/../Frameworks >/dev/null 2>&1 || true +fi + +# ---- signing ---------------------------------------------------------------- +# Ad-hoc, so it runs locally without a Developer ID. NOT --deep: it descends +# into the staged data and fails on the mod catalog's versioned directories. +# Sign the only Mach-O explicitly, then the bundle. +codesign --force --sign - "$APPDIR/Contents/MacOS/$BIN_NAME" 2>/dev/null +codesign --force --sign - "$APPDIR" 2>/dev/null +codesign --verify --deep "$APPDIR" 2>/dev/null \ + || die "ad-hoc codesign failed; the .app will not launch cleanly" +note "signed and verified (ad-hoc)" + +# ---- release gates ---------------------------------------------------------- +echo "---- gates ----" + +# 1. Self-containment. Anything outside the OS or the bundle's own Frameworks +# means the .app only runs on a machine that happens to have that library. +BAD_LINKS="$(otool -L "$APPDIR/Contents/MacOS/$BIN_NAME" | tail -n +2 | awk '{print $1}' \ + | grep -vE '^(/usr/lib/|/System/Library/|@executable_path/../Frameworks/|@rpath/)' || true)" +[ -z "$BAD_LINKS" ] || die "binary is not self-contained; links: $(echo "$BAD_LINKS" | tr '\n' ' ')" +note "self-contained (links only OS libraries)" + +# 2. No baked absolute retail-BIOS path. One baked into the binary makes it +# silently load the BUILDER'S BIOS wherever that path exists, so the clean +# OpenBIOS-by-default path never gets exercised where releases are validated. +# +# Match only genuinely rooted paths. The Windows gate keys on a drive letter +# ("C:\..."); the macOS tell is a path rooted in a real user or volume +# location. Two things must NOT trip this: the correct relative default +# "bios/SCPH1001.BIN", and the usage-message placeholder +# "/path/to/SCPH1001.BIN" — both are expected in every release binary. +ROOTED='/(Users|home|private|var|tmp|opt|Volumes)/' +BAKED="$(strings -a "$APPDIR/Contents/MacOS/$BIN_NAME" \ + | grep -oE "${ROOTED}[^ \"']*SCPH[0-9]*\.BIN" | sort -u || true)" +[ -z "$BAKED" ] || die "binary contains baked absolute BIOS path(s): $(echo "$BAKED" | tr '\n' ' ')" + +# Same failure class, wider net: any path into the build machine's home +# directory that survived into the binary is a leak (and often a privacy +# problem in a public release). +HOME_LEAK="$(strings -a "$APPDIR/Contents/MacOS/$BIN_NAME" \ + | grep -oE "${HOME}/[^ \"']*" | sort -u | head -5 || true)" +[ -z "$HOME_LEAK" ] || die "binary contains baked build-machine path(s): $(echo "$HOME_LEAK" | tr '\n' ' ')" +note "no baked absolute BIOS or build-machine path" + +# 3. Bundled OpenBIOS: redistributable and required; retail images are not. +OPENBIOS="$APPDIR/Contents/Resources/bios/openbios.bin" +[ -f "$OPENBIOS" ] || die "bundled OpenBIOS image missing" +[ "$(stat -f%z "$OPENBIOS")" = "524288" ] || die "OpenBIOS image is not 512 KiB" +[ -f "$APPDIR/Contents/Resources/bios/OpenBIOS.LICENSE" ] || die "OpenBIOS MIT notice missing" +note "bundled OpenBIOS (512 KiB) + MIT notice" + +# 4. Required mod packages. A silently mod-less launcher looks fine but has no +# Mods page at all, which is indistinguishable from the feature being cut. +for m in "${REQUIRE_MODS[@]}"; do + [ -f "$APPDIR/Contents/Resources/mods/packages/$m" ] \ + || die "required mod package missing: mods/packages/$m" +done +[ ${#REQUIRE_MODS[@]} -eq 0 ] || note "mod catalog: ${#REQUIRE_MODS[@]} required package(s) present" + +# 5. Nothing from the developer's machine or the copyrighted source material. +STRAY="$(find "$STAGE" \( \ + -iname '*.cue' -o -iname '*.iso' -o -iname '*.chd' -o -iname '*.mcd' \ + -o -iname '*.mcr' -o -iname 'SCPH*.BIN' -o -iname 'settings.toml' \ + -o -iname 'state.toml' -o -iname 'overlay_captures.json' \ + -o -iname 'bios.cfg' -o -iname 'disc.cfg' \) -print 2>/dev/null || true)" +[ -z "$STRAY" ] || die "files that must never ship entered the stage: +$STRAY" +# Every .bin except the bundled OpenBIOS is suspect (disc images, BIOS dumps). +STRAY_BIN="$(find "$STAGE" -iname '*.bin' ! -path "$OPENBIOS" -print 2>/dev/null || true)" +[ -z "$STRAY_BIN" ] || die "unexpected .bin in stage (only openbios.bin may ship): +$STRAY_BIN" +STRAY_GEN="$(find "$STAGE" -path '*/generated/*' -print 2>/dev/null | head -1 || true)" +[ -z "$STRAY_GEN" ] || die "generated recompiler output entered the stage: $STRAY_GEN" +note "no disc / retail BIOS / generated / local-state files" + +# 6. saves/ ships empty so a player's first run starts clean. +SAVED="$(find "$STAGE/saves" -type f -print 2>/dev/null || true)" +[ -z "$SAVED" ] || die "saves/ must ship empty, contains: +$SAVED" +note "saves/ empty" + +echo "---- gates passed ----" +note "BUILT: $APPDIR" + +# ---- archive ---------------------------------------------------------------- +if [ "$DO_ZIP" = "1" ]; then + ZIP="$OUT/$PACKAGE-$VERSION-macos-$ARCH_LABEL.zip" + rm -f "$ZIP" "$ZIP.sha256" + # ditto, not zip: it preserves the symlinks and the code signature so the + # unpacked .app still verifies. A plain `zip` can flatten both. + ( cd "$STAGE_ROOT" && ditto -c -k --sequesterRsrc --keepParent \ + "$(basename "$STAGE")" "$ZIP" ) + ( cd "$OUT" && shasum -a 256 "$(basename "$ZIP")" > "$(basename "$ZIP").sha256" ) + note "BUILT: $ZIP" + note "SHA-256: $(awk '{print $1}' "$ZIP.sha256")" +fi + +if [ "$DO_DMG" = "1" ]; then + DMG="$OUT/$PACKAGE-$VERSION-macos-$ARCH_LABEL.dmg" + rm -f "$DMG" + if command -v create-dmg >/dev/null 2>&1; then + create-dmg --volname "$APP_NAME" --app-drop-link 420 180 "$DMG" "$APPDIR" \ + || hdiutil create -volname "$APP_NAME" -srcfolder "$STAGE" -ov -format UDZO "$DMG" + else + hdiutil create -volname "$APP_NAME" -srcfolder "$STAGE" -ov -format UDZO "$DMG" + fi + note "BUILT: $DMG" +fi