feedBack-desktop/scripts/build-macos.sh
Byron Gamatos 7b7a5b59da
fix(build): strip dangling symlinks so macOS builds aren't "damaged" (#30)
A local macOS tester reported the DMG as "damaged and can't be opened."
Root cause: the bundled tree contained a dangling symlink — rig_builder
ships a build-time link `vst/src/racks/DPF` -> DISTRHO framework that isn't
present at runtime. A broken symlink is harmless on Linux/squashfs (the
AppImage was fine), but on macOS it:
  - breaks codesign — `spctl` reports "a sealed resource is missing or
    invalid", which Gatekeeper surfaces as "damaged"; and
  - breaks `xattr -dr com.apple.quarantine` — it aborts on the dangling
    link, so even the quarantine-removal workaround can't complete.

Strip dangling symlinks from the cloned core+plugins tree after bundling
(safe on every platform — they're broken/unused). Verified on-device:
after removal + ad-hoc re-sign the app verifies clean and launches once
quarantine is cleared.

Also fix build-macos.sh's artifact check, which still globbed the
pre-rebrand `Slopsmith.app` and falsely reported "No artifacts found" for
the now `feedback.app` bundle.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:19:59 +02:00

421 lines
20 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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
# <productName>.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 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