Clean release snapshot

This commit is contained in:
Byron Gamatos
2026-06-16 18:48:12 +02:00
commit bd603184d5
291 changed files with 47318 additions and 0 deletions
+419
View File
@@ -0,0 +1,419 @@
#!/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:-<unset>})." >&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
# Slopsmith.app to release/mac-arm64/ (no .dmg/.zip). Velopack's
# pack step turns that .app into the actual release assets. Glob
# mac*/ so the check also passes for an x64 (mac/) or universal
# (mac-universal/) local build — verify_artifacts expands this.
printf "%s\n" "$PROJECT_DIR/release/mac*/Slopsmith.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 30s3min), 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