#!/bin/bash # Native macOS build script # Uses Homebrew for dependencies and system Python set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" CONFIG="$PROJECT_DIR/.build-config.json" # Platform identifier export PLATFORM="macos" # Check we're on macOS if [[ "$OSTYPE" != "darwin"* ]]; then echo "Error: This script is for macOS only" >&2 exit 1 fi echo "=== Slopsmith Desktop macOS Build ===" echo "" # Disable electron-builder's keychain-identity auto-discovery on unsigned # builds. Without this, electron-builder picks the first codesigning # identity it finds (often an "Apple Development" cert from Xcode) and # tries to sign with it — which both produces unusable artifacts AND # fails when Slopsmith.app contains paths the Apple Development cert # can't sign. Signed CI builds set APPLE_SIGNING_IDENTITY / CSC_NAME, so # this guard only triggers for local unsigned dev builds. if [[ -z "${APPLE_SIGNING_IDENTITY:-}" && -z "${CSC_NAME:-}" && -z "${CSC_LINK:-}" ]]; then export CSC_IDENTITY_AUTO_DISCOVERY=false fi # Derive CSC_NAME (electron-builder's identity name) from # APPLE_SIGNING_IDENTITY. codesign accepts the full identity string with # "Developer ID Application:" prefix; electron-builder rejects that # prefix and wants the bare team-name + team-id form. Strip the prefix # once here so the rest of the build (sign-macos-binaries.sh and # electron-builder) can each consume the form they expect. if [[ -z "${CSC_NAME:-}" && -n "${APPLE_SIGNING_IDENTITY:-}" ]]; then export CSC_NAME="${APPLE_SIGNING_IDENTITY#Developer ID Application: }" fi # Color setup export RED='\033[0;31m' export GREEN='\033[0;32m' export YELLOW='\033[1;33m' export BLUE='\033[0;34m' export NC='\033[0m' # Source common build logic source "$SCRIPT_DIR/build-common.sh" # Platform-specific: Install system dependencies install_system_deps() { if command -v brew &>/dev/null; then PACKAGES=$(grep -v '^[[:space:]]*#' "$PROJECT_DIR/.packages/brew.txt" | grep -v '^[[:space:]]*$' | tr '\n' ' ') if [[ -n "$PACKAGES" ]]; then brew install $PACKAGES fi else echo "Error: Homebrew not found. Install from https://brew.sh" >&2 exit 1 fi } # Platform-specific: Bundle Python runtime # # Uses python-build-standalone (Astral) — a fully relocatable CPython # distribution built specifically for redistribution. Avoids every # hazard of trying to copy a Homebrew framework: no PEP 668 marker, no # install_name_tool dance, no broken site-packages symlink, sys.prefix # correctly resolves to the bundle's location at runtime. bundle_python_impl() { local config_py config_py=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$PROJECT_DIR/.build-config.json" .versions.python) local py_mm="${config_py%.*}" local arch arch=$(uname -m) local config_key case "$arch" in arm64|aarch64) config_key="python_standalone_macos_arm64" ;; x86_64) config_key="python_standalone_macos_x64" ;; *) echo "Error: unsupported macOS arch: $arch" >&2 exit 1 ;; esac local pbs_url pbs_sha pbs_url=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$PROJECT_DIR/.build-config.json" ".external.${config_key}.url") pbs_sha=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$PROJECT_DIR/.build-config.json" ".external.${config_key}.sha256") local runtime="$PROJECT_DIR/resources/python/runtime" local tarball="/tmp/cpython-${config_py}-macos-${arch}.tar.gz" mkdir -p "$PROJECT_DIR/resources/python" rm -rf "$runtime" if [[ ! -f "$tarball" ]] || ! shasum -a 256 "$tarball" | awk '{print $1}' | grep -qx "$pbs_sha"; then echo " Downloading python-build-standalone ${config_py} (${arch})" curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors "$pbs_url" -o "$tarball" fi local actual_sha actual_sha=$(shasum -a 256 "$tarball" | awk '{print $1}') if [[ "$actual_sha" != "$pbs_sha" ]]; then echo "Error: python-build-standalone tarball SHA256 mismatch" >&2 echo " expected: $pbs_sha" >&2 echo " got: $actual_sha" >&2 exit 1 fi # PBS tarballs extract to a top-level `python/` dir; rename to # `runtime` so the rest of the build (and python.ts) finds the # interpreter at resources/python/runtime/bin/python3. local extract_dir="/tmp/pbs-extract-$$" rm -rf "$extract_dir" mkdir -p "$extract_dir" tar -xzf "$tarball" -C "$extract_dir" mv "$extract_dir/python" "$runtime" rm -rf "$extract_dir" # PBS tarballs ship a working pip pre-installed in the bundle's # site-packages, and `bin/python3` is a real binary (not a symlink), # so the install is fully relocatable as-is. # # Install slopsmith's runtime requirements first (single source of # truth — drift used to silently break desktop builds whenever # slopsmith added a dep), then desktop-only extras. SLOPSMITH_DIR # is exported by clone_slopsmith() in build-common.sh; fall back # to local-dev paths to match bundle-slopsmith.sh's discovery so # this script works outside CI too. if [[ -z "${SLOPSMITH_DIR:-}" ]]; then if [[ -d "$PROJECT_DIR/../slopsmith" ]]; then SLOPSMITH_DIR="$PROJECT_DIR/../slopsmith" elif [[ -d "$HOME/Repositories/slopsmith" ]]; then SLOPSMITH_DIR="$HOME/Repositories/slopsmith" fi fi if [[ -z "${SLOPSMITH_DIR:-}" ]] || [[ ! -f "$SLOPSMITH_DIR/requirements.txt" ]]; then echo "ERROR: slopsmith requirements.txt not found (SLOPSMITH_DIR=${SLOPSMITH_DIR:-})." >&2 echo " Expected SLOPSMITH_DIR to be exported by clone_slopsmith() in build-common.sh," >&2 echo " or slopsmith cloned next to this repo." >&2 exit 1 fi "$runtime/bin/python3" -m pip install --quiet --no-cache-dir \ -r "$SLOPSMITH_DIR/requirements.txt" 2>&1 | tail -5 "$runtime/bin/python3" -m pip install --quiet --no-cache-dir \ -r "$PROJECT_DIR/.packages/python.txt" 2>&1 | tail -5 } # Platform-specific: Return expected artifact patterns get_expected_artifacts() { # mac.target is "dir": electron-builder writes the unpacked # .app to release/mac-arm64/ (no .dmg/.zip). Velopack's # pack step turns that .app into the actual release assets. Glob # mac*/*.app so the check matches the productName-derived bundle name # (now feedback.app, not the pre-rebrand Slopsmith.app) and also passes # for an x64 (mac/) or universal (mac-universal/) local build. printf "%s\n" "$PROJECT_DIR/release/mac*/*.app" } # Platform-specific: Bundle system binaries bundle_binaries_impl() { # macOS: copy existing binaries and bundle dependencies # ffmpeg + ffprobe (static builds, NOT brew's ffmpeg). # # Homebrew's stock `ffmpeg` formula (8.1.1+) no longer ships # --enable-libvorbis. Sloppak conversion encodes .ogg with # `-c:a libvorbis`, so a brew-ffmpeg-bundled desktop app silently # degrades to the built-in `vorbis -strict experimental` encoder on # user machines. Pull the matching arch's static build instead: # - osxexperts.net for arm64 (Apple Silicon) # - evermeet.cx for x86_64 (Intel) # Both ship with --enable-libvorbis; URLs + SHA256 pins live in # .build-config.json so an upstream rebuild surfaces as a SHA # mismatch rather than a silent codec change. demucs needs ffprobe # alongside ffmpeg (it reads stream metadata before invoking the # encoder), so we download both from the same provider. local arch ff_key fp_key arch=$(uname -m) case "$arch" in arm64|aarch64) ff_key="ffmpeg_macos_arm64"; fp_key="ffprobe_macos_arm64" ;; x86_64) ff_key="ffmpeg_macos_x64"; fp_key="ffprobe_macos_x64" ;; *) echo "Error: unsupported macOS arch: $arch" >&2 exit 1 ;; esac local bin_dir="$PROJECT_DIR/resources/bin" mkdir -p "$bin_dir" download_and_install_macos_ffmpeg_tool() { # $1 = tool name (ffmpeg|ffprobe), $2 = config key local tool="$1" key="$2" local url sha tarball url=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" ".external.${key}.url") sha=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" ".external.${key}.sha256") tarball="/tmp/${tool}-macos-${arch}.zip" if [[ ! -f "$tarball" ]] || ! shasum -a 256 "$tarball" | awk '{print $1}' | grep -qx "$sha"; then echo " Downloading $tool from $url" curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors "$url" -o "$tarball" fi local actual_sha actual_sha=$(shasum -a 256 "$tarball" | awk '{print $1}') if [[ "$actual_sha" != "$sha" ]]; then echo "Error: $tool zip SHA256 mismatch — upstream rebuilt under the same URL" >&2 echo " expected: $sha" >&2 echo " got: $actual_sha" >&2 echo " url: $url" >&2 echo "Update .external.${key}.sha256 in .build-config.json after verifying the new binary." >&2 exit 1 fi local extract_dir="/tmp/${tool}-extract-$$" rm -rf "$extract_dir" mkdir -p "$extract_dir" # `find` after unzip tolerates either layout (osxexperts puts # the binary at the zip root, evermeet does too at the moment, # but the spec doesn't promise it). The osxexperts zip also # ships __MACOSX/._* resource forks alongside the binary; the # path filter avoids picking those up as the result. unzip -q -o "$tarball" -d "$extract_dir" local found found=$(find "$extract_dir" -type f -name "$tool" -not -path '*/__MACOSX/*' | head -1) if [[ -z "$found" ]]; then echo "Error: '$tool' binary not found after unzipping $tarball — upstream layout may have changed." >&2 exit 1 fi cp "$found" "$bin_dir/$tool" chmod +x "$bin_dir/$tool" # Static builds from third-party sites carry the macOS quarantine # xattr by default; clear it so the binary can be exec'd by the # build steps that follow (sign-macos-binaries.sh would also # strip this, but verify_bundled_binaries runs the binary first). xattr -d com.apple.quarantine "$bin_dir/$tool" 2>/dev/null || true rm -rf "$extract_dir" } download_and_install_macos_ffmpeg_tool ffmpeg "$ff_key" download_and_install_macos_ffmpeg_tool ffprobe "$fp_key" # Sloppak conversion encodes .ogg with -c:a libvorbis. Verify the # downloaded ffmpeg actually has the encoder — both osxexperts and # evermeet build with --enable-libvorbis today, but pinning by SHA # already catches binary drift; this is the runtime guarantee. The # lib/sloppak_convert.py fallback is a safety net for unbundled # installs, not a license to ship a libvorbis-less binary. if ! "$bin_dir/ffmpeg" -hide_banner -encoders 2>/dev/null | grep -wq libvorbis; then echo "Error: bundled ffmpeg lacks libvorbis encoder. Sloppak conversion would fall back to the lower-quality built-in vorbis encoder on user machines." >&2 echo "The pinned static build for arch=$arch ($ff_key) should include --enable-libvorbis; if it doesn't, pick a different upstream and update .build-config.json." >&2 exit 1 fi # Apple Silicon only: the native arm64 ffmpeg static builds (osxexperts, # martin-riedl) omit --enable-librubberband, so Retune's pitch-shift step # has no `rubberband` filter and fails with "Filter not found". Bundle the # Intel evermeet ffmpeg (which HAS rubberband) as `ffmpeg-rubberband`; # lib/retune.py prefers it for that one step and it runs under Rosetta 2. # Everything else keeps using the native arm64 ffmpeg (no Rosetta needed). if [[ "$arch" == "arm64" || "$arch" == "aarch64" ]]; then local rb_url rb_sha rb_zip rb_extract rb_found rb_actual rb_url=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" ".external.ffmpeg_macos_rubberband.url") rb_sha=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" ".external.ffmpeg_macos_rubberband.sha256") rb_zip="/tmp/ffmpeg-rubberband-macos.zip" if [[ ! -f "$rb_zip" ]] || ! shasum -a 256 "$rb_zip" | awk '{print $1}' | grep -qx "$rb_sha"; then echo " Downloading ffmpeg-rubberband (Intel) from $rb_url" curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors "$rb_url" -o "$rb_zip" fi rb_actual=$(shasum -a 256 "$rb_zip" | awk '{print $1}') if [[ "$rb_actual" != "$rb_sha" ]]; then echo "Error: ffmpeg-rubberband zip SHA256 mismatch — upstream rebuilt under the same URL" >&2 echo " expected: $rb_sha" >&2 echo " got: $rb_actual" >&2 echo " url: $rb_url" >&2 echo "Update .external.ffmpeg_macos_rubberband.sha256 in .build-config.json after verifying the new binary." >&2 exit 1 fi rb_extract="/tmp/ffmpeg-rubberband-extract-$$" rm -rf "$rb_extract"; mkdir -p "$rb_extract" unzip -q -o "$rb_zip" -d "$rb_extract" rb_found=$(find "$rb_extract" -type f -name ffmpeg -not -path '*/__MACOSX/*' | head -1) if [[ -z "$rb_found" ]]; then echo "Error: 'ffmpeg' binary not found after unzipping $rb_zip — upstream layout may have changed." >&2 exit 1 fi cp "$rb_found" "$bin_dir/ffmpeg-rubberband" chmod +x "$bin_dir/ffmpeg-rubberband" xattr -d com.apple.quarantine "$bin_dir/ffmpeg-rubberband" 2>/dev/null || true rm -rf "$rb_extract" # Verify librubberband via the embedded `configuration:` string with # `grep -a`, NOT by running the binary: the build host may be Apple # Silicon without Rosetta 2, so executing this Intel binary could fail # even though it's correct. The config string is arch-independent. if ! grep -a -q 'enable-librubberband' "$bin_dir/ffmpeg-rubberband"; then echo "Error: bundled ffmpeg-rubberband lacks --enable-librubberband — Retune pitch-shift would still fail on Apple Silicon." >&2 echo "Pick an Intel ffmpeg build with librubberband and update .external.ffmpeg_macos_rubberband in .build-config.json." >&2 exit 1 fi # Retune re-encodes the shifted audio as OGG (vorbis); require libvorbis # here too so its output isn't downgraded to the built-in encoder. if ! grep -a -q 'enable-libvorbis' "$bin_dir/ffmpeg-rubberband"; then echo "Error: bundled ffmpeg-rubberband lacks --enable-libvorbis — Retune output OGG would use the lower-quality built-in vorbis encoder." >&2 exit 1 fi echo " ffmpeg-rubberband (Intel, for Retune via Rosetta 2) bundled and verified." fi local fluidsynth_bin fluidsynth_bin="$(command -v fluidsynth || true)" if [[ -z "$fluidsynth_bin" ]]; then echo "Error: fluidsynth not found on PATH. Install it with \`brew install fluid-synth\` (and ensure /opt/homebrew/bin is on PATH)." >&2 exit 1 fi cp "$fluidsynth_bin" "$PROJECT_DIR/resources/bin/" # vgmstream: use the local Homebrew Intel build instead of the upstream mac zip. # The upstream vgmstream-mac.zip currently gives this Intel Mac an arm64 binary, # which causes "Bad CPU type in executable" and pulls /opt/homebrew dependencies. echo -e "${BLUE}=== Using Homebrew vgmstream-cli ===${NC}" VGM_BIN="$(command -v vgmstream-cli || true)" if [[ -z "$VGM_BIN" ]]; then echo -e "${RED}ERROR: vgmstream-cli not found. Install it with: brew install vgmstream${NC}" >&2 exit 1 fi echo "Found vgmstream-cli at: $VGM_BIN" cp "$VGM_BIN" "$PROJECT_DIR/resources/bin/vgmstream-cli" chmod +x "$PROJECT_DIR/resources/bin/vgmstream-cli" echo "Copied binary details:" ls -la "$PROJECT_DIR/resources/bin/vgmstream-cli" file "$PROJECT_DIR/resources/bin/vgmstream-cli" xattr -d com.apple.quarantine "$PROJECT_DIR/resources/bin/vgmstream-cli" 2>/dev/null || true echo -e "${BLUE}=== Skipping vgmstream-cli self-test ===${NC}" # The Homebrew Intel binary is copied and architecture-checked above. # vgmstream-cli returns non-zero for its info/help modes, so don't block packaging here. echo -e "${GREEN}vgmstream-cli setup complete${NC}" # Run dylibbundler on every bundled binary so each one's brew deps # (libfluidsynth, libspeex, libmpg123, libvorbis, libogg, ffmpeg # libs, etc.) get copied into resources/bin/ and the binaries' load # commands get rewritten to @executable_path/. Without this, # vgmstream-cli (downloaded from upstream) at runtime asks dyld for # /opt/homebrew/opt/speex/lib/libspeex.1.dylib — fine on the dev # machine, fatal on every other Mac. ffmpeg has the same problem # against its own brew deps. dylibbundler is idempotent and skips # paths it has already rewritten, so the per-binary loop is safe # even when binaries share dylibs. if command -v dylibbundler &>/dev/null; then for bin in fluidsynth ffmpeg ffprobe vgmstream-cli; do local target="$PROJECT_DIR/resources/bin/$bin" [[ -f "$target" ]] || continue echo -e "${BLUE}Bundling ${bin} dependencies...${NC}" dylibbundler -cd -b -of -x "$target" \ -d "$PROJECT_DIR/resources/bin" \ -p '@executable_path/' done fi # Sign all bundled native binaries with the Developer ID Application # cert before verify_bundled_binaries runs them. Signing also clears # the macOS quarantine attribute that downloaded binaries carry, so # the verify step doesn't have to special-case quarantine. No-op # when APPLE_SIGNING_IDENTITY is unset (local dev without a cert). "$SCRIPT_DIR/sign-macos-binaries.sh" } # Run the build main "$@" # Post-build: notarize and staple the DMG. electron-builder notarizes # and staples the .app, then builds + signs the DMG — but the DMG # itself is not submitted to Apple's notary service, so it ships # unstapled. That's fine for online installs (Gatekeeper checks the # .app inside on first launch), but offline first launches and some # enterprise tools want a stapled DMG. notarytool with --wait blocks # until Apple finishes (usually 30s–3min), then stapler embeds the # ticket so the DMG verifies offline. No-op when signing was off. if [[ -n "${APPLE_SIGNING_IDENTITY:-}" && -n "${APPLE_ID:-}" \ && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" \ && -n "${APPLE_TEAM_ID:-}" ]]; then shopt -s nullglob for dmg in "$PROJECT_DIR"/release/*.dmg; do echo -e "${BLUE}Notarizing $(basename "$dmg") (wait for Apple)...${NC}" xcrun notarytool submit "$dmg" \ --apple-id "$APPLE_ID" \ --password "$APPLE_APP_SPECIFIC_PASSWORD" \ --team-id "$APPLE_TEAM_ID" \ --wait echo -e "${BLUE}Stapling notarization ticket to $(basename "$dmg")...${NC}" # `notarytool submit --wait` returns when Apple's notary service # accepts the submission, but the ticket can take an extra # 30-60 s to propagate to CloudKit (where `stapler` reads from). # Stapling immediately fails with `Error 65: Record not found` # on CI roughly half the time. Retry with backoff. staple_ok=0 for attempt in 1 2 3 4 5; do if xcrun stapler staple "$dmg"; then staple_ok=1 break fi echo " staple attempt $attempt failed; waiting before retry..." sleep $((attempt * 15)) done if [[ "$staple_ok" -ne 1 ]]; then echo -e "${RED}Failed to staple $(basename "$dmg") after 5 attempts${NC}" >&2 exit 1 fi xcrun stapler validate "$dmg" done shopt -u nullglob fi