chore: import upstream snapshot with attribution
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:32:09 +08:00
commit 8f10353f0c
135 changed files with 37786 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env bash
#
# Build a portable Linux StemDeck package: a single .tar.gz containing the
# Tauri binary plus a self-contained Python runtime (torch + demucs), so the
# user extracts and runs ./StemDeck with no toolchain.
#
# This is the Linux analog of scripts/windows/make-portable.ps1. Like the macOS
# runtime pack (scripts/macos/make-runtime-pack.sh) it bundles a full
# python-build-standalone install — a plain `venv` will not work because the
# desktop shell checks for the stdlib under python/lib/ (python_stdlib_present
# in desktop/src-tauri/src/main.rs).
#
# Phase 1 ships the CPU-only variant. FFmpeg is NOT bundled in the tarball (so we
# don't redistribute it); instead the desktop shell downloads a static build on
# first launch into the user data dir, falling back to a system `ffmpeg` on PATH
# when one exists (see ensure_ffmpeg / download_linux_ffmpeg).
#
# Layout produced (so find_repo_root matches its backend/app + python branch):
# StemDeck-Linux-x64/
# StemDeck # Tauri ELF binary
# cpu-only # marker read by is_cpu_only_package
# README-LINUX.txt
# THIRD_PARTY_NOTICES.txt
# backend/{app,static,pyproject.toml,uv.lock}
# python/{bin/python,lib/pythonX.Y/...} # full PBS install
set -euo pipefail
PACKAGE_NAME="${PACKAGE_NAME:-StemDeck-Linux-x64}"
PACKAGE_VERSION="${PACKAGE_VERSION:-}"
OUTPUT_ROOT="${OUTPUT_ROOT:-dist}"
PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
TORCH_VERSION="${TORCH_VERSION:-2.6.0}"
SKIP_TAURI_BUILD="${SKIP_TAURI_BUILD:-0}"
# CPU_ONLY=1 (default): force the CPU-only torch wheel and mark the package so the
# desktop shell skips GPU detection. CPU_ONLY=0: keep the project's default torch,
# which on Linux x86_64 is the CUDA build (NVIDIA variant) — the shell then detects
# the GPU and uses CUDA at runtime.
CPU_ONLY="${CPU_ONLY:-1}"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
STAGE="${REPO_ROOT}/${OUTPUT_ROOT}/${PACKAGE_NAME}"
ARCHIVE_PATH="${REPO_ROOT}/${OUTPUT_ROOT}/${PACKAGE_NAME}.tar.gz"
CHECKSUM_PATH="${ARCHIVE_PATH}.sha256"
PYTHON_DIR="${STAGE}/python"
BACKEND_DIR="${STAGE}/backend"
TARGET_BIN="${REPO_ROOT}/desktop/src-tauri/target/release/stemdeck"
if [[ "$(uname -s)" != "Linux" ]]; then
echo "ERROR: this packaging script must run on Linux." >&2
exit 1
fi
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "ERROR: required command not found on PATH: $1" >&2
exit 1
fi
}
require_command uv
require_command cargo
require_command node
require_command npm
require_command tar
require_command sha256sum
# python-build-standalone (PBS) Python for x86_64 Linux. Unlike a venv, the
# full install carries its own stdlib under lib/, which the desktop shell needs.
echo "==> Installing python-build-standalone ${PYTHON_VERSION}"
uv python install "cpython-${PYTHON_VERSION}-linux-x86_64-gnu"
PBS_PYTHON="$(uv python find "cpython-${PYTHON_VERSION}-linux-x86_64-gnu")"
PBS_BASE_PREFIX="$("$PBS_PYTHON" -c 'import sys; print(sys.base_prefix)')"
if [[ ! -d "${PBS_BASE_PREFIX}/lib" ]]; then
echo "ERROR: PBS base prefix has no lib/ dir: ${PBS_BASE_PREFIX}" >&2
exit 1
fi
echo "==> Cleaning stage"
rm -rf "$STAGE" "$ARCHIVE_PATH" "$CHECKSUM_PATH"
mkdir -p "$STAGE" "$BACKEND_DIR" "$PYTHON_DIR"
# Copy the entire PBS install into python/ (-a preserves symlinks/permissions).
echo "==> Bundling Python runtime from ${PBS_BASE_PREFIX}"
cp -a "$PBS_BASE_PREFIX/." "$PYTHON_DIR/"
# PBS ships an EXTERNALLY-MANAGED marker that blocks installs into the copy.
find "$PYTHON_DIR/lib" -name "EXTERNALLY-MANAGED" -delete 2>/dev/null || true
BUNDLED_PYTHON="${PYTHON_DIR}/bin/python"
echo "==> Installing StemDeck into bundled Python"
# --system is required because python/ is a full PBS install, not a venv.
uv pip install --system --python "$BUNDLED_PYTHON" pip setuptools wheel
# Version is git-derived (hatch-vcs / setuptools-scm). Pin it so the install
# does not depend on git tags in the build checkout (#169).
if [[ -n "$PACKAGE_VERSION" ]]; then
export SETUPTOOLS_SCM_PRETEND_VERSION="${PACKAGE_VERSION#v}"
fi
uv pip install --system --python "$BUNDLED_PYTHON" "$REPO_ROOT"
# Always bake the small CPU-only torch wheel — for BOTH variants. On Linux the
# default PyPI torch wheel bundles the full CUDA runtime (~2.5 GB), which makes
# the packaged tarball exceed GitHub's 2 GiB per-asset release limit. So we
# mirror what the Windows NVIDIA package actually does: ship CPU torch, and let
# the desktop shell download the matching CUDA wheel at first run on GPU
# machines (install_cuda_torch, gated cfg(not(macos)) so it covers Linux). The
# NVIDIA variant differs only by omitting the cpu-only marker below.
#
# pip strips the local '+cpu' version when resolving, so the project install
# pulls the CUDA wheel even if a CPU wheel was requested; --force-reinstall
# --no-deps replaces just the torch/torchaudio wheels (proven on Windows).
echo "==> Baking CPU-only torch (NVIDIA variant downloads CUDA at first run)"
"$BUNDLED_PYTHON" -m pip install \
"torch==${TORCH_VERSION}+cpu" "torchaudio==${TORCH_VERSION}+cpu" \
--index-url https://download.pytorch.org/whl/cpu \
--force-reinstall --no-deps
# The project install above pulled the default Linux torch, which is the CUDA
# build, dragging in nvidia-* CUDA runtime packages (cuDNN, cuBLAS, NCCL, ...) and
# triton -- together ~2.5 GB. The CPU torch swap used --no-deps, so those packages
# are now orphaned but still installed, bloating the tarball past GitHub's 2 GiB
# asset limit. Remove them: CPU torch does not use them, and the NVIDIA variant
# re-downloads CUDA at first run anyway.
echo "==> Removing orphaned CUDA runtime packages"
orphans=$("$BUNDLED_PYTHON" -m pip list --format=freeze 2>/dev/null \
| sed -n 's/^\(nvidia-[^=]*\)==.*/\1/p')
orphans="$orphans triton"
echo " removing:$orphans"
"$BUNDLED_PYTHON" -m pip uninstall -y $orphans 2>/dev/null || true
echo "==> Verifying imports"
"$BUNDLED_PYTHON" -c "import fastapi, uvicorn, yt_dlp, demucs, torch, torchaudio, librosa, pyloudnorm, soundfile; print('torch', torch.__version__, 'cuda', torch.version.cuda)"
echo "==> Staging backend"
cp -R "$REPO_ROOT/app" "$BACKEND_DIR/app"
cp -R "$REPO_ROOT/static" "$BACKEND_DIR/static"
cp "$REPO_ROOT/pyproject.toml" "$BACKEND_DIR/pyproject.toml"
cp "$REPO_ROOT/uv.lock" "$BACKEND_DIR/uv.lock"
RESOLVED_VERSION="${PACKAGE_VERSION#v}"
printf '{ "version": "%s" }\n' "$RESOLVED_VERSION" > "$BACKEND_DIR/static/version.json"
cp "$REPO_ROOT/packaging/linux/README-LINUX.txt" "$STAGE/README-LINUX.txt"
cp "$REPO_ROOT/packaging/linux/THIRD_PARTY_NOTICES.txt" "$STAGE/THIRD_PARTY_NOTICES.txt"
# CPU-only marker: read by is_cpu_only_package so the shell skips GPU detection.
# Omitted for the NVIDIA variant so the shell detects the GPU and uses CUDA.
if [[ "$CPU_ONLY" == "1" ]]; then
touch "$STAGE/cpu-only"
fi
echo "==> Stripping build-time artifacts from bundled Python"
find "$PYTHON_DIR" -type d -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null || true
find "$PYTHON_DIR" -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete 2>/dev/null || true
TORCH_LIB="${PYTHON_DIR}/lib/python${PYTHON_VERSION}/site-packages/torch"
for rel in include test share/cmake; do
rm -rf "${TORCH_LIB:?}/${rel}" 2>/dev/null || true
done
# Static link archives are only needed to build C++ extensions, never to run.
find "$TORCH_LIB" -name "*.a" -type f -delete 2>/dev/null || true
echo "==> Building Tauri desktop binary"
if [[ "$SKIP_TAURI_BUILD" != "1" ]]; then
pushd "$REPO_ROOT/desktop" >/dev/null
if [[ -f package-lock.json ]]; then
npm ci --include=dev
else
npm install --include=dev
fi
CI=true node node_modules/@tauri-apps/cli/tauri.js build
popd >/dev/null
fi
if [[ ! -f "$TARGET_BIN" ]]; then
echo "ERROR: Tauri binary not found at ${TARGET_BIN}" >&2
exit 1
fi
cp "$TARGET_BIN" "$STAGE/StemDeck"
chmod +x "$STAGE/StemDeck"
echo "==> Creating archive"
tar -czf "$ARCHIVE_PATH" -C "${REPO_ROOT}/${OUTPUT_ROOT}" "$PACKAGE_NAME"
( cd "${REPO_ROOT}/${OUTPUT_ROOT}" && sha256sum "${PACKAGE_NAME}.tar.gz" > "${PACKAGE_NAME}.tar.gz.sha256" )
echo "==> Done"
if [[ "$CPU_ONLY" == "1" ]]; then
echo "Variant : CPU-only"
else
echo "Variant : NVIDIA/CUDA"
fi
echo "Stage : ${STAGE}"
echo "Archive : ${ARCHIVE_PATH}"
echo "Checksum: ${CHECKSUM_PATH}"
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
ARCH="${ARCH:-arm64}"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
# Default the version to the current git tag so local builds match the tag with
# no VERSION passed; falls back to a dev label outside a tagged checkout. (#169)
VERSION="${VERSION:-$(git -C "$REPO_ROOT" describe --tags --always 2>/dev/null || echo 0.0.0-dev)}"
VERSION="${VERSION#v}"
BUILD_DIR="${REPO_ROOT}/.build"
if [[ "$(uname)" != "Darwin" ]]; then
echo "ERROR: make-app.sh must run on macOS" >&2
exit 1
fi
if [[ "$ARCH" != "arm64" && "$ARCH" != "x64" ]]; then
echo "ERROR: ARCH must be arm64 or x64, got '${ARCH}'" >&2
exit 1
fi
for cmd in node npm cargo; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: required command not found on PATH: $cmd" >&2
exit 1
fi
done
mkdir -p "$BUILD_DIR"
echo "==> Stamping version ${VERSION}"
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" "$REPO_ROOT/desktop/src-tauri/Cargo.toml"
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" "$REPO_ROOT/desktop/src-tauri/tauri.conf.json"
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" "$REPO_ROOT/desktop/package.json"
cd "$REPO_ROOT/desktop"
# Tauri CLI reads CI env var as a boolean flag; Woodpecker sets CI=woodpecker
# which fails the boolean parser. Force a valid value.
export CI=true
npm ci
if [[ "$ARCH" == "arm64" ]]; then
npx tauri build --bundles app --target aarch64-apple-darwin
TARGET_DIR="$REPO_ROOT/desktop/src-tauri/target/aarch64-apple-darwin/release"
else
npx tauri build --bundles app --target x86_64-apple-darwin
TARGET_DIR="$REPO_ROOT/desktop/src-tauri/target/x86_64-apple-darwin/release"
fi
APP_DIR="${TARGET_DIR}/bundle/macos/StemDeck.app"
if [[ ! -d "$APP_DIR" ]]; then
APP_DIR="$(find "$REPO_ROOT/desktop/src-tauri/target" -path '*/bundle/macos/StemDeck.app' -type d | head -1)"
fi
if [[ -z "$APP_DIR" || ! -d "$APP_DIR" ]]; then
echo "ERROR: could not find built StemDeck.app" >&2
exit 1
fi
RESOURCES="${APP_DIR}/Contents/Resources"
mkdir -p "$RESOURCES"
if [[ -f "$BUILD_DIR/runtime-manifest-${ARCH}.json" ]]; then
cp "$BUILD_DIR/runtime-manifest-${ARCH}.json" "$RESOURCES/runtime-manifest.json"
else
cp "$REPO_ROOT/desktop/ui/runtime-manifest.json" "$RESOURCES/runtime-manifest.json"
fi
if [[ -f "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" ]]; then
cp "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" "$RESOURCES/THIRD_PARTY_NOTICES.txt"
fi
echo "$APP_DIR" > "$BUILD_DIR/app-path-${ARCH}.txt"
echo "==> App ready: $APP_DIR"
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
set -euo pipefail
ARCH="${ARCH:-arm64}"
VERSION="${VERSION:-LOCAL_DEV_TEST}"
VERSION="${VERSION#v}"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BUILD_DIR="${REPO_ROOT}/.build"
DIST_DIR="${BUILD_DIR}/macos-dist"
DMG_STAGING="${BUILD_DIR}/dmg-staging-${ARCH}"
DMG_NAME="StemDeck-macOS-${ARCH}.dmg"
DMG_PATH="${DIST_DIR}/${DMG_NAME}"
DMG_RW_PATH="${DIST_DIR}/StemDeck-macOS-${ARCH}.rw.dmg"
RUNTIME_NAME="StemDeck-runtime-macOS-${ARCH}.tar.zst"
RUNTIME_PATH="${BUILD_DIR}/${RUNTIME_NAME}"
BACKGROUND_SRC="${REPO_ROOT}/packaging/macos/dmg-background.svg"
BACKGROUND_DIR_NAME=".background"
BACKGROUND_PNG_NAME="dmg-background.png"
if [[ "$(uname)" != "Darwin" ]]; then
echo "ERROR: make-dmg.sh must run on macOS" >&2
exit 1
fi
if [[ "$ARCH" != "arm64" && "$ARCH" != "x64" ]]; then
echo "ERROR: ARCH must be arm64 or x64, got '${ARCH}'" >&2
exit 1
fi
for cmd in ditto hdiutil qlmanage shasum; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: required command not found on PATH: $cmd" >&2
exit 1
fi
done
if [[ "$ARCH" == "arm64" ]]; then
APP_DIR="${REPO_ROOT}/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app"
else
APP_DIR="${REPO_ROOT}/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/StemDeck.app"
fi
if [[ ! -d "$APP_DIR" ]]; then
echo "ERROR: built app not found: $APP_DIR" >&2
echo "Run: ARCH=${ARCH} scripts/macos/make-app.sh" >&2
exit 1
fi
if [[ ! -f "$RUNTIME_PATH" ]]; then
echo "ERROR: runtime pack not found: $RUNTIME_PATH" >&2
echo "Run: ARCH=${ARCH} VERSION=${VERSION} scripts/macos/make-runtime-pack.sh" >&2
exit 1
fi
rm -rf "$DMG_STAGING"
mkdir -p "$DMG_STAGING" "$DIST_DIR"
mkdir -p "$DMG_STAGING/$BACKGROUND_DIR_NAME"
ditto "$APP_DIR" "$DMG_STAGING/StemDeck.app"
ln -s /Applications "$DMG_STAGING/Applications"
if [[ -f "$BACKGROUND_SRC" ]]; then
qlmanage -t -s 1320 -o "$DMG_STAGING/$BACKGROUND_DIR_NAME" "$BACKGROUND_SRC" >/dev/null 2>&1
mv "$DMG_STAGING/$BACKGROUND_DIR_NAME/dmg-background.svg.png" "$DMG_STAGING/$BACKGROUND_DIR_NAME/$BACKGROUND_PNG_NAME"
fi
if [[ -f "$REPO_ROOT/packaging/macos/README-macOS.txt" ]]; then
cp "$REPO_ROOT/packaging/macos/README-macOS.txt" "$DMG_STAGING/README-macOS.txt"
fi
if [[ -f "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" ]]; then
cp "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" "$DMG_STAGING/THIRD_PARTY_NOTICES.txt"
fi
rm -f "$DMG_PATH" "$DMG_RW_PATH"
hdiutil create \
-volname "StemDeck" \
-srcfolder "$DMG_STAGING" \
-ov \
-format UDRW \
"$DMG_RW_PATH"
MOUNT_DIR="$(mktemp -d /tmp/stemdeck-dmg.XXXXXX)"
cleanup_mount() {
hdiutil detach "$MOUNT_DIR" >/dev/null 2>&1 || true
rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
}
trap cleanup_mount EXIT
hdiutil attach "$DMG_RW_PATH" -readwrite -noverify -nobrowse -mountpoint "$MOUNT_DIR" >/dev/null
if command -v SetFile >/dev/null 2>&1; then
SetFile -a V "$MOUNT_DIR/$BACKGROUND_DIR_NAME" || true
SetFile -a V "$MOUNT_DIR/README-macOS.txt" || true
SetFile -a V "$MOUNT_DIR/THIRD_PARTY_NOTICES.txt" || true
fi
if [[ -f "$MOUNT_DIR/$BACKGROUND_DIR_NAME/$BACKGROUND_PNG_NAME" ]]; then
osascript <<APPLESCRIPT || echo "warning: DMG window styling failed (cosmetic only, DMG is still valid)"
tell application "Finder"
set dmgFolder to POSIX file "$MOUNT_DIR" as alias
open dmgFolder
set current view of container window of dmgFolder to icon view
set toolbar visible of container window of dmgFolder to false
set statusbar visible of container window of dmgFolder to false
set bounds of container window of dmgFolder to {100, 100, 760, 500}
set viewOptions to icon view options of container window of dmgFolder
set arrangement of viewOptions to not arranged
set icon size of viewOptions to 104
set text size of viewOptions to 13
set background picture of viewOptions to POSIX file "$MOUNT_DIR/$BACKGROUND_DIR_NAME/$BACKGROUND_PNG_NAME"
set position of item "StemDeck.app" of dmgFolder to {205, 205}
set position of item "Applications" of dmgFolder to {455, 205}
close container window of dmgFolder
open dmgFolder
update dmgFolder without registering applications
delay 1
close container window of dmgFolder
end tell
APPLESCRIPT
fi
sync
hdiutil detach "$MOUNT_DIR" >/dev/null
rmdir "$MOUNT_DIR"
trap - EXIT
hdiutil convert "$DMG_RW_PATH" -format UDZO -imagekey zlib-level=9 -o "$DMG_PATH" >/dev/null
rm -f "$DMG_RW_PATH"
CHECKSUMS_PATH="${DIST_DIR}/SHA256SUMS-macOS-${ARCH}.txt"
{
shasum -a 256 "$DMG_PATH"
shasum -a 256 "$RUNTIME_PATH"
} | sed "s#${REPO_ROOT}/##" > "$CHECKSUMS_PATH"
echo "==> DMG ready: $DMG_PATH"
echo "==> Runtime pack: $RUNTIME_PATH"
echo "==> Checksums: $CHECKSUMS_PATH"
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SOURCE_SVG="${SOURCE_SVG:-${REPO_ROOT}/imgs/stemdeck-svg-assets/stemdeck-icon.svg}"
ICON_DIR="${REPO_ROOT}/desktop/src-tauri/icons"
WORK_DIR="${TMPDIR:-/tmp}/stemdeck-iconset"
RENDER_DIR="${WORK_DIR}/render"
ICONSET_DIR="${WORK_DIR}/icon.iconset"
if [[ "$(uname)" != "Darwin" ]]; then
echo "ERROR: make-iconset.sh must run on macOS" >&2
exit 1
fi
for cmd in qlmanage sips iconutil; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: required command not found on PATH: $cmd" >&2
exit 1
fi
done
if [[ ! -f "$SOURCE_SVG" ]]; then
echo "ERROR: source SVG not found: $SOURCE_SVG" >&2
exit 1
fi
rm -rf "$WORK_DIR"
mkdir -p "$RENDER_DIR" "$ICONSET_DIR" "$ICON_DIR"
qlmanage -t -s 1024 -o "$RENDER_DIR" "$SOURCE_SVG" >/dev/null
RENDERED_PNG="$(find "$RENDER_DIR" -maxdepth 1 -type f -name '*.png' | head -1)"
if [[ -z "$RENDERED_PNG" || ! -f "$RENDERED_PNG" ]]; then
echo "ERROR: qlmanage did not render a PNG from $SOURCE_SVG" >&2
exit 1
fi
cp "$RENDERED_PNG" "$ICON_DIR/icon.png"
for size in 16 32 128 256 512; do
sips -z "$size" "$size" "$ICON_DIR/icon.png" \
--out "$ICONSET_DIR/icon_${size}x${size}.png" >/dev/null
done
sips -z 32 32 "$ICON_DIR/icon.png" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null
sips -z 64 64 "$ICON_DIR/icon.png" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null
sips -z 256 256 "$ICON_DIR/icon.png" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null
sips -z 512 512 "$ICON_DIR/icon.png" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null
cp "$ICON_DIR/icon.png" "$ICONSET_DIR/icon_512x512@2x.png"
iconutil -c icns "$ICONSET_DIR" -o "$ICON_DIR/icon.icns"
echo "==> Icon PNG: $ICON_DIR/icon.png"
echo "==> Icon ICNS: $ICON_DIR/icon.icns"
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env bash
set -euo pipefail
ARCH="${ARCH:-arm64}"
VERSION="${VERSION:-LOCAL_DEV_TEST}"
VERSION="${VERSION#v}"
RELEASE_BASE_URL="${RELEASE_BASE_URL:-https://github.com/stemdeckapp/stemdeck/releases/download/v${VERSION}}"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BUILD_DIR="${REPO_ROOT}/.build"
STAGING="${BUILD_DIR}/runtime-staging-${ARCH}"
RUNTIME_DIR="${STAGING}/runtime"
PYTHON_DIR="${RUNTIME_DIR}/python"
BACKEND_DIR="${RUNTIME_DIR}/backend"
if [[ "$(uname)" != "Darwin" ]]; then
echo "ERROR: make-runtime-pack.sh must run on macOS" >&2
exit 1
fi
if [[ "$ARCH" != "arm64" && "$ARCH" != "x64" ]]; then
echo "ERROR: ARCH must be arm64 or x64, got '${ARCH}'" >&2
exit 1
fi
PYTHON_BIN="${PYTHON_BIN:-}"
if [[ -z "$PYTHON_BIN" ]]; then
for candidate in python3.12 python3.11 python3.10 python3; do
if command -v "$candidate" >/dev/null 2>&1; then
PYTHON_BIN="$candidate"
break
fi
done
fi
for cmd in ditto shasum tar "$PYTHON_BIN"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: required command not found on PATH: $cmd" >&2
exit 1
fi
done
PYTHON_VERSION="$("$PYTHON_BIN" - <<'PY'
import sys
print(f"{sys.version_info.major}.{sys.version_info.minor}")
PY
)"
PYTHON_MACHINE="$("$PYTHON_BIN" - <<'PY'
import platform
print(platform.machine())
PY
)"
case "$PYTHON_VERSION" in
3.10|3.11|3.12|3.13) ;;
*)
echo "ERROR: ${PYTHON_BIN} is Python ${PYTHON_VERSION}; Torch 2.6 runtime builds require Python 3.10-3.13." >&2
echo "Set PYTHON_BIN=/path/to/python3.12 or PYTHON_BIN=/path/to/python3.11." >&2
exit 1
;;
esac
HOST_ARCH="$(uname -m)"
if [[ "$ARCH" == "arm64" && "$PYTHON_MACHINE" != "arm64" ]]; then
echo "ERROR: arm64 runtime requires an arm64 Python, got ${PYTHON_MACHINE}" >&2
exit 1
fi
if [[ "$ARCH" == "x64" && "$PYTHON_MACHINE" != "x86_64" ]]; then
echo "ERROR: x64 runtime requires an x86_64 Python, got ${PYTHON_MACHINE}" >&2
exit 1
fi
if [[ "$ARCH" == "x64" && "$HOST_ARCH" == "arm64" ]]; then
if ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
echo "ERROR: x64 runtime on arm64 hosts requires Rosetta 2." >&2
exit 1
fi
fi
rm -rf "$STAGING"
mkdir -p "$PYTHON_DIR" "$BACKEND_DIR" "$BUILD_DIR"
echo "==> Bundling Python installation (${ARCH})"
echo "==> Python: $("$PYTHON_BIN" --version)"
echo "==> Python architecture: ${PYTHON_MACHINE}"
# Get the full PBS (python-build-standalone) installation root.
# This directory has the complete stdlib in lib/pythonX.Y/ — unlike a venv,
# which only creates site-packages/ and relies on the original base_prefix
# (a path that won't exist on user machines) for stdlib.
PYTHON_BASE_PREFIX="$("$PYTHON_BIN" - <<'PY'
import sys
print(sys.base_prefix)
PY
)"
echo "==> Python base prefix: ${PYTHON_BASE_PREFIX}"
if [[ ! -d "${PYTHON_BASE_PREFIX}/lib" ]]; then
echo "ERROR: Python base prefix has no lib/ dir: ${PYTHON_BASE_PREFIX}" >&2
echo " Make sure PYTHON_BIN points to a python-build-standalone (UV) Python." >&2
exit 1
fi
# Verify the stdlib is actually present in base_prefix before copying.
STDLIB_CHECK="$("$PYTHON_BIN" - <<'PY'
import sys, pathlib
ver = f"python{sys.version_info.major}.{sys.version_info.minor}"
p = pathlib.Path(sys.base_prefix) / "lib" / ver / "encodings" / "__init__.py"
print("ok" if p.is_file() else f"missing:{p}")
PY
)"
if [[ "$STDLIB_CHECK" != "ok" ]]; then
echo "ERROR: stdlib not found in base_prefix (${STDLIB_CHECK})" >&2
echo " base_prefix: ${PYTHON_BASE_PREFIX}" >&2
exit 1
fi
# Copy the entire PBS Python installation into the runtime bundle.
# ditto preserves symlinks, HFS+ metadata, and extended attributes.
ditto "$PYTHON_BASE_PREFIX" "$PYTHON_DIR"
# PBS Python ships an EXTERNALLY-MANAGED marker that blocks uv from installing
# packages into it. Remove it so we can treat this copy as our own install.
find "$PYTHON_DIR/lib" -name "EXTERNALLY-MANAGED" -delete
echo "==> Installing packages into bundled Python"
# --system is required because $PYTHON_DIR is not a venv (it's a full Python install).
uv pip install --system --python "$PYTHON_DIR/bin/python" pip setuptools wheel
# The project version is git-derived (hatch-vcs). Pin it explicitly from $VERSION
# so the install doesn't depend on git tags being present in the build checkout (#169).
SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION#v}" \
uv pip install --system --python "$PYTHON_DIR/bin/python" "$REPO_ROOT"
echo "==> Verifying stdlib and imports"
PYTHON_DIR="$PYTHON_DIR" PYTHONHOME="$PYTHON_DIR" "$PYTHON_DIR/bin/python" - <<'PY'
import importlib, os, pathlib, sys
ver = f"python{sys.version_info.major}.{sys.version_info.minor}"
stdlib = pathlib.Path(os.environ["PYTHON_DIR"]) / "lib" / ver
if not (stdlib / "encodings" / "__init__.py").is_file():
print(f"ERROR: encodings not found in {stdlib}", file=sys.stderr)
sys.exit(1)
print(f" stdlib OK at {stdlib}")
packages = [
"fastapi", "uvicorn", "yt_dlp", "demucs", "torch", "torchaudio",
"librosa", "pyloudnorm", "soundfile",
]
for package in packages:
importlib.import_module(package)
print(f" OK {package}")
PY
echo "==> Staging backend"
cp -R "$REPO_ROOT/app" "$BACKEND_DIR/app"
cp -R "$REPO_ROOT/static" "$BACKEND_DIR/static"
cp "$REPO_ROOT/pyproject.toml" "$BACKEND_DIR/pyproject.toml"
cp "$REPO_ROOT/uv.lock" "$BACKEND_DIR/uv.lock"
cat > "$BACKEND_DIR/static/version.json" <<JSON
{
"version": "${VERSION}",
"arch": "${ARCH}"
}
JSON
echo "==> Capturing dependency inventory"
mkdir -p "$RUNTIME_DIR/licenses"
uv pip list --system --python "$PYTHON_DIR/bin/python" --format=json > "$RUNTIME_DIR/licenses/pip-list.json"
cat > "$RUNTIME_DIR/runtime-manifest.json" <<JSON
{
"version": "${VERSION}",
"arch": "${ARCH}",
"createdBy": "scripts/macos/make-runtime-pack.sh"
}
JSON
echo "==> Stripping Python caches"
find "$PYTHON_DIR" -type d -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null || true
find "$PYTHON_DIR" -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete
ARCHIVE_NAME="StemDeck-runtime-macOS-${ARCH}.tar.zst"
ARCHIVE_PATH="${BUILD_DIR}/${ARCHIVE_NAME}"
if command -v zstd >/dev/null 2>&1; then
tar --zstd -cf "$ARCHIVE_PATH" -C "$STAGING" runtime
else
ARCHIVE_NAME="StemDeck-runtime-macOS-${ARCH}.tar.gz"
ARCHIVE_PATH="${BUILD_DIR}/${ARCHIVE_NAME}"
tar -czf "$ARCHIVE_PATH" -C "$STAGING" runtime
fi
SIZE="$(stat -f%z "$ARCHIVE_PATH")"
SHA256="$(shasum -a 256 "$ARCHIVE_PATH" | awk '{print $1}')"
RUNTIME_URL="${RELEASE_BASE_URL}/${ARCHIVE_NAME}"
cat > "${BUILD_DIR}/runtime-manifest-${ARCH}.json" <<JSON
{
"version": "${VERSION}",
"arch": "${ARCH}",
"runtimeUrl": "${RUNTIME_URL}",
"runtimeSha256": "${SHA256}",
"runtimeSize": ${SIZE},
"archiveName": "${ARCHIVE_NAME}"
}
JSON
echo "==> Runtime pack ready"
echo "Archive: ${ARCHIVE_PATH}"
echo "Size: ${SIZE}"
echo "SHA256: ${SHA256}"
echo "Manifest: ${BUILD_DIR}/runtime-manifest-${ARCH}.json"
+264
View File
@@ -0,0 +1,264 @@
param(
[string]$Configuration = "release",
[string]$OutputRoot = "dist",
[string]$PackageName = "StemDeck-Windows-x64",
[string]$PackageVersion,
[switch]$SkipTauriBuild,
[switch]$CpuOnly,
[switch]$StripVenv
)
$ErrorActionPreference = "Stop"
$PSNativeCommandErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
if ($env:OS -ne "Windows_NT") {
throw "This packaging script must run on Windows."
}
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$Stage = Join-Path $Root "$OutputRoot\$PackageName"
$ZipPath = Join-Path $Root "$OutputRoot\$PackageName.zip"
$ChecksumPath = "$ZipPath.sha256"
$PythonDir = Join-Path $Stage "python"
$PythonExe = Join-Path $PythonDir "Scripts\python.exe"
$BackendDir = Join-Path $Stage "backend"
$DesktopDir = Join-Path $Root "desktop"
$TauriDir = Join-Path $DesktopDir "src-tauri"
$TargetExe = Join-Path $TauriDir "target\$Configuration\stemdeck.exe"
function Require-Command([string]$Name) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required command not found on PATH: $Name"
}
}
function Copy-Tree([string]$Source, [string]$Destination) {
if (Test-Path $Destination) {
Remove-Item -Recurse -Force $Destination
}
Copy-Item -Recurse -Force $Source $Destination
}
function Copy-TreeContents([string]$Source, [string]$Destination, [string[]]$ExcludeNames = @()) {
New-Item -ItemType Directory -Force $Destination | Out-Null
Get-ChildItem -LiteralPath $Source -Force |
Where-Object { $ExcludeNames -notcontains $_.Name } |
ForEach-Object {
Copy-Item -LiteralPath $_.FullName -Destination $Destination -Recurse -Force
}
}
function Set-PyvenvValue([string]$ConfigPath, [string]$Key, [string]$Value) {
$content = Get-Content -LiteralPath $ConfigPath
$pattern = "^\s*$([regex]::Escape($Key))\s*="
$line = "$Key = $Value"
$found = $false
$updated = foreach ($entry in $content) {
if ($entry -match $pattern) {
$found = $true
$line
} else {
$entry
}
}
if (-not $found) {
$updated += $line
}
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllLines($ConfigPath, [string[]]$updated, $utf8NoBom)
}
function Get-PackageVersion {
if ($PackageVersion) {
return $PackageVersion.TrimStart("v")
}
$tauriConfig = Get-Content -LiteralPath (Join-Path $TauriDir "tauri.conf.json") -Raw |
ConvertFrom-Json
return [string]$tauriConfig.version
}
function Bundle-PythonRuntime([string]$VenvDir, [string]$VenvPython) {
$baseExecutable = (& $VenvPython -c "import sys; print(getattr(sys, '_base_executable', sys.executable))").Trim()
if (-not (Test-Path $baseExecutable)) {
throw "Could not locate base Python executable: $baseExecutable"
}
$baseHome = Split-Path -Parent $baseExecutable
$portableBaseHome = Join-Path $VenvDir "base"
$baseLib = Join-Path $baseHome "Lib"
$baseDlls = Join-Path $baseHome "DLLs"
if (-not (Test-Path $baseLib)) {
throw "Could not locate base Python standard library: $baseLib"
}
Write-Host "Bundling Python runtime from $baseHome..."
New-Item -ItemType Directory -Force $portableBaseHome | Out-Null
Copy-Item -Force $baseExecutable (Join-Path $portableBaseHome "python.exe")
$basePythonw = Join-Path $baseHome "pythonw.exe"
if (Test-Path $basePythonw) {
Copy-Item -Force $basePythonw (Join-Path $portableBaseHome "pythonw.exe")
}
Get-ChildItem -LiteralPath $baseHome -Filter "*.dll" -File -Force |
Copy-Item -Destination $portableBaseHome -Force
if (Test-Path $baseDlls) {
Copy-Tree $baseDlls (Join-Path $portableBaseHome "DLLs")
}
Copy-TreeContents $baseLib (Join-Path $portableBaseHome "Lib") @("site-packages")
$cfg = Join-Path $VenvDir "pyvenv.cfg"
Set-PyvenvValue $cfg "home" $portableBaseHome
Set-PyvenvValue $cfg "executable" (Join-Path $portableBaseHome "python.exe")
}
function Invoke-TauriBuild {
$TauriCli = Join-Path $DesktopDir "node_modules\@tauri-apps\cli\tauri.js"
if (-not (Test-Path $TauriCli)) {
throw "Tauri CLI not found at $TauriCli. npm install/ci may have omitted devDependencies."
}
& node $TauriCli build
}
function Assert-Fresh-TauriBuild {
if (-not (Test-Path $TargetExe)) {
throw "Tauri executable not found at $TargetExe. Remove -SkipTauriBuild or build the NVIDIA package first."
}
$exe = Get-Item $TargetExe
$newerSources = @(
Get-ChildItem -Path (Join-Path $DesktopDir "ui") -File -Recurse
Get-ChildItem -Path (Join-Path $TauriDir "src") -File -Recurse
Get-Item (Join-Path $TauriDir "Cargo.toml")
Get-Item (Join-Path $TauriDir "tauri.conf.json")
) | Where-Object { $_.LastWriteTimeUtc -gt $exe.LastWriteTimeUtc }
if ($newerSources.Count -gt 0) {
$list = ($newerSources | Select-Object -First 8 | ForEach-Object { " - $($_.FullName)" }) -join "`n"
throw @"
-SkipTauriBuild would package a stale StemDeck.exe.
The existing executable is older than desktop UI/Tauri source files:
$list
Remove -SkipTauriBuild or run the NVIDIA package build first so the CPU package reuses a fresh executable.
"@
}
}
Require-Command "node"
Require-Command "npm"
Require-Command "cargo"
if (-not (Get-Command "py" -ErrorAction SilentlyContinue) -and -not (Get-Command "python" -ErrorAction SilentlyContinue)) {
throw "Python launcher not found. Install Python 3.12 on the Windows build agent."
}
if (Test-Path $Stage) {
Remove-Item -Recurse -Force $Stage
}
if (Test-Path $ZipPath) {
Remove-Item -Force $ZipPath
}
if (Test-Path $ChecksumPath) {
Remove-Item -Force $ChecksumPath
}
New-Item -ItemType Directory -Force $Stage | Out-Null
New-Item -ItemType Directory -Force $BackendDir | Out-Null
New-Item -ItemType Directory -Force (Join-Path $Stage "data") | Out-Null
foreach ($Dir in @("cache", "downloads", "ffmpeg", "jobs", "logs", "models")) {
New-Item -ItemType Directory -Force (Join-Path $Stage "data\$Dir") | Out-Null
}
if ($CpuOnly) {
New-Item -ItemType File -Force (Join-Path $Stage "cpu-only") | Out-Null
New-Item -ItemType File -Force (Join-Path $Stage "data\cpu-only") | Out-Null
}
Copy-Tree (Join-Path $Root "app") (Join-Path $BackendDir "app")
Copy-Tree (Join-Path $Root "static") (Join-Path $BackendDir "static")
$PackageVersion = Get-PackageVersion
$VersionJson = @{ version = $PackageVersion } | ConvertTo-Json -Compress
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText((Join-Path $BackendDir "static\version.json"), $VersionJson + "`n", $utf8NoBom)
Copy-Item -Force (Join-Path $Root "pyproject.toml") (Join-Path $BackendDir "pyproject.toml")
Copy-Item -Force (Join-Path $Root "uv.lock") (Join-Path $BackendDir "uv.lock")
Copy-Item -Force (Join-Path $Root "packaging\windows\README-WINDOWS.txt") (Join-Path $Stage "README-WINDOWS.txt")
Copy-Item -Force (Join-Path $Root "packaging\windows\THIRD_PARTY_NOTICES.txt") (Join-Path $Stage "THIRD_PARTY_NOTICES.txt")
if (Get-Command "py" -ErrorAction SilentlyContinue) {
& py -3.12 -m venv $PythonDir
} else {
& python -m venv $PythonDir
}
& $PythonExe -m pip install --upgrade pip
# The project version is git-derived (hatch-vcs). Pin it from $PackageVersion so
# the install doesn't depend on git tags in the build checkout (#169).
if ($PackageVersion) {
$env:SETUPTOOLS_SCM_PRETEND_VERSION = ($PackageVersion -replace '^v', '')
}
& $PythonExe -m pip install "$Root"
if ($CpuOnly) {
# pip strips local version identifiers when resolving requirements, so it installs
# the CUDA wheel from PyPI even when we pre-install the CPU wheel. Force-reinstall
# after the fact: uninstalls CUDA torch and replaces it with the CPU-only variant.
& $PythonExe -m pip install torch==2.6.0+cpu torchaudio==2.6.0+cpu `
--index-url https://download.pytorch.org/whl/cpu `
--force-reinstall --no-deps
}
& $PythonExe -c "import fastapi, uvicorn, yt_dlp, demucs, torch, torchaudio, librosa, pyloudnorm, soundfile"
Bundle-PythonRuntime $PythonDir $PythonExe
& $PythonExe -c "import sys, fastapi, uvicorn; print('Portable Python:', sys.executable)"
if ($StripVenv) {
Write-Host "Stripping venv of build-time artifacts..."
Get-ChildItem -Path $PythonDir -Filter "__pycache__" -Recurse -Directory -Force |
Remove-Item -Recurse -Force
foreach ($rel in @("torch\include", "torch\share\cmake", "torch\test")) {
$p = Join-Path $PythonDir "Lib\site-packages\$rel"
if (Test-Path $p) { Remove-Item -Recurse -Force $p }
}
# Remove C++ static link libraries from torch — needed only for building C++ extensions,
# never for running Python. dnnl.lib alone is ~623 MB.
Get-ChildItem -Path (Join-Path $PythonDir "Lib\site-packages\torch") `
-Filter "*.lib" -Recurse -File -Force |
Remove-Item -Force
}
Push-Location $DesktopDir
try {
if (Test-Path "package-lock.json") {
npm ci --include=dev
} else {
npm install --include=dev
}
if (-not $SkipTauriBuild) {
$env:CI = "true" # Woodpecker sets CI=woodpecker; Tauri only accepts true/false
rustup default stable
Invoke-TauriBuild
} else {
Assert-Fresh-TauriBuild
}
} finally {
Pop-Location
}
if (-not (Test-Path $TargetExe)) {
throw "Tauri executable not found at $TargetExe"
}
Copy-Item -Force $TargetExe (Join-Path $Stage "StemDeck.exe")
Compress-Archive -Path (Join-Path $Stage "*") -DestinationPath $ZipPath -Force
$Hash = Get-FileHash -Algorithm SHA256 $ZipPath
Set-Content -Path $ChecksumPath -Value "$($Hash.Hash) $PackageName.zip"
$Variant = if ($CpuOnly) { "CPU-only" } else { "CUDA/GPU (NVIDIA)" }
Write-Host "Variant : $Variant"
Write-Host "Staged at : $Stage"
Write-Host "Zip created : $ZipPath"
Write-Host "Checksum : $ChecksumPath"