feedBack-desktop/scripts/build-common.sh
Matthew Harris Glover bac5e67670 fix(build): prune cached plugin dirs no longer configured
clone_or_update's reset --hard (not clean) deliberately preserves sibling
directories, since the persistent cache needs to survive repeated
core-repo updates without losing other plugins' checkouts. But that also
meant a plugin removed or renamed in the plugins array left its old clone
behind forever — and bundle-slopsmith.sh bundles every directory under
plugins/ (globs "$SLOPSMITH_DIR/plugins/"*), so the stale clone would ship
into later local builds.

Track the dirnames this loop manages across runs in a manifest file (a
dotfile, so bundle-slopsmith.sh's glob skips it) and delete any
previously-managed dirname that's absent from the current list. Never
touches a directory that isn't in the manifest, so anything outside this
loop's control (e.g. built-ins living in the core repo's own tracked
tree) is untouched.

Flagged by coderabbitai on the PR.
2026-07-17 08:14:20 -04:00

734 lines
26 KiB
Bash

#!/bin/bash
# Common build logic for all platforms.
# Platform scripts source this file and implement four functions:
# install_system_deps() — install OS packages (apt / brew / winget)
# bundle_python_impl() — bundle Python runtime
# bundle_binaries_impl() — bundle system binaries (ffmpeg etc.)
# get_expected_artifacts() — globs verify_artifacts checks at the end
set -euo pipefail
# Check if this is being sourced by a platform script
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
echo "Error: build-common.sh should not be run directly" >&2
echo "Run ./build-release.sh instead" >&2
exit 1
fi
# Script directory must be set by sourcing script
if [[ -z "${SCRIPT_DIR:-}" ]]; then
echo "Error: SCRIPT_DIR not set by sourcing script" >&2
exit 1
fi
# is_skipped_lib() — glibc/loader skip list, shared verbatim with
# bundle-binaries.sh so the bundler and the audit never disagree.
source "$SCRIPT_DIR/bundled-lib-skiplist.sh"
# Colors
if [[ -z "${RED:-}" ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
fi
# Check that required variables are set
if [[ -z "${PROJECT_DIR:-}" ]]; then
echo "Error: PROJECT_DIR not set by sourcing script" >&2
exit 1
fi
if [[ -z "${PLATFORM:-}" ]]; then
echo "Error: PLATFORM not set by sourcing script" >&2
exit 1
fi
# Ensure platform is lowercase
PLATFORM="$(echo "$PLATFORM" | tr '[:upper:]' '[:lower:]')"
# Validate platform
if [[ ! "$PLATFORM" =~ ^(linux|macos|windows)$ ]]; then
echo -e "${RED}Error: Invalid platform: $PLATFORM${NC}" >&2
exit 1
fi
# Configuration file
CONFIG="$PROJECT_DIR/.build-config.json"
PARSE_CONFIG="$SCRIPT_DIR/parse-build-config.py"
# Check config file
if [[ ! -f "$CONFIG" ]]; then
echo -e "${RED}Error: $CONFIG not found${NC}" >&2
exit 1
fi
if ! python3 "$PARSE_CONFIG" "$CONFIG" >/dev/null; then
echo -e "${RED}Error: $CONFIG is not valid JSON${NC}" >&2
exit 1
fi
get_cfg() { python3 "$PARSE_CONFIG" "$CONFIG" "$1"; }
# --- Platform functions (to be implemented by platform scripts) ---
# Platform scripts MUST implement these three functions:
# install_system_deps()
# bundle_python_impl()
# bundle_binaries_impl()
# --- Common Build Steps ---
# Clone $authed_url into $dir at $ref, OR update an existing checkout in place so
# a persistent clone dir (Docker volume) is reused across builds instead of
# re-downloading ~49 repos every run. $authed_url carries the (rotating) auth
# token; the stored remote is rewritten to the token-free $plain_url so the reuse
# check stays stable as the token changes each run. Uses `reset --hard` (not
# `clean`) on purpose: the core checkout has the plugin dirs nested under it as
# untracked children, and clean would wipe them. Falls back to a fresh clone when
# the dir is absent, is a different repo, or the fetch fails — so a cold CI runner
# (no cache volume) behaves exactly as a plain `git clone` did.
clone_or_update() {
local authed_url="$1" dir="$2" ref="${3:-}" plain_url="$4"
if [[ -d "$dir/.git" && "$(git -C "$dir" config --get remote.origin.url 2>/dev/null)" == "$plain_url" ]]; then
if git -C "$dir" fetch --depth 1 "$authed_url" ${ref:+"$ref"} 2>/dev/null \
&& git -C "$dir" reset --hard FETCH_HEAD >/dev/null 2>&1; then
return 0
fi
# fetch/reset failed (deleted branch, corrupt cache) — fall through.
fi
rm -rf "$dir"
git clone --depth 1 ${ref:+--branch "$ref"} "$authed_url" "$dir" || return 1
git -C "$dir" remote set-url origin "$plain_url"
}
# Clone Slopsmith and plugins (shared across all platforms)
clone_slopsmith() {
# RUNNER_TEMP on Windows runners is a native Windows path
# (e.g. `D:\a\_temp`) that Git Bash / MSYS tools don't reliably
# treat as a filesystem path. POSIX `/tmp/slopsmith` is the
# default; if a non-POSIX environment really wants RUNNER_TEMP, it
# can pass an explicit clone_dir argument (resolved via cygpath -u
# on Windows if needed).
# clone_dir defaults to /tmp/slopsmith (fresh every CI run). Set
# SLOPSMITH_CLONE_DIR to a persistent path (a Docker volume, wired by
# build-linux-docker.sh) to reuse the clone across local builds.
local clone_dir="${1:-${SLOPSMITH_CLONE_DIR:-/tmp/slopsmith}}"
# Skip if already set for local development
if [[ -n "${SLOPSMITH_DIR:-}" ]] && [[ -d "$SLOPSMITH_DIR" ]]; then
echo "Using existing SLOPSMITH_DIR: $SLOPSMITH_DIR"
return 0
fi
# SLOPSMITH_REF selects the core branch/tag to bundle (set by the
# Build workflow's slopsmith_ref input). Defaults to main so local
# builds and the push/tag CI paths behave exactly as before.
# --branch accepts either a branch or a tag, both shallow-cloneable.
local slopsmith_ref="${SLOPSMITH_REF:-main}"
# SLOPSMITH_REPO overrides the core repo (default got-feedback/feedback),
# mirroring SLOPSMITH_REF. Lets a contributor without push access to the
# core repo bundle a branch pushed to their own fork for a test build:
# SLOPSMITH_REPO=me/feedBack SLOPSMITH_REF=my-branch
local slopsmith_repo="${SLOPSMITH_REPO:-got-feedback/feedback}"
local _auth=""
[[ -n "${GH_CLONE_TOKEN:-}" ]] && _auth="x-access-token:${GH_CLONE_TOKEN}@"
# mkdir -p the parent so a volume-mounted clone_dir subpath works.
mkdir -p "$(dirname "$clone_dir")"
echo "Cloning/updating Slopsmith repository (${slopsmith_repo} ref: ${slopsmith_ref})..."
clone_or_update "https://${_auth}github.com/${slopsmith_repo}.git" "$clone_dir" "$slopsmith_ref" "https://github.com/${slopsmith_repo}.git"
# Remove broken symlinks from plugins dir
find "$clone_dir/plugins" -maxdepth 1 -type l -delete 2>/dev/null || true
# Clone bundled plugins. Format per entry:
# <owner>/<repo>[@<branch>][:<dirname>]
# Dirname defaults to <repo> minus the "feedback-plugin-" prefix
# with hyphens replaced by underscores (slopsmith treats plugin
# directories as Python module names, which can't contain dashes).
# Provide an explicit dirname after a colon for repos that don't
# follow the feedback-plugin-* naming convention. An optional
# @<branch> clones a non-default branch (used to ship in-review
# plugin work in a feature-branch test build).
cd "$clone_dir/plugins"
local plugins=(
# Bundled plugins — all under the got-feedback org after the migration.
got-feedback/feedback-plugin-bongocat
got-feedback/feedback-plugin-drums
got-feedback/feedback-plugin-editor
got-feedback/feedback-plugin-flappy-bend
got-feedback/feedback-plugin-fretboard
got-feedback/feedback-plugin-guitar-theory
got-feedback/feedback-plugin-invert-highway
got-feedback/feedback-plugin-jumpingtab
got-feedback/feedback-plugin-loosefolder:loose_folder
got-feedback/feedback-plugin-lyrics-karaoke
got-feedback/feedback-plugin-metronome
got-feedback/feedback-plugin-midi
got-feedback/feedback-plugin-multiplayer
got-feedback/feedback-plugin-musicxml-import
got-feedback/feedback-plugin-nam-tone
got-feedback/feedback-plugin-notedetect
got-feedback/feedback-plugin-piano
got-feedback/feedback-plugin-practice
got-feedback/feedback-plugin-sectionmap
got-feedback/feedback-plugin-song-preview
got-feedback/feedback-plugin-splitscreen
got-feedback/feedback-plugin-staffview
got-feedback/feedback-plugin-stem-mixer
got-feedback/feedback-plugin-stems
got-feedback/feedback-plugin-stepmode
got-feedback/feedback-plugin-strum-fighter
got-feedback/feedback-plugin-studio
got-feedback/feedback-plugin-tabview
got-feedback/feedback-plugin-themes
got-feedback/feedback-plugin-transpose-chords
got-feedback/feedback-plugin-tutorials
got-feedback/feedback-plugin-virtuoso
# Rig Builder (NAM tone builder) — the repo was renamed
# feedBack-plugin-rig-builder (capital B, so the lowercase prefix
# strip doesn't apply; explicit dirname instead). The old
# `got-feedback/rig_builder` name only worked via a GitHub rename
# redirect, which silently breaks if a new repo ever takes that name.
got-feedback/feedBack-plugin-rig-builder:rig_builder
# Camera Director (3D-highway free camera) — our fork of nimuart's plugin
# (v3 popover launcher + drum/piano support; PR upstream at
# nimuart/cameradirector_feedback#3). Repo isn't under the
# feedback-plugin-* prefix, so give an explicit dirname matching the id.
got-feedback/cameradirector_feedback:camera_director
)
local total=0
local cloned=0
local expected_dirnames=()
for entry in "${plugins[@]}"; do
total=$((total + 1))
# Split off an optional ":<dirname>" then an optional "@<branch>".
# Git branch names can't contain ':' so the dirname split is safe
# to do first; what's left is "<owner>/<repo>" or "<owner>/<repo>@<branch>".
local spec="$entry" dirname="" branch=""
if [[ "$spec" == *:* ]]; then
dirname="${spec##*:}"
spec="${spec%%:*}"
fi
local owner_repo="$spec"
if [[ "$spec" == *@* ]]; then
branch="${spec##*@}"
owner_repo="${spec%%@*}"
fi
if [[ -z "$dirname" ]]; then
dirname="${owner_repo##*/}"
dirname="${dirname#feedback-plugin-}"
dirname="${dirname//-/_}"
fi
expected_dirnames+=("$dirname")
# clone_or_update reuses a cached plugin checkout (persistent clone_dir)
# and honors the optional @branch pin via $branch; a fresh dir falls back
# to a full shallow clone. dirname is relative to the plugins/ cwd.
if clone_or_update "https://${_auth}github.com/${owner_repo}.git" "$dirname" "$branch" "https://github.com/${owner_repo}.git" 2>/dev/null; then
cloned=$((cloned + 1))
else
echo " skipped ${owner_repo}${branch:+@$branch}"
fi
done
# Prune plugins no longer configured. clone_or_update's `reset --hard` (not
# `clean`) deliberately preserves sibling directories — needed so a
# persistent cache survives repeated core-repo updates — but that means a
# plugin removed or renamed here would otherwise leave its old clone behind
# forever, and bundle-slopsmith.sh bundles every directory under plugins/
# (globs "$SLOPSMITH_DIR/plugins/"*), so a stale clone would ship into the
# app. Track dirnames this loop has managed across runs in a manifest (a
# dotfile, so that glob skips it) and delete any previously-managed
# dirname that's absent from this run's list. Never touches a directory
# we didn't create ourselves (e.g. anything that's part of the core repo's
# own tracked tree, which isn't in the manifest).
local manifest="$clone_dir/plugins/.managed-plugins"
if [[ -f "$manifest" ]]; then
local prior_dirname still_wanted d
while IFS= read -r prior_dirname; do
[[ -n "$prior_dirname" ]] || continue
still_wanted=0
for d in "${expected_dirnames[@]}"; do
[[ "$d" == "$prior_dirname" ]] && still_wanted=1 && break
done
if [[ "$still_wanted" == "0" && -d "$clone_dir/plugins/$prior_dirname" ]]; then
echo " Pruning stale cached plugin: $prior_dirname"
rm -rf "$clone_dir/plugins/$prior_dirname"
fi
done < "$manifest"
fi
printf '%s\n' "${expected_dirnames[@]}" > "$manifest"
# Strip dangling symlinks from the bundled tree. Some plugins ship
# build-time symlinks into sources that aren't present at runtime (e.g.
# rig_builder's vst/src/racks/DPF -> DISTRHO framework). A broken symlink
# is harmless on Linux/squashfs, but on macOS it BREAKS codesign ("a
# sealed resource is missing or invalid" -> the app reads as "damaged")
# AND breaks `xattr -dr com.apple.quarantine` (it aborts on the dangling
# link, so the quarantine-removal workaround can't complete). Remove them.
local _dangling
_dangling=$(find "$clone_dir" -type l ! -exec test -e {} \; -print 2>/dev/null | wc -l | tr -d ' ')
if [[ "${_dangling:-0}" != "0" ]]; then
find "$clone_dir" -type l ! -exec test -e {} \; -delete 2>/dev/null || true
echo "Stripped ${_dangling} dangling symlink(s) from the bundled tree (macOS codesign safety)"
fi
export SLOPSMITH_DIR="$clone_dir"
echo "Cloned ${cloned} of ${total} plugins"
cd - >/dev/null
}
step=1
echo_validate_env() {
echo -e "${BLUE}Step $step: Validating environment${NC}"
step=$((step + 1))
}
echo_step() {
echo -e "${BLUE}Step $step: $1${NC}"
step=$((step + 1))
}
echo_summary() {
echo -e "${GREEN}${NC} $1"
}
echo_warning() {
echo -e "${YELLOW}!${NC} $1"
}
echo_error() {
echo -e "${RED}${NC} $1"
}
validate_environment() {
echo_validate_env
NODE_VERSION=$(get_cfg .versions.node)
PYTHON_VERSION=$(get_cfg .versions.python)
echo "Platform: $PLATFORM"
echo "Node: $NODE_VERSION"
echo "Python: $PYTHON_VERSION"
echo ""
# Check Node.js
if command -v node &>/dev/null; then
INSTALLED_NODE=$(node -p "process.version.replace('v', '')")
echo_summary "Found Node.js $INSTALLED_NODE"
else
echo_error "Node.js not found"
exit 1
fi
# Check Python 3
if command -v python3 &>/dev/null; then
INSTALLED_PYTHON=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
echo_summary "Found Python $INSTALLED_PYTHON"
else
echo_error "Python 3 not found"
exit 1
fi
echo ""
}
install_npm_deps() {
echo_step "Installing npm dependencies"
npm install
echo_summary "npm dependencies installed"
echo ""
}
build_native_addons() {
echo_step "Building native addons (audio engine)"
# FAST_BUILD reuses the native .node from the previous build (the whole
# C++/JUCE compile — by far the biggest cost under amd64 emulation). Only
# skips when the artifact is actually present, so a FAST_BUILD run with no
# prior full build still compiles instead of shipping a broken app.
if [[ "${FAST_BUILD:-0}" == "1" && -f "$PROJECT_DIR/build/Release/slopsmith_audio.node" ]]; then
echo_warning "FAST_BUILD: reusing existing build/Release/slopsmith_audio.node"
echo ""
return 0
fi
npm run build:native
echo_summary "Native addons built"
echo ""
}
bundle_slopsmith() {
echo_step "Bundling Slopsmith and plugins"
npm run bundle:slopsmith
echo_summary "Slopsmith bundled"
echo ""
}
bundle_python() {
mkdir -p "$PROJECT_DIR/resources"
# FAST_BUILD reuses the extracted python-build-standalone runtime (a network
# download + tar extract that never changes between JS-only iterations).
if [[ "${FAST_BUILD:-0}" == "1" && -x "$PROJECT_DIR/resources/python/runtime/bin/python3" ]]; then
echo_summary "FAST_BUILD: reusing existing resources/python/runtime"
echo ""
return 0
fi
bundle_python_impl
echo_summary "Python runtime bundled"
echo ""
}
bundle_binaries() {
mkdir -p "$PROJECT_DIR/resources/bin"
# FAST_BUILD reuses bundled binaries (ffmpeg/fluidsynth/vgmstream + their
# dylib chains — includes a network fetch for vgmstream).
if [[ "${FAST_BUILD:-0}" == "1" && -x "$PROJECT_DIR/resources/bin/fluidsynth" ]]; then
echo_summary "FAST_BUILD: reusing existing resources/bin"
echo ""
return 0
fi
bundle_binaries_impl
echo_summary "System binaries bundled"
echo ""
}
verify_bundled_binaries() {
# Smoke test: verify bundled binaries are executable and can run
local bin_dir="$PROJECT_DIR/resources/bin"
local ext=""
if [[ "$PLATFORM" == "windows" ]]; then
ext=".exe"
fi
echo_step "Verifying bundled binaries"
# Verify fluidsynth: supports --version
local fs_path="$bin_dir/fluidsynth${ext}"
if [[ ! -f "$fs_path" ]]; then
echo_error "Missing bundled binary: $fs_path"
exit 1
fi
if ! "$fs_path" --version >/dev/null 2>&1; then
echo_error "Binary fluidsynth failed to execute"
exit 1
fi
echo " ✓ fluidsynth"
# Verify ffmpeg: supports -version
local ff_path="$bin_dir/ffmpeg${ext}"
if [[ ! -f "$ff_path" ]]; then
echo_error "Missing bundled binary: $ff_path"
exit 1
fi
if ! "$ff_path" -version >/dev/null 2>&1; then
echo_error "Binary ffmpeg failed to execute"
exit 1
fi
echo " ✓ ffmpeg"
# Verify ffprobe: demucs spawns it before ffmpeg to read stream
# metadata. Required on every platform because falling through to a
# host-installed ffprobe makes stem splitting work on the build host
# and silently fail on user machines without it.
local ffp_path="$bin_dir/ffprobe${ext}"
if [[ ! -f "$ffp_path" ]]; then
echo_error "Missing bundled binary: $ffp_path"
exit 1
fi
if ! "$ffp_path" -version >/dev/null 2>&1; then
echo_error "Binary ffprobe failed to execute"
exit 1
fi
echo " ✓ ffprobe"
# Verify vgmstream-cli: doesn't have --version, check it produces output with version
local vgm_path="$bin_dir/vgmstream-cli${ext}"
echo " Checking vgmstream-cli at: $vgm_path"
if [[ ! -f "$vgm_path" ]]; then
echo_error "Missing bundled binary: $vgm_path"
exit 1
fi
echo " File exists, checking permissions:"
ls -la "$vgm_path"
# `file` is a diagnostic-only call — keep it best-effort so a minimal
# base image (e.g. the Linux Docker builder, which doesn't ship the
# libmagic-backed `file` binary) doesn't fail the build over a debug
# line. The actual smoke test is the run-and-grep below.
if command -v file >/dev/null 2>&1; then
echo " File type:"
file "$vgm_path"
fi
echo " Attempting to run vgmstream-cli..."
local vgm_output
local vgm_exit_code
# vgmstream-cli with no args prints its version header then exits 1.
# Capture the exit code via the if-branch so `set -e` doesn't trip
# AND vgm_exit_code reflects the binary's real status (not the `|| true`
# short-circuit that the previous form ended up reporting).
if vgm_output=$("$vgm_path" 2>&1); then
vgm_exit_code=0
else
vgm_exit_code=$?
fi
echo " Exit code: $vgm_exit_code"
echo " Raw output:"
echo "$vgm_output" | head -20
echo " Checking if output matches expected pattern..."
if [[ -z "$vgm_output" ]]; then
echo_error "Binary vgmstream-cli produced no output"
exit 1
fi
if [[ ! "$vgm_output" =~ vgmstream.*CLI.*decoder ]]; then
echo_error "Binary vgmstream-cli produced unexpected output"
echo " Expected pattern: vgmstream.*CLI.*decoder"
echo " Actual output (first 500 chars):"
echo "${vgm_output:0:500}"
exit 1
fi
echo " ✓ vgmstream-cli"
# On Linux, audit each bundled ELF binary's NEEDED entries: every
# SONAME must either be in the glibc/loader skip list (delegated to
# the user's libc) or sit next to the binary in resources/bin/.
# The `-version` smoke tests above can't catch this on the build
# host because /usr/lib happens to satisfy the deps — but on a user
# machine with a different ffmpeg ABI the load fails at runtime
# (issue #68 on Fedora 44 / Arch).
if [[ "$PLATFORM" == "linux" ]]; then
if ! command -v readelf >/dev/null 2>&1; then
echo_error "readelf not found (apt: binutils) - required to audit bundled binaries' shared library deps"
exit 1
fi
for bin in fluidsynth ffmpeg ffprobe vgmstream-cli; do
audit_bundled_deps "$bin_dir/$bin" "$bin_dir" || exit 1
done
# Also audit each bundled .so's own NEEDED entries. ldd-on-the-top-
# level-binary usually resolves the full transitive closure, but
# dlopen-resolved deps and interposer libs can slip through that
# traversal. Auditing every bundled .so closes the gap.
for so in "$bin_dir"/*.so*; do
[ -f "$so" ] || continue
audit_bundled_deps "$so" "$bin_dir" || exit 1
done
echo " ✓ shared-library audit"
fi
echo_summary "All bundled binaries verified"
echo ""
}
# Asserts every NEEDED SONAME in $1 is either in the glibc skip list
# or present as a file in $2 (the bundle directory). Returns non-zero
# with a specific error if any SONAME is unsatisfied.
audit_bundled_deps() {
local bin_path="$1"
local bundle_dir="$2"
local missing=()
local soname
while IFS= read -r soname; do
[ -n "$soname" ] || continue
is_skipped_lib "$soname" && continue
[ -f "$bundle_dir/$soname" ] && continue
missing+=("$soname")
done < <(readelf -d "$bin_path" 2>/dev/null | awk -F'[][]' '/\(NEEDED\)/ {print $2}')
if [ ${#missing[@]} -gt 0 ]; then
echo_error "Bundled $(basename "$bin_path") needs shared libs that are not in resources/bin/:"
for soname in "${missing[@]}"; do
echo " - $soname"
done
echo " Fix: extend scripts/bundle-binaries.sh so these SONAMEs are bundled (or add them to the glibc skip list if they MUST come from the host libc)."
return 1
fi
}
bundle_soundfont() {
echo_step "Bundling default soundfont"
bash "$SCRIPT_DIR/bundle-soundfont.sh"
echo_summary "Soundfont bundled"
echo ""
}
build_typescript() {
echo_step "Building TypeScript"
npm run build:ts
echo_summary "TypeScript built"
echo ""
}
package_application() {
echo_step "Packaging application"
# Call electron-builder directly. The package.json `dist:*` scripts
# chain `build:native && bundle && build:ts && electron-builder`,
# but build-common.sh's main() has already run all three of those
# explicitly. Going through `npm run dist:*` would re-run them, which
# on macOS is wasteful: build:native rebuilds the native audio addon
# that build-common.sh already produced.
#
# `--publish never` is required: on a tag push electron-builder
# defaults to auto-publishing to GitHub Releases and then errors out
# without GH_TOKEN. The workflow has a dedicated `release` job that
# publishes via softprops/action-gh-release after artifact upload —
# the build job just needs to produce artifacts, not publish them.
local builder_platform
case "$PLATFORM" in
linux) builder_platform="--linux" ;;
macos) builder_platform="--mac" ;;
windows) builder_platform="--win" ;;
*)
echo_error "Unsupported packaging platform: $PLATFORM"
exit 1
;;
esac
local builder_args=("$builder_platform")
# FAST_BUILD trims packaging for local iteration, where the shipped
# artifact itself is throwaway: package.json's linux.target builds BOTH
# AppImage and deb (each separately compresses the ~2GB resources/
# payload), and electron-builder's default asar/7z compression is tuned
# for a real release, not a quick smoke test. Neither changes what's IN
# the app, only how it's packaged for testing — CI/non-fast builds get
# the full untouched config. LINUX_TARGETS/BUILDER_COMPRESSION let a
# caller override either independently of FAST_BUILD.
if [[ "$PLATFORM" == "linux" ]]; then
local targets="${LINUX_TARGETS:-}"
if [[ -z "$targets" && "${FAST_BUILD:-0}" == "1" ]]; then
targets="AppImage"
fi
[[ -n "$targets" ]] && builder_args+=("$targets")
fi
local compression="${BUILDER_COMPRESSION:-}"
if [[ -z "$compression" && "${FAST_BUILD:-0}" == "1" ]]; then
compression="store"
fi
[[ -n "$compression" ]] && builder_args+=("-c.compression=$compression")
npx electron-builder "${builder_args[@]}" --publish never
echo_summary "Application packaged"
echo ""
}
verify_artifacts() {
echo_step "Verifying artifacts"
ARTIFACTS_FOUND=0
# Read patterns into array (avoid process substitution for CI compatibility)
patterns=()
tempfile=$(mktemp)
get_expected_artifacts > "$tempfile"
cat "$tempfile" >&2
while IFS= read -r line; do
patterns+=("$line")
done < "$tempfile"
rm -f "$tempfile"
for pattern in "${patterns[@]}"; do
shopt -s nullglob
files=($pattern)
shopt -u nullglob
if [ ${#files[@]} -gt 0 ]; then
ARTIFACTS_FOUND=1
break
fi
done
if [[ $ARTIFACTS_FOUND -eq 1 ]]; then
echo_summary "Build successful!"
echo ""
ls -lh "$PROJECT_DIR/release/" 2>/dev/null | grep -v "^total" | awk 'NR > 1' | head -10 || true
else
echo_error "No artifacts found"
if [[ -d "$PROJECT_DIR/release" ]]; then
echo "Contents of release/:"
ls -la "$PROJECT_DIR/release/" 2>&1 || echo "(directory empty)"
else
echo "release/ directory doesn't exist"
fi
exit 1
fi
echo ""
}
# Per-step timing. run_timed records "<seconds>|<label>" so main() can print a
# breakdown at the end — the cheapest way to see where the ~35 min actually goes
# before optimizing further.
STEP_TIMES=()
run_timed() {
local label="$1"; shift
local _t0=$(date +%s)
"$@"
STEP_TIMES+=("$(( $(date +%s) - _t0 ))|$label")
}
print_timings() {
echo -e "${BLUE}=== Build step timings ===${NC}"
local entry secs label
for entry in "${STEP_TIMES[@]}"; do
secs="${entry%%|*}"
label="${entry#*|}"
printf ' %5ds %s\n' "$secs" "$label"
done
}
# Main entry point - platform scripts call this
main() {
local start_time=$(date +%s)
case "$PLATFORM" in
linux|macos|windows)
;;
*)
echo_error "Unsupported platform: $PLATFORM"
exit 1
;;
esac
# Verify that all required functions are defined by the sourcing platform script
local missing_functions=()
local required_funcs=(
install_system_deps
bundle_python_impl
bundle_binaries_impl
get_expected_artifacts
)
for func in "${required_funcs[@]}"; do
if ! type "$func" &>/dev/null; then
missing_functions+=("$func")
fi
done
if [[ ${#missing_functions[@]} -gt 0 ]]; then
echo_error "Required functions not defined by platform script:"
for func in "${missing_functions[@]}"; do
echo " - $func"
done
exit 1
fi
validate_environment
install_system_deps
run_timed "npm install" install_npm_deps
# clone_slopsmith provides the slopsmith core + plugins that
# bundle_slopsmith packages into the app; run it before the build steps
# that consume $SLOPSMITH_DIR.
run_timed "clone core+plugins" clone_slopsmith
run_timed "build:native (C++)" build_native_addons
run_timed "bundle:slopsmith" bundle_slopsmith
run_timed "bundle python" bundle_python
run_timed "bundle binaries" bundle_binaries
verify_bundled_binaries
run_timed "bundle soundfont" bundle_soundfont
run_timed "build:ts" build_typescript
run_timed "electron-builder" package_application
verify_artifacts
local end_time=$(date +%s)
local duration=$((end_time - start_time))
print_timings
echo -e "${GREEN}${NC} Build complete for $PLATFORM in ${duration}s"
echo "Output: $PROJECT_DIR/release/"
}