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
+186
View File
@@ -0,0 +1,186 @@
# Build Scripts
Unified build system for Slopsmith Desktop supporting Linux (via Docker), macOS, and Windows.
## Quick Start
```bash
./scripts/build-release.sh
```
## Call Hierarchy
```
┌─────────────────┐
│ GitHub Actions │
│ build.yml │
└────────┬────────┘
│ (calls the same script everywhere)
┌──────────────────┐
│ build-release.sh │ Platform dispatcher
└────────┬─────────┘
│ Detects host OS:
│ - Linux → build-linux-docker.sh
│ - macOS → build-macos.sh
│ - Windows → build-windows.sh
┌──────────────────────────────────────┐
│ For Linux: Docker wrapper │
│ build-linux-docker.sh → │
│ Docker container: │
│ ./build-linux-ubuntu.sh │
└──────────────────────────────────────┘
│ Sources:
┌─────────────────┐
│ build-common.sh │ Shared build logic (~250 lines)
└─────────────────┘
▲ ▲ ▲
│ │ │
│ │ ├─── Platform-specific implementations:
│ │ install_system_deps()
│ │ bundle_python_impl()
│ │ bundle_binaries_impl()
│ │
│ └─ build-macos.sh
│ build-windows.sh
│ build-linux-ubuntu.sh
└─ platform: mac / win / linux
```
## How It Works
1. **build-release.sh** - Platform dispatcher. Detects OS and routes to the right build script.
2. **Linux builds** - Always Docker-based for reproducibility:
- `build-linux-docker.sh` → Docker container → `build-linux-ubuntu.sh` → packages
3. **Native builds** - macOS and Windows run directly on host:
- `build-macos.sh` - Uses Homebrew dependencies
- `build-windows.sh` - Uses Git Bash, downloads binaries
4. **build-common.sh** - Shared logic sourced by platform scripts:
- Validates environment (Node.js, Python, .NET)
- Runs npm install, builds C++ engine, bundles resources
- Calls platform-specific functions for: dependency installation, Python bundling, binary bundling
## Platform-Specific Scripts
### Files
| Script | Purpose | Requirements | Output |
|--------|---------|--------------|--------|
| `build-linux-docker.sh` | Reproducible Docker build | Docker, adjacent slopsmith repo | `.AppImage`, `.deb` |
| `build-linux-ubuntu.sh` | Native Ubuntu build | Ubuntu/Debian + apt | `.AppImage`, `.deb` |
| `build-macos.sh` | Native macOS build | Homebrew, Xcode CLI | `.dmg`, `.zip` |
| `build-windows.sh` | Native Windows build | Git Bash, Node.js, Python, .NET | `.exe` installer |
### Two-Layer Ubuntu Builds
Most Linux distributions don't have identical package versions. Using Docker ensures the build is reproducible:
- **Direct use**: `./scripts/build-linux-docker.sh`
- **Inside container**: Runs `./scripts/build-linux-ubuntu.sh`
- **Why**: Guarantees identical builds across different Linux distros
### Platform-Specific Functions
Each platform script implements four functions that `build-common.sh` calls:
```bash
install_system_deps() {
# Platform-specific: apt install, brew install, choco install, or downloads
}
bundle_python_impl() {
# Linux: copy system Python
# macOS: download python-build-standalone
# Windows: download embeddable Python zip
}
bundle_binaries_impl() {
# Linux: copy existing + patchelf
# macOS: copy existing + dylibbundler + sign
# Windows: download binaries (ffmpeg, vgmstream, fluidsynth)
}
get_expected_artifacts() {
# Globs verify_artifacts checks at the end of the build, e.g.
# printf "%s\n" "$PROJECT_DIR/release/*.dmg" "$PROJECT_DIR/release/*.zip"
}
```
## Requirements
| Platform | Requirements |
|----------|--------------|
| **Linux (Docker)** | Docker, adjacent slopsmith repo |
| **Linux (native)** | Ubuntu/Debian, sudo, Node.js 22+, Python 3.12+, .NET 10+, apt dependencies |
| **macOS** | macOS 11+, Homebrew, Xcode CLI, Node.js 22+, Python 3.12+, .NET 10+ |
| **Windows** | Windows 10/11, Git for Windows + Bash, Node.js 22+, Python 3.12+, .NET 10+ |
**Windows Note:** These scripts must run in Git Bash (MSYS), not `cmd.exe` or PowerShell. They rely on MSYS-style paths such as `/tmp`, which work fine inside Git Bash but won't resolve correctly from a native Windows shell — so for local development outside GitHub Actions, run the scripts from a Git Bash terminal.
## GitHub Actions
The CI workflow is extremely simple - just calls the same script:
```yaml
# .github/workflows/build.yml
steps:
# Install platform-specific dependencies (apt, brew, or choco)
- name: Install dependencies
run: ...
# Build using the same script developers use locally
- name: Build
shell: bash
run: ./scripts/build-release.sh
```
Result:
- Local builds and CI use identical code paths
- Build failures can be reproduced and debugged locally
- Workflow is "dumb" - all logic lives in versioned scripts
## macOS Code Signing & Notarization
The macOS build signs every bundled native binary (fluidsynth, ffmpeg, vgmstream-cli, embedded Python interpreter + dylibs + extension `.so`s) with a Developer ID Application certificate, then electron-builder signs the `.app` and submits it to Apple's notary service. With signing in place, users get no Gatekeeper "app is damaged" warning on first launch.
### Required GitHub secrets
| Secret | Purpose |
|---|---|
| `APPLE_CERTIFICATE_P12_BASE64` | Developer ID Application cert exported as `.p12`, then `base64 -i cert.p12` |
| `APPLE_CERTIFICATE_PASSWORD` | The `.p12` export password |
| `APPLE_SIGNING_IDENTITY` | Full identity, e.g. `Developer ID Application: Your Name (TEAMID)` |
| `APPLE_ID` | Apple ID email |
| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password from appleid.apple.com (not the regular Apple ID password) |
| `APPLE_TEAM_ID` | 10-char team ID from developer.apple.com → Membership |
| `KEYCHAIN_PASSWORD` | Any random string — used for the temporary CI keychain |
When `APPLE_CERTIFICATE_P12_BASE64` is unset (forks, contributor PRs without secret access), the certificate-import step is skipped and `sign-macos-binaries.sh` exits early. The build still completes — it just produces an unsigned `.app` that will trigger Gatekeeper on macOS.
### Local macOS builds
Local builds without `APPLE_SIGNING_IDENTITY` set produce an unsigned `.app` (same as before signing was added). To produce a signed local build for testing, ensure your Developer ID Application certificate is in your login keychain and run:
```bash
APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
./scripts/build-release.sh
```
This signs the bundled binaries but does **not** notarize — notarization requires `APPLE_ID` + `APPLE_APP_SPECIFIC_PASSWORD` + `APPLE_TEAM_ID` env vars and is run by electron-builder when those are present.
### Local cmake-js cache
`build-windows.sh` only force-clears `$HOME/.cmake-js` when `$CI` is set (or `CLEAN_CMAKE_JS=1` is exported). Local Windows builds reuse the cache by default; set `CLEAN_CMAKE_JS=1` if you need a fully fresh build to mirror CI behaviour.
+69
View File
@@ -0,0 +1,69 @@
# VST trace — runtime-gated diagnostic logging
`src/audio/VSTTrace.h` defines a `VST_TRACE(...)` macro the addon, the VST
host code, and the sandbox subprocess all use to emit lines into
`%TEMP%\slopsmith-vst-trace-<pid>.log` (Linux/macOS:
`/tmp/slopsmith-vst-trace-<pid>.log`) and stderr. The filename is per-PID
so concurrent runs (e.g. addon spawning a sandbox subprocess) each get
their own log without interleaving. It's compiled into every build but no-ops at runtime unless the
`SLOPSMITH_SANDBOX_DEBUG` environment variable is set to a non-empty value
other than `"0"`.
The first call caches the env var, so flipping the variable mid-process has
no effect — set it before launching the host process (`node ...`,
`electron ...`, or the sandbox subprocess via the parent's environment).
## What you'll see when it's enabled
* `[ctrl] ...` — control-channel framing from `ControlChannel.cpp`
(`ConnectNamedPipe`, `readFrame got N bytes`, `event: ready`, error codes).
* Sandbox subprocess startup steps from `slopsmith-vst-host.exe`:
`args ok`, `audio shm opened`, `control pipe connected`,
`plugin loaded: <name>`, `sending ready event`.
* `LoadVST: path='...'`, `SubprocessHandle.start: spawned pid=N`, the full
CreateProcess command line.
* `VSTHost.loadPlugin / VST3ComponentHolder.initialise` host-callback traces
from the JUCE VST3 host context (in-process load path only).
The sandbox host also opens a per-PID file at
`%TEMP%\slopsmith-vst-host-<pid>.log` **unconditionally** — this is by
design and intentionally not gated on `SLOPSMITH_SANDBOX_DEBUG`. The
sandbox subprocess runs hidden (no console window) and can die before
the env var has propagated, so an always-on per-PID file is the only
reliable way to diagnose "the subprocess died and I have no console"
crashes in the field. The file is small (a handful of lines per session),
written from a single process, and rotates per PID so they cap naturally.
## Turning it on
```cmd
:: Windows — set before launching node / electron / the desktop app:
set SLOPSMITH_SANDBOX_DEBUG=1
node load-gr6.js
```
```bash
# macOS / Linux:
SLOPSMITH_SANDBOX_DEBUG=1 node load-gr6.js
```
## Reading the log
The `<pid>` portion is the OS process ID of the writer — find your most
recent run via `dir %TEMP%\slopsmith-vst-trace-*.log` (Windows) or
`ls -t /tmp/slopsmith-vst-trace-*.log | head` (POSIX).
```cmd
:: Windows — view the most recently modified trace file:
for /f "delims=" %f in ('dir /b /o-d %%TEMP%%\slopsmith-vst-trace-*.log') do @type "%%TEMP%%\%f" & exit /b
```
```bash
# macOS / Linux
cat "$(ls -t /tmp/slopsmith-vst-trace-*.log | head -1)"
```
Per-PID naming means files accumulate over time. Clean up periodically
with `del %TEMP%\slopsmith-vst-trace-*.log` (Windows) or
`rm /tmp/slopsmith-vst-trace-*.log` (POSIX) when they're no longer
needed.
+149
View File
@@ -0,0 +1,149 @@
#!/bin/bash
# Build the JUCE audio engine as a Node.js native addon
# Usage: ./scripts/build-audio.sh [debug|release]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
BUILD_TYPE="${1:-Release}"
cd "$PROJECT_DIR"
# Ensure JUCE submodule is available
if [ ! -f "JUCE/CMakeLists.txt" ]; then
echo "Initializing JUCE submodule..."
git submodule update --init --recursive
fi
# Ensure node_modules exist (for node-addon-api headers)
if [ ! -d "node_modules" ]; then
echo "Installing npm dependencies..."
npm install
fi
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
CMAKE_ARCH="x64"
;;
aarch64|arm64)
CMAKE_ARCH="arm64"
;;
*)
CMAKE_ARCH="$ARCH"
;;
esac
# Linux: NeuralAmpModelerCore's A2 (slimmable) sources use
# std::atomic<std::shared_ptr<...>>, a C++20 library feature that libstdc++ only
# implements from GCC 12 on. ubuntu-22.04's default g++ is 11, where the primary
# std::atomic template fires a "trivially copyable" static_assert and the build
# fails. Prefer a g++ >= 12 when the default is older. No-op on macOS/Windows and
# on hosts whose default compiler is already new enough, and respects a CXX the
# caller already set.
if [ "$(uname -s)" = "Linux" ] && [ -z "${CXX:-}" ]; then
default_major="$(g++ -dumpversion 2>/dev/null | cut -d. -f1)"
if [ -n "$default_major" ] && [ "$default_major" -lt 12 ] 2>/dev/null; then
for v in 14 13 12; do
if command -v "g++-$v" >/dev/null 2>&1; then
export CC="gcc-$v" CXX="g++-$v"
echo "Default g++ is $default_major (<12, lacks std::atomic<shared_ptr>); using g++-$v for the NAM A2 sources"
# A build/ configured earlier with the default g++ has that
# compiler cached in CMakeCache.txt; cmake-js would reuse it and
# ignore CC/CXX, so the A2 sources would still compile with the
# old g++ and hit the static_assert. Drop a stale cache (one that
# isn't already on the selected compiler) so cmake reconfigures.
if [ -f build/CMakeCache.txt ] && ! grep -q "CMAKE_CXX_COMPILER:.*g++-$v" build/CMakeCache.txt; then
echo "Removing stale build/ (configured with a different compiler) so cmake reconfigures"
rm -rf build
fi
break
fi
done
if [ -z "${CXX:-}" ]; then
echo "Warning: default g++ is $default_major (<12) and no g++-12+ was found." >&2
echo " The NAM A2 sources need std::atomic<shared_ptr> (GCC 12+ libstdc++); the build will likely fail." >&2
fi
fi
fi
# Get the Electron version directly from the installed Electron package.
# Native addons MUST be built against the exact Electron ABI that ships
# with the app — guessing a fallback (the prior `|| echo 35.7.5`) can
# produce a .node that loads but crashes at runtime when the actual
# Electron version differs.
echo "Detecting Electron version..."
ELECTRON_PKG="node_modules/electron/package.json"
if [[ ! -f "$ELECTRON_PKG" ]]; then
echo "Error: $ELECTRON_PKG not found. Run \`npm install\` before building native addons." >&2
exit 1
fi
ELECTRON_VERSION=$(node -p "require('./$ELECTRON_PKG').version" 2>/dev/null | tr -d '\r\n')
if [[ -z "$ELECTRON_VERSION" ]]; then
echo "Error: failed to read Electron version from $ELECTRON_PKG." >&2
exit 1
fi
echo " Electron version: $ELECTRON_VERSION"
# Set environment variables for cmake-js
# CROSS-PLATFORM NOTE: cmake-js looks for these CMAKE_JS_* variables internally
export CMAKE_JS_RUNTIME="electron"
export CMAKE_JS_RUNTIME_VERSION="$ELECTRON_VERSION"
export CMAKE_JS_ARCH="$CMAKE_ARCH"
# Also set npm_config variables for compatibility
# CROSS-PLATFORM NOTE: These are needed because cmake-js falls back to node-gyp
# which expects npm_config_* variables. Both sets are required for reliable
# cross-platform builds, especially on Windows where environment handling differs.
export npm_config_runtime="electron"
export npm_config_target="$ELECTRON_VERSION"
export npm_config_arch="$CMAKE_ARCH"
export npm_config_target_arch="$CMAKE_ARCH"
# Optional: clear cmake-js cache on Windows (where this matters most)
# CROSS-PLATFORM NOTE: Only clear cache in CI environments by default to avoid
# permission issues on local Windows machines and preserve incremental builds.
# On Windows, cmake-js downloads headers to a different location
# (C:\Users\...\.cmake-js) than on Unix systems.
# To force cache clearing locally, set CLEAN_CMAKE_JS=1
if [ "${CLEAN_CMAKE_JS:-}" = "1" ] || { [ -n "$CI" ] && [ -d "$HOME/.cmake-js" ]; }; then
echo "Clearing cmake-js cache..."
rm -rf "$HOME/.cmake-js"
fi
echo ""
echo "Building audio engine..."
echo " Platform: $(uname -s)"
echo " Arch: $CMAKE_ARCH"
echo " Electron: $ELECTRON_VERSION"
echo " Build type: $BUILD_TYPE"
echo ""
# Debug: show what cmake-js will see
echo "Environment for cmake-js:"
echo " CMAKE_JS_RUNTIME=$CMAKE_JS_RUNTIME"
echo " CMAKE_JS_RUNTIME_VERSION=$CMAKE_JS_RUNTIME_VERSION"
echo " CMAKE_JS_ARCH=$CMAKE_JS_ARCH"
echo " npm_config_runtime=$npm_config_runtime"
echo " npm_config_target=$npm_config_target"
echo ""
npx cmake-js build \
--runtime electron \
--runtime-version "$ELECTRON_VERSION" \
--arch "$CMAKE_ARCH" \
--CDCMAKE_BUILD_TYPE="$BUILD_TYPE"
echo ""
echo "Build complete!"
if [ -f "build/Release/slopsmith_audio.node" ]; then
echo "Output: build/Release/slopsmith_audio.node"
ls -lh "build/Release/slopsmith_audio.node"
else
echo "Warning: slopsmith_audio.node not found in expected location"
find build -name "*.node" 2>/dev/null
fi
+590
View File
@@ -0,0 +1,590 @@
#!/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 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).
local clone_dir="${1:-/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
# Make the clone re-runnable: a leftover dir from a previous failed
# build would otherwise abort `git clone`. CI runners start fresh so
# this is purely a quality-of-life fix for local re-runs.
if [[ -d "$clone_dir" ]]; then
rm -rf "$clone_dir"
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}"
echo "Cloning Slopsmith repository (ref: ${slopsmith_ref})..."
git clone --depth 1 --branch "$slopsmith_ref" https://github.com/slopsmith/slopsmith.git "$clone_dir"
# 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 "slopsmith-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 slopsmith-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=(
# byrongamatos plugins
slopsmith/slopsmith-plugin-drum-highway-3d
slopsmith/slopsmith-plugin-drums
slopsmith/slopsmith-plugin-editor
slopsmith/slopsmith-plugin-flappy-bend
slopsmith/slopsmith-plugin-fretboard
slopsmith/slopsmith-plugin-jumpingtab
slopsmith/slopsmith-plugin-keys-highway-3d
slopsmith/slopsmith-plugin-lyrics-karaoke
slopsmith/slopsmith-plugin-metronome
slopsmith/slopsmith-plugin-midi
slopsmith/slopsmith-plugin-multiplayer
slopsmith/slopsmith-plugin-musicxml-import
slopsmith/slopsmith-plugin-nam-tone
slopsmith/slopsmith-plugin-notedetect
slopsmith/slopsmith-plugin-piano
slopsmith/slopsmith-plugin-practice
slopsmith/slopsmith-plugin-profileimport
slopsmith/slopsmith-plugin-sectionmap
slopsmith/slopsmith-plugin-setlist
slopsmith/slopsmith-plugin-staffview
slopsmith/slopsmith-plugin-stepmode
slopsmith/slopsmith-plugin-studio
slopsmith/slopsmith-plugin-tabimport
slopsmith/slopsmith-plugin-tabview
slopsmith/slopsmith-plugin-tones
slopsmith/slopsmith-plugin-tutorials
# Community plugins
alleexx/slopsmith-plugin-transpose-chords
ChrisBeWithYou/slopsmith-plugin-slopscale
DeathlySin/slopsmith-plugin-song-preview
Jafz2001/slopsmith-plugin-nam-rig-builder
masc0t/slopsmith-plugin-find-more
masc0t/slopsmith-plugin-invert-highway
masc0t/slopsmith-plugin-themes
masc0t/slopsmith-update-manager:update_manager
slopsmith/slopsmith-plugin-stem-mixer
topkoa/slopsmith-plugin-guitar-theory
topkoa/slopsmith-plugin-sloppak-converter
topkoa/slopsmith-plugin-splitscreen
topkoa/slopsmith-plugin-stems
)
local total=0
local cloned=0
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#slopsmith-plugin-}"
dirname="${dirname//-/_}"
fi
local clone_args=(--depth 1)
[[ -n "$branch" ]] && clone_args+=(--branch "$branch")
if git clone "${clone_args[@]}" "https://github.com/${owner_repo}.git" "$dirname" 2>/dev/null; then
cloned=$((cloned + 1))
else
echo " skipped ${owner_repo}${branch:+@$branch}"
fi
done
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)"
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"
bundle_python_impl
echo_summary "Python runtime bundled"
echo ""
}
bundle_binaries() {
mkdir -p "$PROJECT_DIR/resources/bin"
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
npx electron-builder "$builder_platform" --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 ""
}
# 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
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.
clone_slopsmith
build_native_addons
bundle_slopsmith
bundle_python
bundle_binaries
verify_bundled_binaries
bundle_soundfont
build_typescript
package_application
verify_artifacts
local end_time=$(date +%s)
local duration=$((end_time - start_time))
echo -e "${GREEN}${NC} Build complete for $PLATFORM in ${duration}s"
echo "Output: $PROJECT_DIR/release/"
}
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
# Docker-based Linux build wrapper
# Runs build-linux-ubuntu.sh inside a reproducible container
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DEVCONTAINER_DIR="$PROJECT_DIR/.devcontainer"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo "=== Slopsmith Desktop Docker Build ==="
echo ""
echo "This script provides reproducible Linux builds by running"
echo "build-linux-ubuntu.sh inside a Docker container."
echo ""
# Check prerequisites
echo -e "${BLUE}Checking prerequisites...${NC}"
if ! command -v docker &>/dev/null; then
echo -e "${RED}Error: Docker is not installed${NC}" >&2
echo "Install: https://docs.docker.com/get-docker/" >&2
exit 1
fi
if ! docker info &>/dev/null; then
echo -e "${RED}Error: Docker daemon is not running${NC}" >&2
exit 1
fi
echo -e "${GREEN}${NC} Docker available"
echo ""
# Build container image
echo -e "${BLUE}Building container image...${NC}"
echo " (This will take a few minutes on first run)"
echo ""
docker build \
-f "$DEVCONTAINER_DIR/Dockerfile" \
-t slopsmith-ubuntu-builder \
"$PROJECT_DIR"
# `set -e` at the top of this script already aborts on a failed
# `docker build` — no manual `$?` check needed (and the check that
# was here would in practice be unreachable).
echo -e "${GREEN}${NC} Container image built"
echo ""
# Clear stale CMake build cache. CMakeCache.txt bakes in the build path;
# when the project is mounted at a different path inside the container the
# paths don't match and cmake aborts. A clean build/ guarantees consistency.
if [[ -d "$PROJECT_DIR/build" ]]; then
echo -e "${BLUE}Clearing stale CMake cache...${NC}"
rm -rf "$PROJECT_DIR/build"
fi
# Generate unique container name
CONTAINER_NAME="slopsmith-build-$(date +%s)-$$-$RANDOM"
echo -e "${BLUE}Running build in container...${NC}"
echo -e "${BLUE}Container name:${NC} $CONTAINER_NAME"
echo ""
echo "The container will be preserved after the build to allow debugging."
echo "Clean up when done:"
echo " docker stop $CONTAINER_NAME && docker rm $CONTAINER_NAME"
echo ""
set +e
docker run \
--name "$CONTAINER_NAME" \
-v "$PROJECT_DIR:/workspace" \
-w /workspace \
-e ELECTRON_CACHE=/home/vscode/.cache/electron \
-e ELECTRON_BUILDER_CACHE=/home/vscode/.cache/electron-builder \
-e GIT_TERMINAL_PROMPT=0 \
-t \
slopsmith-ubuntu-builder \
bash -c './scripts/build-linux-ubuntu.sh'
BUILD_EXIT_CODE=$?
set -e
echo ""
if [[ $BUILD_EXIT_CODE -eq 0 ]]; then
echo -e "${GREEN}${NC} Build completed successfully!"
else
echo -e "${RED}${NC} Build failed (exit code: $BUILD_EXIT_CODE)"
echo ""
echo "To debug:"
echo " docker exec -it $CONTAINER_NAME /bin/bash"
echo " docker logs $CONTAINER_NAME"
echo ""
fi
exit $BUILD_EXIT_CODE
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# Native Ubuntu build script
# Assumes host is running Ubuntu Linux
# Uses native Ubuntu packages (apt) and copies 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="linux"
# Check we're on Linux
if [[ ! -f /etc/os-release ]] || ! grep -q "ubuntu\|debian" /etc/os-release; then
echo "Note: This script is optimized for Ubuntu/Debian but may work on other distributions."
echo "For non-Ubuntu Linux, system dependencies might need manual installation."
echo ""
fi
echo "=== Slopsmith Desktop Ubuntu Native Build ==="
echo ""
# 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: Return expected artifact patterns
get_expected_artifacts() {
printf "%s\n" "$PROJECT_DIR/release/*.AppImage" "$PROJECT_DIR/release/*.deb"
}
# Platform-specific: Install system dependencies
install_system_deps() {
if command -v sudo &>/dev/null; then
sudo apt-get update
PACKAGES=$(grep -v '^[[:space:]]*#' "$PROJECT_DIR/.packages/apt.txt" | grep -v '^[[:space:]]*$' | tr '\n' ' ')
if [[ -n "$PACKAGES" ]]; then
sudo apt-get install -y $PACKAGES
fi
else
echo -e "${YELLOW}!${NC} sudo not available, skipping apt package installation"
echo " Make sure build dependencies are already installed"
fi
}
# Platform-specific: Bundle Python runtime
bundle_python_impl() {
# Linux: use existing bundle-python.sh script
bash "$SCRIPT_DIR/bundle-python.sh"
}
# Platform-specific: Bundle system binaries
bundle_binaries_impl() {
# Linux: use existing bundle-binaries.sh script
bash "$SCRIPT_DIR/bundle-binaries.sh"
}
# Run the build
main "$@"
+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
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
# Unified release build script that dispatches to platform-specific scripts.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
# Color output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo "=== Slopsmith Desktop Build ==="
echo ""
# Detect platform
# CROSS-PLATFORM NOTE: MINGW*, MSYS*, and CYGWIN* patterns must all be checked
# to properly detect Windows environment. Git Bash sets OSTYPE to msys, but we
# also check for MINGW and CYGWIN to cover all Windows bash environments.
PLATFORM=""
case "$(uname -s)" in
Linux*)
PLATFORM="linux"
;;
Darwin*)
PLATFORM="macos"
;;
MINGW*|MSYS*|CYGWIN*)
PLATFORM="windows"
;;
*)
echo -e "${RED}Error: Unsupported platform: $(uname -s)${NC}" >&2
echo "Supported platforms: Linux, macOS, Windows (Git Bash)" >&2
exit 1
;;
esac
echo -e "${GREEN}Platform:${NC} $PLATFORM"
echo ""
case "$PLATFORM" in
linux)
# Check if running on Ubuntu
if [[ -f /etc/os-release ]] && grep -q '^ID=ubuntu' /etc/os-release; then
# Ubuntu: Use native Ubuntu build
if [[ -f "$SCRIPT_DIR/build-linux-ubuntu.sh" ]]; then
bash "$SCRIPT_DIR/build-linux-ubuntu.sh"
else
echo -e "${RED}Error: build-linux-ubuntu.sh not found${NC}" >&2
exit 1
fi
else
# Other Linux: Use Docker for reproducibility across distros
if [[ -f "$SCRIPT_DIR/build-linux-docker.sh" ]]; then
bash "$SCRIPT_DIR/build-linux-docker.sh"
else
echo -e "${RED}Error: build-linux-docker.sh not found${NC}" >&2
exit 1
fi
fi
;;
macos)
if [[ -f "$SCRIPT_DIR/build-macos.sh" ]]; then
bash "$SCRIPT_DIR/build-macos.sh"
else
echo -e "${RED}Error: build-macos.sh not found${NC}" >&2
exit 1
fi
;;
windows)
if [[ -f "$SCRIPT_DIR/build-windows.sh" ]]; then
bash "$SCRIPT_DIR/build-windows.sh"
else
echo -e "${RED}Error: build-windows.sh not found${NC}" >&2
exit 1
fi
;;
*)
echo -e "${RED}Error: Unexpected platform: $PLATFORM${NC}" >&2
exit 1
;;
esac
# The exit code from the platform build script
exit_code=$?
if [[ $exit_code -eq 0 ]]; then
echo ""
echo -e "${GREEN}${NC} Build complete!"
echo "Artifacts: $PROJECT_DIR/release/"
else
echo ""
echo -e "${RED}${NC} Build failed"
fi
exit $exit_code
+276
View File
@@ -0,0 +1,276 @@
#!/bin/bash
# Native Windows build script
# Runs in Git Bash (Git for Windows)
set -euo pipefail
# Use plain `pwd` (POSIX path). `pwd -W` returns a Windows-form path
# with backslashes on MSYS / Git Bash, which would then break `dirname`
# and `source "$SCRIPT_DIR/build-common.sh"` since those expect POSIX
# paths. If a Windows-form path is needed downstream (e.g. for cmake-js
# or a non-MSYS tool), convert at the point of use via
# `cygpath -w "$SCRIPT_DIR"`.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
CONFIG="$PROJECT_DIR/.build-config.json"
# Platform identifier
export PLATFORM="windows"
# Check for Git Bash/MSYS
if [[ "$OSTYPE" != "msys" ]] && [[ "$OSTYPE" != "win32" ]] && [[ -z "${MSYSTEM:-}" ]]; then
echo "Error: This script must be run in Git Bash (Git for Windows)" >&2
echo "Download: https://git-scm.com/download/win" >&2
exit 1
fi
echo "=== Slopsmith Desktop Windows Build ==="
echo ""
# 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: Return expected artifact patterns
# Windows target is now "dir" (electron-builder unpacked only) because Velopack
# generates the installer via `vpk pack` on tagged CI runs. For non-tag builds
# (PR validation, main-branch pushes) only the unpacked dir is produced.
get_expected_artifacts() {
printf "%s\n" "$PROJECT_DIR/release/win-unpacked/*.exe"
}
# Platform-specific: Install system dependencies
install_system_deps() {
# Windows: install via Chocolatey if available
if command -v choco.exe &>/dev/null || command -v choco &>/dev/null; then
choco install cmake ffmpeg -y --installargs 'ADD_CMAKE_TO_PATH=System' || echo "Chocolatey install may have failed, continuing..."
else
echo_warning "Chocolatey not found - skipping system package installation"
echo " Make sure cmake and ffmpeg are already in PATH"
fi
}
# Platform-specific: Bundle Python runtime
bundle_python_impl() {
# Windows: download embeddable Python
PYTHON_VERSION=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .versions.python)
PYTHON_MAJOR="${PYTHON_VERSION%%.*}"
PYTHON_MINOR="${PYTHON_VERSION#*.}"
PYTHON_EMBED_URL="https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip"
echo "Downloading Python embeddable..."
local curl_status=0
curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors "$PYTHON_EMBED_URL" -o /tmp/python-embed.zip || curl_status=$?
if [[ "$curl_status" -ne 0 ]]; then
echo_error "Failed to download Python embeddable package"
echo " URL: $PYTHON_EMBED_URL"
echo " curl exit code: $curl_status"
exit 1
fi
# Wipe the existing python dir before extracting so a re-run doesn't
# leave stale files (e.g. an old ._pth from a previous Python version)
# mixed in with the freshly-extracted embeddable distribution.
rm -rf "$PROJECT_DIR/resources/python"
mkdir -p "$PROJECT_DIR/resources/python"
unzip -q /tmp/python-embed.zip -d "$PROJECT_DIR/resources/python/"
# Enable site-packages by editing the ._pth file
# IMPORTANT: On Windows embeddable Python, PYTHONPATH environment variable is IGNORED
# when a ._pth file exists (isolated mode). We must add paths directly to the .pth file.
# The embeddable zip is supposed to ship a ._pth file; if it doesn't,
# the rest of the script's PATH-injection won't work, so fail fast.
PTH_FILE=$(find "$PROJECT_DIR/resources/python" -name "*._pth" | head -1)
if [[ -z "$PTH_FILE" ]]; then
echo_error "No ._pth file found in extracted embeddable Python — upstream zip layout may have changed"
exit 1
fi
if [[ -n "$PTH_FILE" ]]; then
# Enable site-packages
sed -i 's/#import site/import site/' "$PTH_FILE"
echo "Lib/site-packages" >> "$PTH_FILE"
# Add Slopsmith paths (relative to resources/python)
# These must be in the .pth file since PYTHONPATH is ignored in isolated mode
echo "# Slopsmith modules (relative to resources/python)" >> "$PTH_FILE"
echo "../slopsmith" >> "$PTH_FILE"
echo "../slopsmith/lib" >> "$PTH_FILE"
fi
# Install pip
echo "Downloading pip..."
if ! curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors \
https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py; then
echo_error "Failed to download pip installer"
exit 1
fi
"$PROJECT_DIR/resources/python/python.exe" /tmp/get-pip.py --quiet --no-cache-dir
# Install packages
# Install build tools first (required for building from source on Windows embeddable Python)
"$PROJECT_DIR/resources/python/python.exe" -m pip install --quiet --no-cache-dir \
setuptools wheel
# Install slopsmith runtime requirements (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 works outside CI.
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
"$PROJECT_DIR/resources/python/python.exe" -m pip install --quiet --no-cache-dir \
-r "$SLOPSMITH_DIR/requirements.txt"
"$PROJECT_DIR/resources/python/python.exe" -m pip install --quiet --no-cache-dir \
-r "$PROJECT_DIR/.packages/python.txt"
}
# Usage: download_with_retries <url> <output_path> <description>
download_with_retries() {
local url="$1"
local output_path="$2"
local description="$3"
local max_attempts=3
local attempt=1
local delay=10
while [[ $attempt -le $max_attempts ]]; do
echo " Downloading $description (attempt $attempt/$max_attempts)..."
if curl -sL --fail --max-time 120 "$url" -o "$output_path"; then
echo " Successfully downloaded $description"
return 0
fi
local exit_code=$?
echo " Download failed with exit code $exit_code"
if [[ $attempt -lt $max_attempts ]]; then
echo " Retrying in ${delay}s..."
sleep $delay
delay=$((delay * 2))
fi
attempt=$((attempt + 1))
done
echo_error "Failed to download $description after $max_attempts attempts"
return 1
}
# Platform-specific: Bundle system binaries
# These binaries are REQUIRED for core functionality. The build will fail
# if downloads don't succeed after multiple retry attempts.
bundle_binaries_impl() {
mkdir -p "$PROJECT_DIR/resources/bin"
# ffmpeg static build
echo "Downloading ffmpeg..."
if ! download_with_retries \
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip" \
"/tmp/ffmpeg.zip" \
"ffmpeg"; then
exit 1
fi
unzip -q /tmp/ffmpeg.zip -d /tmp/ffmpeg
# Validate the expected layout instead of `cp ... || true` — a broken
# / changed zip layout would otherwise drop `ffmpeg.exe` silently and
# surface as a less-direct error in `verify_bundled_binaries`.
FFMPEG_BIN=$(find /tmp/ffmpeg -name 'ffmpeg.exe' -type f | head -1)
if [[ -z "$FFMPEG_BIN" ]]; then
echo_error "ffmpeg.exe not found after extracting /tmp/ffmpeg.zip — upstream zip layout may have changed"
exit 1
fi
cp "$FFMPEG_BIN" "$PROJECT_DIR/resources/bin/"
# Sloppak conversion encodes .ogg with -c:a libvorbis. The BtbN GPL
# build typically ships libvorbis, but verify so a future upstream
# change doesn't silently degrade users to the built-in vorbis
# encoder. The lib/sloppak_convert.py fallback is a safety net for
# unbundled installs, not a license to ship a libvorbis-less binary.
if ! "$PROJECT_DIR/resources/bin/ffmpeg.exe" -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."
echo_error "BtbN's GPL build no longer includes --enable-libvorbis; pick a different release asset (or earlier build) that ships it."
exit 1
fi
# ffprobe ships in the same BtbN zip as ffmpeg. demucs's audio loader
# spawns ffprobe before ffmpeg to read stream metadata; without it the
# loader dies with FileNotFoundError before ffmpeg is ever invoked.
FFPROBE_BIN=$(find /tmp/ffmpeg -name 'ffprobe.exe' -type f | head -1)
if [[ -z "$FFPROBE_BIN" ]]; then
echo_error "ffprobe.exe not found after extracting /tmp/ffmpeg.zip — upstream zip layout may have changed"
exit 1
fi
cp "$FFPROBE_BIN" "$PROJECT_DIR/resources/bin/"
# vgmstream-cli
echo "Downloading vgmstream-cli..."
if ! download_with_retries \
"https://github.com/vgmstream/vgmstream/releases/latest/download/vgmstream-win64.zip" \
"/tmp/vgmstream.zip" \
"vgmstream-cli"; then
exit 1
fi
unzip -q /tmp/vgmstream.zip -d /tmp/vgmstream
VGMSTREAM_EXE="/tmp/vgmstream/vgmstream-cli.exe"
shopt -s nullglob
VGMSTREAM_DLLS=("/tmp/vgmstream"/*.dll)
shopt -u nullglob
if [[ ! -f "$VGMSTREAM_EXE" ]]; then
echo_error "vgmstream-cli.exe not found at $VGMSTREAM_EXE after extraction"
exit 1
fi
if [[ ${#VGMSTREAM_DLLS[@]} -eq 0 ]]; then
echo_error "Expected vgmstream DLLs in /tmp/vgmstream after extraction but found none"
exit 1
fi
cp "$VGMSTREAM_EXE" "$PROJECT_DIR/resources/bin/"
cp "${VGMSTREAM_DLLS[@]}" "$PROJECT_DIR/resources/bin/"
# fluidsynth
echo "Downloading fluidsynth..."
# Reuse parse-build-config.py — Git Bash auto-converts the MSYS-style
# $CONFIG path to native Windows form when it's passed as an arg, but
# NOT when it's interpolated into an inline `python -c "...open('$CONFIG')..."`
# string. The previous inline form silently failed with set -e on
# Windows because Python opened a "/d/a/.../config.json" path that
# doesn't exist as a literal Windows path.
FS_URL=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .external.fluidsynth_windows.url 2>/dev/null || true)
if [[ -n "$FS_URL" ]]; then
if ! download_with_retries \
"$FS_URL" \
"/tmp/fluidsynth.zip" \
"fluidsynth"; then
exit 1
fi
unzip -q /tmp/fluidsynth.zip -d /tmp/fluidsynth
FS_BIN=$(find /tmp/fluidsynth -name 'fluidsynth.exe' -type f | head -1)
if [[ -n "$FS_BIN" ]]; then
cp "$FS_BIN" "$PROJECT_DIR/resources/bin/"
cp "$(dirname "$FS_BIN")"/*.dll "$PROJECT_DIR/resources/bin/" 2>/dev/null || true
fi
else
echo_error "Fluidsynth URL not found in build config"
exit 1
fi
echo_summary "All required Windows binaries downloaded and installed"
}
# Run the build
main "$@"
+191
View File
@@ -0,0 +1,191 @@
#!/bin/bash
# Bundle system binaries (ffmpeg, ffprobe, vgmstream-cli, fluidsynth)
# into resources/bin/ along with their non-glibc shared library
# dependencies, then set RPATH=$ORIGIN so each binary loads its
# siblings from its own directory at runtime.
#
# Without this, a build host with a different ffmpeg ABI than the user
# (e.g. Ubuntu 22.04 ffmpeg 4.x → libav*.so.58, Fedora 44 / Arch
# ffmpeg 7.x → libav*.so.62) ships a binary the user can't load.
#
# Linux-only. macOS bundling runs dylibbundler inline in the CI workflow
# because it's a different dynamic-linker story (Mach-O load paths +
# codesign invalidation). Windows downloads pinned zip archives inline.
set -euo pipefail
if [ "$(uname -s)" != "Linux" ]; then
echo "bundle-binaries.sh is Linux-only. macOS/Windows bundling runs inline in CI." >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
BIN_DIR="$PROJECT_DIR/resources/bin"
mkdir -p "$BIN_DIR"
echo "=== Bundling system binaries ==="
# patchelf is required to set RPATH=$ORIGIN on bundled binaries and
# libs. Without that, the runtime linker on the user's machine falls
# back to /usr/lib and fails when the host ABI doesn't match the build
# host's. Fail here rather than letting fluidsynth's section fail later.
if ! command -v patchelf >/dev/null 2>&1; then
echo "ERROR: patchelf not found on PATH (apt: patchelf) - required to set RPATH on bundled binaries." >&2
exit 1
fi
# is_skipped_lib() — the glibc/loader skip list, shared verbatim with
# build-common.sh's audit so the two never drift.
source "$SCRIPT_DIR/bundled-lib-skiplist.sh"
# Copy every non-glibc shared library that the given binary links to
# into resources/bin/. The final patchelf sweep (below) sets RPATH on
# every binary and every .so so the directory becomes a single
# self-contained load tree.
bundle_with_deps() {
local bin_path="$1"
# ldd exits non-zero (and prints "not a dynamic executable") on
# statically linked binaries like the vgmstream-cli GitHub release.
# Trap that and treat it as "no deps to bundle" rather than letting
# pipefail kill the build.
local ldd_out
if ! ldd_out=$(ldd "$bin_path" 2>/dev/null); then
return 0
fi
echo "$ldd_out" | awk '/=>/ {print $3}' | while read -r lib; do
[ -n "$lib" ] && [ -f "$lib" ] || continue
if is_skipped_lib "$(basename "$lib")"; then
continue
fi
# -L follows symlinks; -n avoids re-copying a lib already
# contributed by an earlier binary (every bundled binary on
# the same build host links the same /usr/lib versions, so
# first-wins is safe). Errors are NOT suppressed: -n makes the
# already-bundled case a no-op exit 0, so any non-zero status
# here is a real copy failure that must fail the build.
cp -Ln "$lib" "$BIN_DIR/"
done
}
# ffmpeg - used for WAV → OGG transcoding on GP5 imports.
# verify_bundled_binaries treats resources/bin/ffmpeg as required and
# will hard-fail later if it's missing — fail here with the actual cause
# rather than letting that downstream check produce a less-direct error.
if command -v ffmpeg >/dev/null 2>&1; then
cp "$(which ffmpeg)" "$BIN_DIR/"
echo " ffmpeg: $(ls -lh "$BIN_DIR/ffmpeg" | awk '{print $5}')"
else
echo "ERROR: ffmpeg not found on PATH; resources/bin/ffmpeg is required for the bundled build (apt: ffmpeg / brew: ffmpeg)." >&2
exit 1
fi
# Sloppak conversion encodes .ogg with -c:a libvorbis. Fail the build now
# rather than ship an ffmpeg that produces "Unknown encoder 'libvorbis'"
# at runtime on user machines. The lib/sloppak_convert.py fallback to
# the built-in `vorbis -strict experimental` encoder 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 "Install an ffmpeg built with --enable-libvorbis (apt's ffmpeg ships it by default; check your distro's package if this fails)." >&2
exit 1
fi
bundle_with_deps "$BIN_DIR/ffmpeg"
# ffprobe - demucs's audio loader spawns ffprobe before ffmpeg to read
# stream metadata; falling through to a host-installed ffprobe (or none
# at all) means the desktop bundle behaves differently on each user's
# machine. Ship the build host's ffprobe alongside ffmpeg so the bundle
# is self-contained on every platform. apt's ffmpeg package includes
# ffprobe, so this is universally available where ffmpeg already is.
if command -v ffprobe >/dev/null 2>&1; then
cp "$(which ffprobe)" "$BIN_DIR/"
echo " ffprobe: $(ls -lh "$BIN_DIR/ffprobe" | awk '{print $5}')"
else
echo "ERROR: ffprobe not found on PATH; resources/bin/ffprobe is required so demucs can read stream metadata in stem-splitting (apt: ffmpeg / brew: ffmpeg)." >&2
exit 1
fi
bundle_with_deps "$BIN_DIR/ffprobe"
# vgmstream-cli - used for WEM → WAV decoding.
# Download from GitHub releases if not in PATH (CI does this inline).
# verify_bundled_binaries downstream treats this as required and will
# hard-fail if it ends up missing — fail here with the actual cause
# instead.
if command -v vgmstream-cli >/dev/null 2>&1; then
cp "$(which vgmstream-cli)" "$BIN_DIR/"
echo " vgmstream-cli: $(ls -lh "$BIN_DIR/vgmstream-cli" | awk '{print $5}')"
else
for tool in curl unzip; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "ERROR: $tool not on PATH; required to download/extract vgmstream-cli." >&2
exit 1
fi
done
echo "Downloading vgmstream-cli from GitHub releases..."
VGM_ASSET="vgmstream-linux.zip"
if ! curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors \
"https://github.com/vgmstream/vgmstream/releases/latest/download/${VGM_ASSET}" \
-o /tmp/vgmstream.zip; then
echo "ERROR: failed to download vgmstream-cli zip from upstream releases." >&2
exit 1
fi
if ! unzip -q /tmp/vgmstream.zip -d /tmp/vgmstream; then
echo "ERROR: failed to extract /tmp/vgmstream.zip — upstream archive may be malformed." >&2
exit 1
fi
VGM_BIN=$(find /tmp/vgmstream -maxdepth 2 -name 'vgmstream-cli' -type f | head -1)
if [ -z "$VGM_BIN" ]; then
echo "ERROR: vgmstream-cli binary not found in downloaded archive — upstream zip layout may have changed." >&2
exit 1
fi
cp "$VGM_BIN" "$BIN_DIR/vgmstream-cli"
chmod +x "$BIN_DIR/vgmstream-cli"
echo " vgmstream-cli: $(ls -lh "$BIN_DIR/vgmstream-cli" | awk '{print $5}') (downloaded)"
rm -rf /tmp/vgmstream /tmp/vgmstream.zip
fi
bundle_with_deps "$BIN_DIR/vgmstream-cli"
# fluidsynth - used for MIDI → WAV in GP5 imports.
if command -v fluidsynth >/dev/null 2>&1; then
cp "$(which fluidsynth)" "$BIN_DIR/fluidsynth"
echo " fluidsynth: $(ls -lh "$BIN_DIR/fluidsynth" | awk '{print $5}')"
else
echo "ERROR: fluidsynth not found on PATH - it is required to bundle GP5 import support. Install fluidsynth and rerun this script (apt: fluidsynth)." >&2
exit 1
fi
bundle_with_deps "$BIN_DIR/fluidsynth"
# Final patchelf sweep: every dynamic binary gets RPATH=$ORIGIN, and
# every dynamic .so does too so transitive deps also load from
# resources/bin/. Done once at the end so the order in which binaries
# contribute their libs doesn't matter.
#
# Be strict about patchelf success on dynamic binaries. The downstream
# audit only checks lib *presence*, not RPATH — so a silent patchelf
# failure here would ship a binary whose loader falls back to /usr/lib
# at runtime, exactly the issue #68 regression. Detect static-vs-dynamic
# explicitly via NEEDED entries in the .dynamic section and only skip
# patchelf on the static case (e.g. the vgmstream-cli GitHub release).
for bin in ffmpeg ffprobe vgmstream-cli fluidsynth; do
bin_path="$BIN_DIR/$bin"
if readelf -d "$bin_path" 2>/dev/null | grep -q '(NEEDED)'; then
patchelf --set-rpath '$ORIGIN' "$bin_path"
else
echo " $bin: statically linked, RPATH not applicable"
fi
done
for so in "$BIN_DIR"/*.so*; do
[ -f "$so" ] || continue
if readelf -d "$so" 2>/dev/null | grep -q '(NEEDED)'; then
patchelf --set-rpath '$ORIGIN' "$so"
fi
done
echo " Total resources/bin/: $(du -sh "$BIN_DIR" | cut -f1)"
echo "=== Binary bundle complete ==="
+89
View File
@@ -0,0 +1,89 @@
#!/bin/bash
# Bundle a portable Python runtime into resources/python/runtime/ for Linux.
#
# Produces a relocatable interpreter + stdlib that the Electron main process
# spawns via `python.ts`. The layout matches python.ts's expectations:
# resources/python/runtime/bin/python3
#
# Downloads python-build-standalone (same source as actions/setup-python in
# CI) so the result is identical regardless of what Python is installed on the
# host or in the Docker container.
#
# Linux-only: macOS and Windows bundles are handled inline in build-common.sh.
set -euo pipefail
if [ "$(uname -s)" != "Linux" ]; then
echo "bundle-python.sh is Linux-only. macOS and Windows bundles run inline in CI." >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
CONFIG="$PROJECT_DIR/.build-config.json"
PYTHON_FULL_VERSION=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .versions.python)
PYTHON_VERSION="${PYTHON_FULL_VERSION%.*}"
PYTHON_STANDALONE_URL=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .external.python_standalone_linux_x64.url)
PYTHON_STANDALONE_SHA256=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .external.python_standalone_linux_x64.sha256)
PYTHON_BUNDLE="$PROJECT_DIR/resources/python/runtime"
echo "=== Bundling Python $PYTHON_FULL_VERSION runtime ==="
echo " Downloading python-build-standalone..."
TMPDIR_PBS=$(mktemp -d)
trap 'rm -rf "$TMPDIR_PBS"' EXIT
curl -fsSL "$PYTHON_STANDALONE_URL" -o "$TMPDIR_PBS/python-standalone.tar.gz"
echo "${PYTHON_STANDALONE_SHA256} $TMPDIR_PBS/python-standalone.tar.gz" | sha256sum -c -
tar -xzf "$TMPDIR_PBS/python-standalone.tar.gz" -C "$TMPDIR_PBS"
PBS_PREFIX="$TMPDIR_PBS/python"
rm -rf "$PROJECT_DIR/resources/python"
mkdir -p "$PYTHON_BUNDLE/bin" "$PYTHON_BUNDLE/lib"
cp "$PBS_PREFIX/bin/python${PYTHON_VERSION}" "$PYTHON_BUNDLE/bin/python3"
chmod +x "$PYTHON_BUNDLE/bin/python3"
cp -r "$PBS_PREFIX/lib/python${PYTHON_VERSION}" "$PYTHON_BUNDLE/lib/"
cp "$PBS_PREFIX/lib"/libpython${PYTHON_VERSION}*.so* "$PYTHON_BUNDLE/lib/"
echo " Bootstrapping pip via ensurepip in the bundled runtime"
LD_LIBRARY_PATH="$PYTHON_BUNDLE/lib" "$PYTHON_BUNDLE/bin/python3" -m ensurepip --upgrade --default-pip
# Resolve the slopsmith repo so we can pip install from its
# requirements.txt — that's the single source of truth for runtime
# deps. Search order matches bundle-slopsmith.sh:
# 1. $SLOPSMITH_DIR env var (set by clone_slopsmith() in CI)
# 2. ../slopsmith (sibling to this repo)
# 3. ~/Repositories/slopsmith (legacy dev layout)
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." >&2
echo "Searched:" >&2
echo " \$SLOPSMITH_DIR=${SLOPSMITH_DIR:-<unset>}" >&2
echo " $PROJECT_DIR/../slopsmith" >&2
echo " $HOME/Repositories/slopsmith" >&2
echo "Clone slopsmith next to this repo: git clone https://github.com/slopsmith/slopsmith.git $PROJECT_DIR/../slopsmith" >&2
exit 1
fi
echo " Installing slopsmith runtime requirements ($SLOPSMITH_DIR/requirements.txt)"
LD_LIBRARY_PATH="$PYTHON_BUNDLE/lib" "$PYTHON_BUNDLE/bin/python3" -m pip install --quiet --no-cache-dir \
-r "$SLOPSMITH_DIR/requirements.txt" 2>&1 | tail -3
echo " Installing desktop-only Python extras"
LD_LIBRARY_PATH="$PYTHON_BUNDLE/lib" "$PYTHON_BUNDLE/bin/python3" -m pip install --quiet --no-cache-dir \
-r "$PROJECT_DIR/.packages/python.txt" 2>&1 | tail -3
echo " Python runtime size: $(du -sh "$PYTHON_BUNDLE" | cut -f1)"
echo "=== Python bundle complete ==="
+189
View File
@@ -0,0 +1,189 @@
#!/bin/bash
# Bundle the Slopsmith server source + plugins into resources/slopsmith/.
#
# Slopsmith repo location is resolved in this order:
# 1. $SLOPSMITH_DIR env var
# 2. ../slopsmith (sibling to this repo)
# 3. ~/Repositories/slopsmith (legacy dev layout)
#
# Cross-platform: avoids `readlink -f` (not available on macOS by default)
# by using python's os.path.realpath. `rsync` is used for the resolved-
# symlink copy step and must be present (see .packages/apt.txt).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
BUNDLE_DIR="$PROJECT_DIR/resources/slopsmith"
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"
else
SLOPSMITH_DIR=""
fi
fi
if [ -z "$SLOPSMITH_DIR" ] || [ ! -d "$SLOPSMITH_DIR" ]; then
echo "ERROR: Slopsmith repository not found." >&2
echo "Searched:" >&2
echo " \$SLOPSMITH_DIR (unset)" >&2
echo " $PROJECT_DIR/../slopsmith" >&2
echo " $HOME/Repositories/slopsmith" >&2
echo "Clone it with: git clone https://github.com/slopsmith/slopsmith.git ../slopsmith" >&2
exit 1
fi
# Portable realpath — readlink -f doesn't exist on stock macOS.
realpath_portable() {
python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$1"
}
echo "=== Bundling Slopsmith server and plugins ==="
echo " Source: $SLOPSMITH_DIR"
rm -rf "$BUNDLE_DIR"
mkdir -p "$BUNDLE_DIR/static" "$BUNDLE_DIR/plugins"
# Server + lib
cp "$SLOPSMITH_DIR/server.py" "$BUNDLE_DIR/"
cp "$SLOPSMITH_DIR/VERSION" "$BUNDLE_DIR/"
cp -r "$SLOPSMITH_DIR/lib" "$BUNDLE_DIR/"
rm -rf "$BUNDLE_DIR/lib/__pycache__"
# Bundled content (progression paths, quests, shop definitions)
[ -d "$SLOPSMITH_DIR/data" ] && cp -r "$SLOPSMITH_DIR/data" "$BUNDLE_DIR/"
# Static assets — copy the whole directory. User-data dirs (art/, sloppak_cache/)
# and generated audio_*.mp3 files are gitignored and won't exist in a clean checkout.
cp -r "$SLOPSMITH_DIR/static/." "$BUNDLE_DIR/static/"
# Strip any leftover user-data that may exist in a dev checkout.
rm -rf "$BUNDLE_DIR/static/art" "$BUNDLE_DIR/static/sloppak_cache"
find "$BUNDLE_DIR/static" -maxdepth 1 -name 'audio_*.mp3' -delete
# Builtin diagnostic sloppak — server._seed_builtin_diagnostic_sloppaks() copies
# this into DLC_DIR/diagnostics-builtin/ on library scan startup.
DIAG_SLOPPAK="$SLOPSMITH_DIR/docs/diagnostics/slopsmith-diagnostic-basic-guitar.sloppak"
if [ -f "$DIAG_SLOPPAK" ]; then
mkdir -p "$BUNDLE_DIR/docs/diagnostics"
cp "$DIAG_SLOPPAK" "$BUNDLE_DIR/docs/diagnostics/"
else
echo "WARNING: diagnostic sloppak not found at $DIAG_SLOPPAK — builtin seeding will skip in packaged builds" >&2
fi
# Cross-platform "cp -r minus .git" — Git Bash on Windows doesn't ship
# rsync, so we can't rely on `rsync --exclude=.git`. Plain `cp -r`
# followed by stripping any nested `.git/` directories works on
# Linux/macOS/Git-Bash alike. The .git stripping matters because
# plugin directories cloned by clone_slopsmith() are git working trees;
# bundling their .git/objects/ would inflate the .app and (on macOS)
# trip electron-builder with EACCES on read-only pack files.
copy_plugin() {
local src="$1"
local dst="$2"
mkdir -p "$dst"
# Use cp -R rather than -r for portable symlink-following semantics.
cp -R "$src/." "$dst/"
find "$dst" -name '.git' -type d -prune -exec rm -rf {} +
}
# Built-in plugins (real directories, not symlinks to avoid duplicates).
for plugin_dir in "$SLOPSMITH_DIR/plugins/editor" "$SLOPSMITH_DIR/plugins/note_detect"; do
if [ -d "$plugin_dir" ] && [ ! -L "$plugin_dir" ]; then
name=$(basename "$plugin_dir")
copy_plugin "$plugin_dir" "$BUNDLE_DIR/plugins/$name"
fi
done
# External plugins: resolve symlinks, skip .git
for plugin_link in "$SLOPSMITH_DIR/plugins/"*; do
name=$(basename "$plugin_link")
[ "$name" = "__pycache__" ] && continue
[ "$name" = "__init__.py" ] && continue
target="$BUNDLE_DIR/plugins/$name"
[ -d "$target" ] && continue # already copied
if [ -L "$plugin_link" ]; then
real_dir=$(realpath_portable "$plugin_link")
if [ -d "$real_dir" ]; then
copy_plugin "$real_dir" "$target"
fi
elif [ -d "$plugin_link" ]; then
copy_plugin "$plugin_link" "$target"
elif [ -f "$plugin_link" ]; then
cp "$plugin_link" "$target"
fi
done
# Plugin-discovery __init__.py
cp "$SLOPSMITH_DIR/plugins/__init__.py" "$BUNDLE_DIR/plugins/"
# Desktop-specific plugins (audio_engine, plugin_manager) declared in
# src/renderer/**/plugin.json
for dp in "$PROJECT_DIR/src/renderer" "$PROJECT_DIR/src/renderer/plugin-manager"; do
if [ -f "$dp/plugin.json" ]; then
pname=$(python3 -c "import json, sys; print(json.load(open(sys.argv[1]))['id'])" "$dp/plugin.json")
mkdir -p "$BUNDLE_DIR/plugins/$pname"
cp "$dp"/*.html "$dp"/*.js "$dp"/plugin.json "$BUNDLE_DIR/plugins/$pname/" 2>/dev/null || true
fi
done
# ── Rebuild Tailwind CSS over the FULL bundled plugin set ──────────────────
# Core's committed static/tailwind.min.css is built scanning only the in-tree
# plugins (highway_3d, editor, note_detect, app_tour_*). Shipped as-is it would
# leave most of the 30+ bundled plugins' classes unstyled — the Play CDN's
# runtime JIT used to cover them, but it was removed (slopsmith#411). So
# regenerate the sheet HERE, after every plugin is copied, so it covers the
# whole bundled set and scales automatically as more plugins are added.
#
# We reuse core's tailwind.config.js for parity (same theme colors, safelist,
# and the highway_3d exclusion — that plugin ships its own assets/plugin.css
# via the `styles` capability). The config is copied into the bundle and run
# from there so its relative content globs (./static/**, ./plugins/**) resolve
# against the bundle regardless of Tailwind's cwd-vs-config-dir semantics.
if command -v npx >/dev/null 2>&1; then
echo "=== Rebuilding Tailwind CSS over bundled plugins ==="
# Whole pipeline runs inside one guarded `if (...)` so ANY failure (config
# copy, no npm cache/network, build error, or the final swap) falls back to
# the committed sheet instead of aborting the bundle under `set -e`. Two
# subtleties: (1) `set -e` is suppressed inside an `if` condition, so the
# steps are &&-chained to make an early failure short-circuit; (2) the build
# writes to a temp file that's mv'd into place only as the last link, so a
# failed/partial `npx` can never truncate the committed fallback sheet.
if (
cp "$SLOPSMITH_DIR/tailwind.config.js" "$BUNDLE_DIR/tailwind.config.js" \
&& cd "$BUNDLE_DIR" \
&& npx -y tailwindcss@3.4.19 \
-c tailwind.config.js \
-i static/_tailwind.src.css \
-o static/tailwind.min.css.new \
--minify \
&& mv -f static/tailwind.min.css.new static/tailwind.min.css
); then
# Drop the build-only inputs so they don't ship in resources/slopsmith.
# `|| true`: cleanup is best-effort — a stray rm failure (e.g. Windows
# file locks) must never abort the bundle under `set -e`.
rm -f "$BUNDLE_DIR/tailwind.config.js" "$BUNDLE_DIR/static/_tailwind.src.css" || true
echo " Tailwind CSS: $(wc -c < "$BUNDLE_DIR/static/tailwind.min.css") bytes (bundled-plugin-aware)"
else
# Discard any partial output; the committed sheet copied earlier stays
# intact. Still drop the build-only input so it never ships (matches the
# success path). `|| true` keeps cleanup non-fatal.
rm -f "$BUNDLE_DIR/tailwind.config.js" "$BUNDLE_DIR/static/tailwind.min.css.new" "$BUNDLE_DIR/static/_tailwind.src.css" || true
echo "WARN: Tailwind rebuild failed — shipping core's committed sheet as-is." >&2
echo " Bundled plugins using classes outside it may render unstyled." >&2
fi
else
# No rebuild engine; still drop the build-only input so bundle contents are
# consistent regardless of whether the rebuild ran. `|| true` keeps it
# non-fatal under `set -e`.
rm -f "$BUNDLE_DIR/static/_tailwind.src.css" || true
echo "WARN: npx/node not found — shipping core's committed tailwind.min.css as-is." >&2
echo " Bundled plugins using classes outside core's sheet may render unstyled." >&2
fi
echo " Slopsmith server: $(du -sh "$BUNDLE_DIR" | cut -f1)"
echo " Plugins: $(ls -d "$BUNDLE_DIR/plugins/"*/ 2>/dev/null | wc -l)"
echo "=== Slopsmith bundle complete ==="
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# Download + verify the default General-MIDI soundfont into
# resources/soundfonts/. Used by both CI and local `npm run bundle` on
# all three platforms.
#
# URL + SHA256 are read from .build-config.json (external.soundfont_general_user).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
CONFIG="$PROJECT_DIR/.build-config.json"
SF_URL=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .external.soundfont_general_user.url)
SF_SHA256=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .external.soundfont_general_user.sha256)
SF_DIR="$PROJECT_DIR/resources/soundfonts"
SF_FILE="$SF_DIR/GeneralUser-GS.sf2"
mkdir -p "$SF_DIR"
if [ -f "$SF_FILE" ]; then
echo " Existing soundfont found — verifying checksum"
else
echo " Downloading GeneralUser-GS.sf2 (~32 MB) from $SF_URL"
curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors "$SF_URL" -o "$SF_FILE"
fi
# macOS ships `shasum -a 256`; Linux / Windows-git-bash ship `sha256sum`.
if command -v sha256sum >/dev/null 2>&1; then
echo "${SF_SHA256} ${SF_FILE}" | sha256sum -c - >/dev/null
else
echo "${SF_SHA256} ${SF_FILE}" | shasum -a 256 -c - >/dev/null
fi
echo " Soundfont: $(ls -lh "$SF_FILE" | awk '{print $5}') (SHA256 verified)"
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# Top-level bundle delegator. Calls the per-concern modular scripts so
# local dev matches CI. Linux-focused; macOS/Windows bundling lives in
# the GitHub Actions workflow because those platforms have quite
# different packaging needs (dylibbundler, zip downloads, etc.).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
echo "=== Bundling Slopsmith Desktop ==="
bash "$SCRIPT_DIR/bundle-slopsmith.sh"
# Skip Python bundling on non-Linux platforms (handled inline in platform scripts)
if [[ "$(uname -s)" == "Linux" ]]; then
bash "$SCRIPT_DIR/bundle-python.sh"
fi
# Skip binary bundling on non-Linux platforms (handled inline in platform scripts)
if [[ "$(uname -s)" == "Linux" ]]; then
bash "$SCRIPT_DIR/bundle-binaries.sh"
fi
bash "$SCRIPT_DIR/bundle-soundfont.sh"
# Default IRs — small copy step that doesn't need its own script.
echo "=== Copying default IRs ==="
mkdir -p "$PROJECT_DIR/resources/default-irs"
cp "$PROJECT_DIR/models/cabs/"*.wav "$PROJECT_DIR/resources/default-irs/" 2>/dev/null || true
echo " Default IRs: $(ls "$PROJECT_DIR/resources/default-irs/" | wc -l) file(s)"
echo ""
echo "=== Bundle complete ==="
echo " Total resources: $(du -sh "$PROJECT_DIR/resources" | cut -f1)"
echo ""
echo "Ready for: npm run dist"
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# Shared skip list for bundled-binary shared-library handling.
#
# Sourced by both scripts/bundle-binaries.sh (which decides which libs
# to copy into resources/bin/) and scripts/build-common.sh (which
# audits that every NEEDED SONAME is satisfied). Keeping the list in
# one place prevents drift: a name present in only one copy would make
# the bundler and the audit disagree about which libs must be present.
#
# These are the low-level libc / loader pieces that MUST come from the
# user's own glibc — bundling them across distros breaks the dynamic
# linker.
is_skipped_lib() {
case "$1" in
libc.so*|libm.so*|libpthread.so*|libdl.so*|librt.so*|\
ld-linux*|libresolv.so*|linux-vdso*|linux-gate*|\
libnsl.so*|libutil.so*|libgcc_s.so*)
return 0 ;;
esac
return 1
}
+129
View File
@@ -0,0 +1,129 @@
// Smoke-test harness for the Windows VST sandbox path. Loads the addon,
// spawns the sandbox subprocess for Guitar Rig 6, opens its editor, then
// closes and shuts down cleanly. Used by clean-rerun.cmd on the test VM.
//
// Run from the repo root: `node scripts/dev/load-gr6.js > load-gr6-sandbox.log`.
// The Guitar Rig 6 path is hardcoded to its standard Win11 install location;
// adjust the GR6 path below if your install differs.
'use strict';
const path = require('path');
const addonPath = path.join(process.cwd(), 'build', 'Release', 'slopsmith_audio.node');
console.log('[test] loading addon from', addonPath);
const addon = require(addonPath);
console.log('[test] addon loaded; methods:', Object.keys(addon).slice(0, 10).join(', '), '...');
// Global watchdog — best-effort coverage for *asynchronous* hang paths
// (event-loop livelocks, setTimeout-stacked cleanup). loadVST is now a
// Napi::AsyncWorker, so the libuv event loop continues to fire timers
// while the load runs on a worker thread; this watchdog can pre-empt
// a hung async load. addon.shutdown still parks the event loop
// synchronously inside dispatchOnMessageThread, so if shutdown itself
// hangs the timer callback never fires — a proper supervisor-process
// + SIGKILL watchdog belongs in the CI harness (test-suite follow-up).
//
// The timer callback hard-exits — do NOT call addon.shutdown() here,
// it would block on the same dispatchOnMessageThread the addon is
// already stuck in and deadlock the process.
const WATCHDOG_MS = 60000;
const watchdog = setTimeout(() => {
console.error(`[test] FATAL: watchdog tripped after ${WATCHDOG_MS} ms (async hang)`);
process.exit(1);
}, WATCHDOG_MS);
function failExit(msg) {
if (msg) console.log('[test] FAIL:', msg);
// addon.shutdown blocks on dispatchOnMessageThread (up to 15s) if
// JUCE init partially succeeded; an init-failure path that triggered
// *because* the message thread never came up would then time out
// before the process exits. Cap with a hard process.exit timer so a
// hung shutdown can't extend the failure window beyond 3s.
const hardKill = setTimeout(() => {
console.error('[test] FAIL: addon.shutdown hung, force-exiting');
process.exit(2);
}, 3000);
hardKill.unref();
try { addon.shutdown(); } catch (_) {}
try { clearTimeout(watchdog); } catch (_) {}
try { clearTimeout(hardKill); } catch (_) {}
process.exit(1);
}
console.log('[test] addon.init()');
try {
addon.init();
} catch (e) {
failExit('EXCEPTION on init: ' + e.message);
}
setTimeout(async () => {
// Allow override for CI / dev machines whose VST3 layout differs from
// the standard "C:\Program Files\Common Files\VST3" install location.
// The default Native Instruments install ships "Guitar Rig 6.vst3";
// some installer versions or FX-only variants land as
// "Guitar Rig 6 FX.vst3". Try both before giving up.
const fs = require('fs');
const candidates = process.env.GR6_PATH
? [process.env.GR6_PATH]
: [
// NI's own installer drops into a vendor subdir; this is the
// most common default on a fresh GR6 install.
'C:\\Program Files\\Native Instruments\\VST3\\Guitar Rig 6.vst3',
'C:\\Program Files\\Native Instruments\\VST3\\Guitar Rig 6 FX.vst3',
// Some installs (and the existing CI fixture VM) drop into the
// shared Common Files VST3 dir; keep these as fallbacks so the
// existing smoke harness doesn't have to flip overnight.
'C:\\Program Files\\Common Files\\VST3\\Guitar Rig 6.vst3',
'C:\\Program Files\\Common Files\\VST3\\Guitar Rig 6 FX.vst3',
];
const gr6 = candidates.find(p => { try { return fs.existsSync(p); } catch (_) { return false; } });
if (!gr6) {
console.error('[test] FATAL: no Guitar Rig 6 install found at any of:');
for (const p of candidates) console.error(' - ' + p);
console.error('Set GR6_PATH to override (e.g. GR6_PATH="C:\\path\\to\\Guitar Rig 6.vst3" node scripts\\dev\\load-gr6.js).');
try { addon.shutdown(); } catch (_) {}
clearTimeout(watchdog);
process.exit(2);
return;
}
console.log('[test] calling addon.loadVST(' + gr6 + ')');
let slot;
try {
// addon.loadVST is now a Promise<number> (Napi::AsyncWorker); await
// it. The enclosing setTimeout callback was made async above.
slot = await addon.loadVST(gr6);
console.log('[test] loadVST returned slot:', slot);
} catch (e) {
failExit('EXCEPTION on loadVST: ' + e.message);
return;
}
if (!Number.isInteger(slot) || slot < 0) {
failExit('loadVST returned invalid slot: ' + String(slot));
return;
}
setTimeout(() => {
console.log('[test] calling addon.openPluginEditor(' + slot + ')');
let ok = false;
try {
ok = addon.openPluginEditor(slot);
console.log('[test] openPluginEditor returned:', ok);
} catch (e) {
failExit('EXCEPTION on openPluginEditor: ' + e.message);
return;
}
if (!ok) {
try { addon.closePluginEditor(slot); } catch (_) {}
failExit('openPluginEditor returned false');
return;
}
console.log('[test] sleeping 5s for editor creation + potential crash...');
setTimeout(() => {
console.log('[test] still alive after editor wait; closing');
try { addon.closePluginEditor(slot); } catch (e) {}
try { addon.shutdown(); } catch (e) {}
clearTimeout(watchdog);
setTimeout(() => process.exit(0), 1000);
}, 5000);
}, 1500);
}, 2000);
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Normalise ONNX Runtime install names on macOS (slopsmith#818).
#
# The prebuilt onnxruntime macOS dylib bakes the *build machine's absolute
# extraction path* (e.g. /Users/runner/work/.../_deps/onnxruntime/.../lib/
# libonnxruntime.1.20.1.dylib) into its LC_ID_DYLIB. At link time the linker
# copies that absolute path into slopsmith_audio.node's LC_LOAD_DYLIB. The
# .node carries the correct `@loader_path` rpath (set in src/audio/
# CMakeLists.txt) and the runtime is staged right beside it — but dyld never
# consults the rpath, because the load command is an absolute path, not an
# `@rpath/...` one. Result: the addon loads fine *only* on the CI runner;
# every other Mac fails to find onnxruntime, MlNoteDetector can't initialise,
# and the engine silently falls back to YIN ("ML detection: OFF").
#
# Fix: rewrite the install names to be `@rpath`-relative so the addon's
# existing `@loader_path` rpath resolves the co-located runtime everywhere.
# Idempotent — re-running is a no-op once the names are already `@rpath/...`.
#
# Usage: fix-onnxruntime-install-names.sh <slopsmith_audio.node> <runtime-lib-name>
# $1 absolute path to the built slopsmith_audio.node
# $2 runtime lib filename, e.g. libonnxruntime.1.20.1.dylib
#
# Invoked as a POST_BUILD step on Apple platforms only. install_name_tool
# invalidates any existing (adhoc) code signature, so we re-adhoc-sign the
# touched binaries afterwards; electron-builder overrides this with the real
# Developer ID signature when it packages + signs the .app.
set -euo pipefail
node="${1:?path to slopsmith_audio.node required}"
libname="${2:?onnxruntime runtime lib name required}"
dir="$(cd "$(dirname "$node")" && pwd)"
runtime="$dir/$libname"
providers="$dir/libonnxruntime_providers_shared.dylib"
# Re-adhoc-sign a binary after rewriting its load commands. `install_name_tool`
# strips the lightweight adhoc signature the linker attaches on Apple Silicon;
# without one, dyld refuses to load the addon on a local (unsigned) dev build.
resign() {
codesign --remove-signature "$1" 2>/dev/null || true
codesign --force --sign - "$1" 2>/dev/null || true
}
# Discover the addon's current (absolute) reference to the runtime, if any.
# Skip when it is already `@rpath/...` so re-runs / already-correct builds are
# no-ops. Match the exact runtime filename to avoid touching unrelated entries.
old_ref="$(otool -L "$node" | awk -v L="$libname" 'index($1,L) && $1 !~ /^@rpath\// {print $1; exit}')"
if [[ -n "${old_ref:-}" ]]; then
install_name_tool -change "$old_ref" "@rpath/$libname" "$node"
resign "$node"
echo "fix-onnxruntime-install-names: $node -> @rpath/$libname"
fi
# Normalise the runtime's own id so future links (and any consumer that reads
# LC_ID_DYLIB) get `@rpath/...` instead of an absolute build path.
if [[ -f "$runtime" ]]; then
cur_id="$(otool -D "$runtime" | sed -n '2p')"
if [[ "$cur_id" != "@rpath/$libname" ]]; then
install_name_tool -id "@rpath/$libname" "$runtime"
resign "$runtime"
echo "fix-onnxruntime-install-names: id $runtime -> @rpath/$libname"
fi
fi
# providers_shared is dlopen()'d by the runtime at session-init and links the
# main runtime by the same baked absolute path; rewrite it too so a build that
# ships the providers stub doesn't reintroduce the absolute dependency.
if [[ -f "$providers" ]]; then
prov_name="$(basename "$providers")"
cur_pid="$(otool -D "$providers" | sed -n '2p')"
if [[ "$cur_pid" != "@rpath/$prov_name" ]]; then
install_name_tool -id "@rpath/$prov_name" "$providers"
fi
prov_ref="$(otool -L "$providers" | awk -v L="$libname" 'index($1,L) && $1 !~ /^@rpath\// {print $1; exit}')"
if [[ -n "${prov_ref:-}" ]]; then
install_name_tool -change "$prov_ref" "@rpath/$libname" "$providers"
fi
resign "$providers"
fi
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Parse `.build-config.json` and emit a value (or the whole doc).
Usage:
parse-build-config.py <path> # pretty-print the whole document
parse-build-config.py <path> .versions.node # print a single value
Keys are dot-delimited, e.g. `.external.rs2014net.commit`. Plain JSON
only — if a future config needs comments, add a proper JSONC parser
(naive regex-based `//` stripping breaks on URLs like `https://...`).
"""
import json
import sys
def main() -> None:
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <path> [.json.path]", file=sys.stderr)
sys.exit(1)
config_file = sys.argv[1]
json_path = sys.argv[2] if len(sys.argv) > 2 else None
try:
with open(config_file, 'r') as f:
data = json.load(f)
except FileNotFoundError:
print(f"Error: file not found: {config_file}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error: invalid JSON in {config_file}: {e}", file=sys.stderr)
sys.exit(1)
if json_path is None:
print(json.dumps(data, indent=2))
return
value = data
for key in json_path.lstrip('.').split('.'):
try:
value = value[key]
except (KeyError, TypeError):
print(f"Error: key {json_path!r} not found in {config_file}", file=sys.stderr)
sys.exit(1)
print(value)
if __name__ == '__main__':
main()
+158
View File
@@ -0,0 +1,158 @@
#!/bin/bash
# Development environment setup
# Installs all dependencies and verifies the build chain
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
echo "=== Slopsmith Desktop Development Setup ==="
echo ""
# Check prerequisites
echo "Checking prerequisites..."
check_command() {
if command -v "$1" &>/dev/null; then
echo " [OK] $1"
else
echo " [MISSING] $1$2"
return 1
fi
}
# Verify the media chain: ffmpeg (WEM/OGG transcode), ffprobe (demucs probes
# stream metadata before invoking ffmpeg), and ffmpeg's libvorbis encoder
# (Sloppak conversion encodes .ogg with `-c:a libvorbis`).
# $1 — install hint shown when ffmpeg/ffprobe are missing.
# $2 — platform-specific remediation shown when libvorbis is absent.
check_media_chain() {
local install_hint="$1"
local libvorbis_hint="$2"
command -v ffmpeg >/dev/null 2>&1 && echo " [OK] ffmpeg" || echo " [MISSING] ffmpeg ($install_hint)"
command -v ffprobe >/dev/null 2>&1 && echo " [OK] ffprobe" || echo " [MISSING] ffprobe (ships with ffmpeg — $install_hint)"
if command -v ffmpeg >/dev/null 2>&1; then
if ffmpeg -hide_banner -encoders 2>/dev/null | grep -wq libvorbis; then
echo " [OK] ffmpeg libvorbis encoder"
else
echo " [WARN] ffmpeg lacks the libvorbis encoder — Sloppak conversion"
echo " falls back to the lower-quality built-in vorbis encoder."
echo " $libvorbis_hint"
fi
fi
}
check_command node "Install Node.js 20+" || exit 1
check_command npm "Comes with Node.js" || exit 1
check_command cmake "Install cmake (apt/brew/pacman)" || exit 1
check_command python3 "Install Python 3.12+" || exit 1
check_command git "Install git" || exit 1
# Platform-specific checks
case "$(uname -s)" in
Linux)
echo ""
echo "Checking Linux build dependencies..."
pkg-config --exists alsa 2>/dev/null && echo " [OK] ALSA" || echo " [MISSING] ALSA dev headers (apt: libasound2-dev / pacman: alsa-lib)"
pkg-config --exists jack 2>/dev/null && echo " [OK] JACK" || echo " [MISSING] JACK dev headers (apt: libjack-jackd2-dev / pacman: jack2)"
pkg-config --exists freetype2 2>/dev/null && echo " [OK] freetype2" || echo " [MISSING] freetype2 (apt: libfreetype-dev / pacman: freetype2)"
pkg-config --exists x11 2>/dev/null && echo " [OK] X11" || echo " [MISSING] X11 dev headers"
pkg-config --exists xrandr 2>/dev/null && echo " [OK] Xrandr" || echo " [MISSING] Xrandr dev headers"
pkg-config --exists xcursor 2>/dev/null && echo " [OK] Xcursor" || echo " [MISSING] Xcursor dev headers"
pkg-config --exists xinerama 2>/dev/null && echo " [OK] Xinerama" || echo " [MISSING] Xinerama dev headers"
check_media_chain "apt: ffmpeg / pacman: ffmpeg" \
"Most distro ffmpeg packages enable libvorbis — reinstall your distro's ffmpeg if this build does not."
command -v vgmstream-cli >/dev/null 2>&1 && echo " [OK] vgmstream-cli" || echo " [MISSING] vgmstream-cli (AUR: yay -S vgmstream-cli-bin / or github.com/vgmstream/vgmstream/releases)"
;;
Darwin)
echo ""
echo "Checking macOS dependencies..."
xcode-select -p &>/dev/null && echo " [OK] Xcode Command Line Tools" || echo " [MISSING] Run: xcode-select --install"
check_media_chain "brew install ffmpeg" \
"Homebrew's ffmpeg 8.1.1+ omits libvorbis — install a static ffmpeg build instead (packaged builds bundle one)."
command -v vgmstream-cli >/dev/null 2>&1 && echo " [OK] vgmstream-cli" || echo " [MISSING] vgmstream-cli (brew install vgmstream)"
;;
MINGW*|MSYS*|CYGWIN*)
echo ""
echo "Checking Windows (Git Bash) dependencies..."
check_media_chain "install ffmpeg and add it to PATH (e.g. winget install Gyan.FFmpeg)" \
"Most prebuilt Windows ffmpeg builds (e.g. Gyan) include libvorbis — pick one that does."
command -v vgmstream-cli >/dev/null 2>&1 && echo " [OK] vgmstream-cli" || echo " [MISSING] vgmstream-cli (github.com/vgmstream/vgmstream/releases — add to PATH)"
;;
esac
echo ""
# Initialize submodules
echo "Initializing git submodules..."
git submodule update --init --recursive 2>/dev/null || echo " Note: Run 'git submodule update --init --recursive' manually if this is a fresh clone"
# Install npm dependencies
echo ""
echo "Installing npm dependencies..."
npm install
# Locate Slopsmith. Matches the build scripts (bundle-slopsmith.sh,
# bundle-python.sh, build-macos.sh): an explicit $SLOPSMITH_DIR is honoured
# verbatim — a typo or partial checkout there is surfaced, never silently
# masked by a sibling or legacy checkout. Only when $SLOPSMITH_DIR is unset
# do we fall back to ../slopsmith then ~/Repositories/slopsmith, and a
# fallback candidate only counts if it actually contains server.py.
SLOPSMITH_DIR_ENV="${SLOPSMITH_DIR:-}"
if [ -z "${SLOPSMITH_DIR:-}" ]; then
if [ -f "$PROJECT_DIR/../slopsmith/server.py" ]; then
SLOPSMITH_DIR="$PROJECT_DIR/../slopsmith"
elif [ -f "$HOME/Repositories/slopsmith/server.py" ]; then
SLOPSMITH_DIR="$HOME/Repositories/slopsmith"
fi
fi
if [ -n "${SLOPSMITH_DIR:-}" ] && [ -f "$SLOPSMITH_DIR/server.py" ]; then
SLOPSMITH_DIR="$(cd "$SLOPSMITH_DIR" && pwd)"
echo ""
echo "Slopsmith found at: $SLOPSMITH_DIR"
# On Windows/Git Bash a bash check passes for an MSYS path like
# /c/src/slopsmith, but `npm run dev` (Electron) resolves $SLOPSMITH_DIR
# with Node, which needs a native Windows path. Warn before setup
# reports a config that dev mode would still fail to start.
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*)
if [ -n "$SLOPSMITH_DIR_ENV" ] && [ "${SLOPSMITH_DIR_ENV#/}" != "$SLOPSMITH_DIR_ENV" ]; then
echo ""
echo " NOTE: \$SLOPSMITH_DIR is an MSYS/Git-Bash path. 'npm run dev' needs a"
echo " native Windows path. Re-export it as:"
if command -v cygpath >/dev/null 2>&1; then
echo " export SLOPSMITH_DIR='$(cygpath -w "$SLOPSMITH_DIR_ENV")'"
else
echo " a native path such as C:\\src\\slopsmith"
fi
fi
;;
esac
PYTHON="${PROJECT_DIR}/.venv/bin/python3"
[ -x "$PYTHON" ] || PYTHON="python3"
echo "Checking Python dependencies (\"$PYTHON\")..."
"$PYTHON" -c "import fastapi" 2>/dev/null && echo " [OK] fastapi" || echo " [MISSING] \"$PYTHON\" -m pip install -r \"$SLOPSMITH_DIR/requirements.txt\""
"$PYTHON" -c "import uvicorn" 2>/dev/null && echo " [OK] uvicorn" || echo " [MISSING] \"$PYTHON\" -m pip install -r \"$SLOPSMITH_DIR/requirements.txt\""
elif [ -n "${SLOPSMITH_DIR:-}" ]; then
echo ""
echo "WARNING: \$SLOPSMITH_DIR is set to '$SLOPSMITH_DIR' but no server.py was found there."
echo " Fix the path or unset \$SLOPSMITH_DIR to fall back to ../slopsmith or ~/Repositories/slopsmith."
else
echo ""
echo "WARNING: Slopsmith not found. Set \$SLOPSMITH_DIR, clone to $PROJECT_DIR/../slopsmith, or use ~/Repositories/slopsmith"
fi
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Build commands:"
echo " npm run build:audio # Build JUCE native addon"
echo " npm run build:ts # Compile TypeScript"
echo " npm run dev # Run in development mode"
echo " npm run dist:linux # Build Linux package"
+98
View File
@@ -0,0 +1,98 @@
#!/bin/bash
# Sign every native binary inside resources/bin and resources/python/runtime
# with the Developer ID Application certificate so the .app passes
# notarization. Runs as part of the macOS bundle step, after binaries
# are downloaded/copied but before verify_bundled_binaries runs them
# (signing also clears the quarantine attribute that GitHub release
# downloads carry by default).
#
# Skips silently when APPLE_SIGNING_IDENTITY is unset — local builds
# without a Developer ID cert still produce a (Gatekeeper-rejected)
# unsigned .app, same as before this script existed.
#
# Required env (set by CI):
# APPLE_SIGNING_IDENTITY Full identity string, e.g.
# "Developer ID Application: Name (TEAMID)"
# Must already be present in the active keychain.
set -euo pipefail
if [[ -z "${APPLE_SIGNING_IDENTITY:-}" ]]; then
echo "[sign-macos] APPLE_SIGNING_IDENTITY not set — skipping (unsigned build)"
exit 0
fi
if [[ "$OSTYPE" != "darwin"* ]]; then
echo "[sign-macos] not on macOS — skipping" >&2
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
ENTITLEMENTS="$PROJECT_DIR/resources/entitlements.mac.plist"
if [[ ! -f "$ENTITLEMENTS" ]]; then
echo "[sign-macos] entitlements file missing: $ENTITLEMENTS" >&2
exit 1
fi
sign_one() {
local target="$1"
[[ -e "$target" ]] || return 0
# Skip symlinks — they get followed during bundling and signing the
# link target separately is enough.
[[ -L "$target" ]] && return 0
echo " $target"
codesign --force --options runtime --timestamp \
--sign "$APPLE_SIGNING_IDENTITY" \
--entitlements "$ENTITLEMENTS" \
"$target"
}
echo "[sign-macos] signing bundled binaries with: $APPLE_SIGNING_IDENTITY"
# 1. Top-level executables and bundled dylibs in resources/bin.
# dylibbundler copies fluidsynth's deps into this directory and
# rewrites their install names to @executable_path/, so each one
# has to be signed individually before the app bundle is built.
BIN_DIR="$PROJECT_DIR/resources/bin"
if [[ -d "$BIN_DIR" ]]; then
while IFS= read -r -d '' f; do
sign_one "$f"
done < <(find "$BIN_DIR" -maxdepth 1 -type f \( -perm -u+x -o -name '*.dylib' \) -print0)
fi
# 2. Embedded CPython runtime — interpreter + libpython dylib + every
# compiled extension. Notarization rejects the bundle if any of
# these is unsigned.
PY_RUNTIME="$PROJECT_DIR/resources/python/runtime"
if [[ -d "$PY_RUNTIME" ]]; then
# Interpreter binaries (python3, python3.x, etc.)
while IFS= read -r -d '' f; do
sign_one "$f"
done < <(find "$PY_RUNTIME/bin" -maxdepth 1 -type f -perm -u+x -print0 2>/dev/null || true)
# libpython dylib(s) and any other shared libs
while IFS= read -r -d '' f; do
sign_one "$f"
done < <(find "$PY_RUNTIME/lib" -maxdepth 2 -type f -name '*.dylib' -print0 2>/dev/null || true)
# Compiled extension modules — both stdlib lib-dynload and any
# site-packages C extensions installed via pip.
while IFS= read -r -d '' f; do
sign_one "$f"
done < <(find "$PY_RUNTIME/lib" -type f \( -name '*.so' -o -name '*.dylib' \) -print0 2>/dev/null || true)
fi
echo "[sign-macos] done"
# Spot-check a couple of binaries so a botched sign call fails the
# build here rather than mid-notarization 2 minutes later.
for probe in "$BIN_DIR/fluidsynth" "$BIN_DIR/ffmpeg" "$BIN_DIR/vgmstream-cli"; do
if [[ -f "$probe" ]]; then
codesign --verify --strict "$probe" || {
echo "[sign-macos] verification failed: $probe" >&2
exit 1
}
fi
done