From 01698bc29a2a03a754f9d92b059b039fe434bb4b Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:04:06 +0100 Subject: [PATCH] feat(linux): add an optional installer for desktop integration Implements #342. StemDeck stays portable: extract the tarball, run ./StemDeck, and none of this is required. install.sh is there for people who would rather launch from their applications menu. It installs the package it sits in and never downloads anything, so the version and the CPU/NVIDIA variant come from the package itself (backend/static/version.json and the cpu-only marker) and cannot drift from the build being installed. That also removes any need to verify a second download. Design notes, mostly things the reference installer in #342 got wrong: - Install is atomic. The new copy goes to .new and is verified before the old one is moved aside, so a failure partway leaves the working install untouched. Removing the old copy first is what made a failed upgrade in that fork leave the machine with no StemDeck, no launcher and no manifest recording where it had been. - A failed copy cleans up its own staging directory rather than leaving a package-sized partial on disk. - Exec is quoted, so an install path containing a space still launches. - Version comparison is semver-aware. sort -V ranks 0.8.0-alpha.17 above 0.8.0, which would tell every pre-release user they were current the day a stable release shipped. - Reading a missing manifest key yields empty rather than killing the script, which under set -euo pipefail is what a grep|head|cut pipeline does. - Global installs put the launcher in /usr/share/applications and the icon in /usr/share/pixmaps, so other users on the machine can see it. - Installing from inside the destination is refused rather than moving the running script out from under bash. - Non-x86_64 machines get a clear refusal instead of a binary that cannot run. User data is never touched. Stems live in ~/Documents/StemDeck and the runtime, models and logs in $XDG_DATA_HOME/stemdeck, both outside the install directory. Legacy data/ from pre-migration builds is carried across an upgrade, and uninstall refuses to delete it, leaving the folder and saying why. tests/linux/test_install_sh.sh runs the real installer against a synthetic package in a throwaway HOME: 52 checks covering install, upgrade, the failed-upgrade case, uninstall, corrupt manifests, paths with spaces, legacy data, self-install, arch refusal and the semver table. CI runs it on Linux with shellcheck and desktop-file-validate. Closes #361 --- .github/workflows/ci.yml | 12 + packaging/linux/README-LINUX.txt | 32 +- packaging/linux/install.sh | 515 +++++++++++++++++++++++++++++++ scripts/linux/make-portable.sh | 7 + tests/linux/test_install_sh.sh | 342 ++++++++++++++++++++ tests/test_packaging_linux.py | 22 +- 6 files changed, 927 insertions(+), 3 deletions(-) create mode 100755 packaging/linux/install.sh create mode 100644 tests/linux/test_install_sh.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 922cdf08..580b9204 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,18 @@ jobs: - run: for f in static/js/*.js; do node --check "$f"; done - run: for f in tests/js/*.test.mjs; do node "$f"; done + # The Linux installer only ever runs on a user's machine, so nothing else + # would catch a regression in it. Runs on a real Linux image rather than the + # macOS bash used during development. + linux-installer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + - run: bash -n packaging/linux/install.sh + - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils + - run: shellcheck -S warning packaging/linux/install.sh + - run: bash tests/linux/test_install_sh.sh + sast-bandit: runs-on: ubuntu-latest container: diff --git a/packaging/linux/README-LINUX.txt b/packaging/linux/README-LINUX.txt index 1529c43f..5847b13f 100644 --- a/packaging/linux/README-LINUX.txt +++ b/packaging/linux/README-LINUX.txt @@ -18,6 +18,33 @@ Run ./StemDeck 4. Let first-run setup prepare local runtime assets. +Nothing needs installing: this folder can be moved or deleted at will. + +Optional: add a desktop launcher +-------------------------------- + +If you would rather launch StemDeck from your applications menu than from a +terminal, run the installer that ships in this folder: + + ./install.sh + +It copies this folder to a fixed location and adds an icon and a menu entry. +It never downloads anything; the version and the CPU/NVIDIA variant come from +the package you already extracted. + + ./install.sh --local just me (~/.local/opt/stemdeck) + ./install.sh --global all users (/opt/stemdeck, needs sudo) + ./install.sh --prefix DIR somewhere else + ./install.sh --uninstall remove it again + ./install.sh --help all options + +To upgrade, extract the new tarball and run ./install.sh from it. It reuses the +location you chose and replaces the old copy only once the new one is verified, +so a failed upgrade leaves the working version alone. + +Uninstalling removes the application only. Your stem library and settings live +outside the install directory and are never touched. + Prerequisites ------------- @@ -57,8 +84,9 @@ the CUDA download entirely. Notes ----- -- This is a portable folder, not a system package. No .desktop entry, service, - or package-manager integration is created. +- This is a portable folder, not a system package. Running it in place creates + no .desktop entry, service, or package-manager integration; ./install.sh adds + a launcher and icon if you want them. - User data lives under $XDG_DATA_HOME/stemdeck (or ~/.local/share/stemdeck). - Your stem library is written to ~/Documents/StemDeck/. - Demucs model weights download from the backend on first use into the data diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh new file mode 100755 index 00000000..b126555d --- /dev/null +++ b/packaging/linux/install.sh @@ -0,0 +1,515 @@ +#!/usr/bin/env bash +# +# StemDeck Linux installer (#342). +# +# Ships inside the portable tarball and installs the package it sits in, so it +# never downloads anything: the version and the CPU/NVIDIA variant are read out +# of the package, which means the installer and the build can never disagree +# about what is being installed. +# +# Desktop integration is the whole point. StemDeck stays portable -- extract the +# tarball and run ./StemDeck and nothing here is required. +# +# Usage: +# ./install.sh install, or upgrade an existing install in place +# ./install.sh --global install to /opt/stemdeck (needs sudo) +# ./install.sh --local install to ~/.local/opt/stemdeck +# ./install.sh --prefix DIR install to DIR/stemdeck +# ./install.sh --uninstall remove what the manifest records +# ./install.sh --yes never prompt +# +# User data is never touched by any of this. Stems live in ~/Documents/StemDeck +# and the runtime, models, ffmpeg and logs in ~/.local/share/stemdeck (or +# $XDG_DATA_HOME/stemdeck); neither is inside the install directory. + +set -euo pipefail + +PKG_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/stemdeck" +MANIFEST="${CONFIG_DIR}/install-manifest" + +GLOBAL_APPS="/usr/share/applications" +GLOBAL_ICONS="/usr/share/pixmaps" +LOCAL_APPS="${XDG_DATA_HOME:-$HOME/.local/share}/applications" +LOCAL_ICONS="${XDG_DATA_HOME:-$HOME/.local/share}/icons" + +ASSUME_YES=0 +MODE="install" +WANT_SCOPE="" +WANT_PREFIX="" + +# --------------------------------------------------------------------------- +# Output +# --------------------------------------------------------------------------- + +info() { printf '==> %s\n' "$*"; } +warn() { printf 'warning: %s\n' "$*" >&2; } +die() { printf '\nerror: %s\n\n' "$*" >&2; exit 1; } + +confirm() { + local prompt="$1" answer + [[ $ASSUME_YES -eq 1 ]] && return 0 + while true; do + read -r -p "$prompt [y/n]: " answer || return 1 + case "$answer" in + y|Y|yes|YES) return 0 ;; + n|N|no|NO) return 1 ;; + *) echo "Please answer y or n." ;; + esac + done +} + +# sudo only where it is actually needed, and only after telling the user why. +run_privileged() { + if [[ $EUID -eq 0 ]]; then + "$@" + else + sudo "$@" + fi +} + +# --------------------------------------------------------------------------- +# Manifest +# +# Deliberately not `grep | head | cut`: under `set -euo pipefail` a key that is +# not present makes grep exit 1, which kills the script with no message and +# makes the "corrupt manifest" handling below unreachable. A missing key must +# read as empty. +# --------------------------------------------------------------------------- + +manifest_value() { + local key="$1" line + [[ -f "$MANIFEST" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" == "${key}="* ]]; then + printf '%s\n' "${line#*=}" + return 0 + fi + done < "$MANIFEST" + return 0 +} + +# --------------------------------------------------------------------------- +# Version comparison +# +# Semver, not `sort -V`: sort -V orders 0.8.0-alpha.17 above 0.8.0, so once a +# stable release ships every pre-release user would be told they are current. +# --------------------------------------------------------------------------- + +_ver_core() { printf '%s' "${1%%-*}"; } +_ver_pre() { case "$1" in *-*) printf '%s' "${1#*-}" ;; *) printf '' ;; esac; } +_is_num() { [[ "$1" =~ ^[0-9]+$ ]]; } + +# Prints -1, 0 or 1 for $1 relative to $2. +version_cmp() { + local a="$1" b="$2" i x y + local -a A B AP BP + + IFS=. read -r -a A <<< "$(_ver_core "$a")" + IFS=. read -r -a B <<< "$(_ver_core "$b")" + for i in 0 1 2; do + x="${A[i]:-0}"; x="${x//[!0-9]/}"; x="${x:-0}" + y="${B[i]:-0}"; y="${y//[!0-9]/}"; y="${y:-0}" + if (( 10#$x > 10#$y )); then echo 1; return 0; fi + if (( 10#$x < 10#$y )); then echo -1; return 0; fi + done + + local ap bp + ap="$(_ver_pre "$a")" + bp="$(_ver_pre "$b")" + if [[ -z "$ap" && -z "$bp" ]]; then echo 0; return 0; fi + # A version with no pre-release tag outranks one that has it: 0.8.0 > 0.8.0-alpha.17. + if [[ -z "$ap" ]]; then echo 1; return 0; fi + if [[ -z "$bp" ]]; then echo -1; return 0; fi + + IFS=. read -r -a AP <<< "$ap" + IFS=. read -r -a BP <<< "$bp" + local n=${#AP[@]} + (( ${#BP[@]} > n )) && n=${#BP[@]} + for (( i = 0; i < n; i++ )); do + x="${AP[i]-}" + y="${BP[i]-}" + # A shorter identifier list sorts first: alpha < alpha.1. + if [[ -z "$x" ]]; then echo -1; return 0; fi + if [[ -z "$y" ]]; then echo 1; return 0; fi + if _is_num "$x" && _is_num "$y"; then + if (( 10#$x > 10#$y )); then echo 1; return 0; fi + if (( 10#$x < 10#$y )); then echo -1; return 0; fi + else + if [[ "$x" > "$y" ]]; then echo 1; return 0; fi + if [[ "$x" < "$y" ]]; then echo -1; return 0; fi + fi + done + echo 0 +} + +# --------------------------------------------------------------------------- +# Reading the package +# --------------------------------------------------------------------------- + +package_version() { + local file="${PKG_DIR}/backend/static/version.json" line + [[ -f "$file" ]] || { printf 'unknown'; return 0; } + # Avoid a jq dependency; the file is written by make-portable.sh as + # { "version": "0.8.0-alpha.17" }. + line="$(tr -d ' \t\n' < "$file")" + line="${line#*\"version\":\"}" + line="${line%%\"*}" + [[ -n "$line" ]] || line="unknown" + printf '%s' "$line" +} + +package_variant() { + if [[ -f "${PKG_DIR}/cpu-only" ]]; then printf 'CPU'; else printf 'NVIDIA'; fi +} + +verify_package() { + [[ -x "${PKG_DIR}/StemDeck" || -f "${PKG_DIR}/StemDeck" ]] \ + || die "this does not look like a StemDeck package: no StemDeck executable next to the installer." + [[ -d "${PKG_DIR}/backend/app" ]] \ + || die "the package is incomplete: backend/app is missing." + [[ -d "${PKG_DIR}/python" ]] \ + || die "the package is incomplete: the bundled Python runtime is missing." + [[ -f "${PKG_DIR}/packaging/stemdeck.png" && -f "${PKG_DIR}/packaging/stemdeck.desktop.in" ]] \ + || die "the package is incomplete: packaging/ assets are missing." + + # STEMDECK_INSTALL_ARCH overrides the detected machine type, for the test + # suite and for anyone deliberately installing under an x86_64 emulation + # layer. Everything else gets a clear refusal rather than a binary that + # cannot run. + local arch + arch="${STEMDECK_INSTALL_ARCH:-$(uname -m)}" + if [[ "$arch" != "x86_64" ]]; then + die "StemDeck ships x86_64 binaries only; this machine reports ${arch}." + fi +} + +# --------------------------------------------------------------------------- +# Where to install +# --------------------------------------------------------------------------- + +resolve_scope() { + local choice + if [[ -n "$WANT_PREFIX" ]]; then + SCOPE="Custom" + INSTALL_DIR="${WANT_PREFIX%/}/stemdeck" + return 0 + fi + case "$WANT_SCOPE" in + global) SCOPE="Global"; INSTALL_DIR="/opt/stemdeck"; return 0 ;; + local) SCOPE="Local"; INSTALL_DIR="${HOME}/.local/opt/stemdeck"; return 0 ;; + esac + + if [[ $ASSUME_YES -eq 1 ]]; then + SCOPE="Local"; INSTALL_DIR="${HOME}/.local/opt/stemdeck"; return 0 + fi + + echo + echo "Where should StemDeck be installed?" + echo + echo " 1) Just me ${HOME}/.local/opt/stemdeck" + echo " 2) All users /opt/stemdeck (needs sudo)" + echo " 3) Somewhere else" + echo + while true; do + read -r -p "Choice [1-3] (default 1): " choice + case "${choice:-1}" in + 1) SCOPE="Local"; INSTALL_DIR="${HOME}/.local/opt/stemdeck"; return 0 ;; + 2) SCOPE="Global"; INSTALL_DIR="/opt/stemdeck"; return 0 ;; + 3) + local dir + read -r -p "Directory: " dir + dir="${dir%/}" + [[ -n "$dir" ]] || { echo "Enter a directory."; continue; } + # A tilde typed at this prompt arrives as a literal character: + # `read` does no expansion. Matching it is the point, so the + # usual "tilde does not expand in quotes" warning is inverted. + # shellcheck disable=SC2088 + case "$dir" in "~") dir="$HOME" ;; "~/"*) dir="${HOME}/${dir#\~/}" ;; esac + [[ "$dir" = /* ]] || { echo "Use an absolute path."; continue; } + SCOPE="Custom"; INSTALL_DIR="${dir}/stemdeck"; return 0 ;; + *) echo "Enter 1, 2 or 3." ;; + esac + done +} + +# Global installs put the launcher and icon where every user can see them. +# Getting this wrong is invisible to the person installing and broken for +# everyone else on the machine. +set_integration_paths() { + if [[ "$SCOPE" == "Global" ]]; then + DESKTOP_FILE="${GLOBAL_APPS}/stemdeck.desktop" + ICON_FILE="${GLOBAL_ICONS}/stemdeck.png" + else + DESKTOP_FILE="${LOCAL_APPS}/stemdeck.desktop" + ICON_FILE="${LOCAL_ICONS}/stemdeck.png" + fi +} + +privileged_for_scope() { + if [[ "$SCOPE" == "Global" ]]; then run_privileged "$@"; else "$@"; fi +} + +# --------------------------------------------------------------------------- +# Install / upgrade +# --------------------------------------------------------------------------- + +# Copy into .new, prove it, then swap. A half-finished copy -- a full +# disk, a snapped network mount -- must never be able to leave the machine with +# no working StemDeck, which is what removing the old copy first would risk. +stage_and_swap() { + local staging="${INSTALL_DIR}.new" + local previous="${INSTALL_DIR}.old" + + privileged_for_scope rm -rf "$staging" "$previous" + privileged_for_scope mkdir -p "$(dirname "$INSTALL_DIR")" + + # Every failure below has to clean up after itself. Under `set -e` a bare + # `cp` that dies partway would abort the script on the spot, leaving a + # half-written copy the size of the package sitting on the disk. + discard_staging() { privileged_for_scope rm -rf "$staging"; } + + info "Copying the package to ${INSTALL_DIR} ..." + if ! privileged_for_scope cp -a "$PKG_DIR" "$staging"; then + discard_staging + die "could not copy the package into place; nothing was changed." + fi + if ! privileged_for_scope chmod +x "${staging}/StemDeck"; then + discard_staging + die "could not make the copied executable runnable; nothing was changed." + fi + if [[ ! -x "${staging}/StemDeck" || ! -d "${staging}/backend/app" || ! -d "${staging}/python" ]]; then + discard_staging + die "the copy came out incomplete; nothing was changed." + fi + + if [[ -e "$INSTALL_DIR" ]]; then + # Pre-migration builds kept user data inside the install directory. The + # app moves it out on first launch, so a copy that has never run a + # current build can still hold the only copy of someone's work. + if [[ -d "${INSTALL_DIR}/data" ]]; then + info "Carrying forward legacy data/ from the previous install" + privileged_for_scope cp -a "${INSTALL_DIR}/data" "${staging}/data" + fi + privileged_for_scope mv "$INSTALL_DIR" "$previous" + fi + + if ! privileged_for_scope mv "$staging" "$INSTALL_DIR"; then + # Put the old one back rather than leaving the user with nothing. + [[ -e "$previous" ]] && privileged_for_scope mv "$previous" "$INSTALL_DIR" + die "could not move the new install into place; the previous install was restored." + fi + privileged_for_scope rm -rf "$previous" +} + +install_integration() { + local tmp + info "Installing the desktop entry ..." + + privileged_for_scope mkdir -p "$(dirname "$ICON_FILE")" "$(dirname "$DESKTOP_FILE")" + privileged_for_scope cp "${INSTALL_DIR}/packaging/stemdeck.png" "$ICON_FILE" + privileged_for_scope chmod 644 "$ICON_FILE" + + tmp="$(mktemp)" + # The template quotes Exec, so an install path containing a space still + # launches. sed with | as the delimiter keeps / in paths from ending it. + sed -e "s|@EXEC@|${INSTALL_DIR}/StemDeck|g" \ + -e "s|@ICON@|${ICON_FILE}|g" \ + "${INSTALL_DIR}/packaging/stemdeck.desktop.in" > "$tmp" + privileged_for_scope cp "$tmp" "$DESKTOP_FILE" + privileged_for_scope chmod 644 "$DESKTOP_FILE" + rm -f "$tmp" + + if command -v desktop-file-validate >/dev/null 2>&1; then + desktop-file-validate "$DESKTOP_FILE" \ + || warn "the desktop entry did not validate cleanly; the launcher may not appear." + fi + if command -v update-desktop-database >/dev/null 2>&1; then + privileged_for_scope update-desktop-database "$(dirname "$DESKTOP_FILE")" >/dev/null 2>&1 || true + fi +} + +write_manifest() { + mkdir -p "$CONFIG_DIR" + # Written last: it is the record that the install completed, so a failure + # anywhere above must not leave one behind claiming otherwise. + cat > "$MANIFEST" </dev/null 2>&1; then + privileged_for_scope update-desktop-database "$(dirname "$desktop")" >/dev/null 2>&1 || true + fi + + rm -f "$MANIFEST" + rmdir "$CONFIG_DIR" 2>/dev/null || true + + echo + echo "StemDeck has been removed." + echo + echo "Your tracks and settings were not touched:" + echo " ~/Documents/StemDeck" + echo " ${XDG_DATA_HOME:-$HOME/.local/share}/stemdeck" + echo +} + +usage() { + sed -n '3,25p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +main() { + while [[ $# -gt 0 ]]; do + case "$1" in + --global) WANT_SCOPE="global" ;; + --local) WANT_SCOPE="local" ;; + --prefix) shift; [[ $# -gt 0 ]] || die "--prefix needs a directory."; WANT_PREFIX="$1" ;; + --uninstall) MODE="uninstall" ;; + --yes|-y) ASSUME_YES=1 ;; + --help|-h) usage; exit 0 ;; + *) die "unknown option: $1 (try --help)" ;; + esac + shift + done + + case "$MODE" in + uninstall) do_uninstall ;; + *) do_install ;; + esac +} + +# Only act when executed. Sourcing the script exposes the functions on their +# own, which is how the test suite exercises version_cmp without installing +# anything. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/scripts/linux/make-portable.sh b/scripts/linux/make-portable.sh index 45d70bc7..ab3b66f8 100755 --- a/scripts/linux/make-portable.sh +++ b/scripts/linux/make-portable.sh @@ -157,6 +157,13 @@ mkdir -p "$STAGE/packaging" cp "$REPO_ROOT/desktop/src-tauri/icons/icon.png" "$STAGE/packaging/stemdeck.png" cp "$REPO_ROOT/packaging/linux/stemdeck.desktop.in" "$STAGE/packaging/stemdeck.desktop.in" +# The optional installer (#361). It installs the package it sits in, so it needs +# no network access and cannot disagree with the build about which version it is +# installing: it reads that from backend/static/version.json above, and the +# variant from the cpu-only marker below. +cp "$REPO_ROOT/packaging/linux/install.sh" "$STAGE/install.sh" +chmod +x "$STAGE/install.sh" + # 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 diff --git a/tests/linux/test_install_sh.sh b/tests/linux/test_install_sh.sh new file mode 100644 index 00000000..cbf9d381 --- /dev/null +++ b/tests/linux/test_install_sh.sh @@ -0,0 +1,342 @@ +#!/usr/bin/env bash +# +# Tests for packaging/linux/install.sh. +# +# Runs the real installer against a synthetic package in a throwaway HOME, so +# nothing here touches the machine it runs on. Every case that the reference +# installer in #342 got wrong has a test here, because each of them is silent +# at the point of failure and only shows up as a user with no working app. +# +# Run: bash tests/linux/test_install_sh.sh + +set -uo pipefail + +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +INSTALLER="${REPO_ROOT}/packaging/linux/install.sh" + +PASS=0 +FAIL=0 + +ok() { PASS=$((PASS + 1)); printf 'PASS %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); printf 'FAIL %s%s\n' "$1" "${2:+ -- $2}"; } +check(){ if [[ "$2" == "1" ]]; then ok "$1"; else bad "$1" "${3:-}"; fi } + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +SANDBOX="$(mktemp -d)" +trap 'rm -rf "$SANDBOX"' EXIT + +# A package shaped like what make-portable.sh produces. +make_package() { + local dir="$1" version="$2" variant="${3:-CPU}" + mkdir -p "$dir/backend/static" "$dir/backend/app" "$dir/python/bin" "$dir/packaging" + printf '#!/bin/sh\necho stemdeck\n' > "$dir/StemDeck" + chmod +x "$dir/StemDeck" + printf '{ "version": "%s" }\n' "$version" > "$dir/backend/static/version.json" + printf 'x' > "$dir/python/bin/python" + printf 'PNG-placeholder' > "$dir/packaging/stemdeck.png" + cp "${REPO_ROOT}/packaging/linux/stemdeck.desktop.in" "$dir/packaging/stemdeck.desktop.in" + cp "$INSTALLER" "$dir/install.sh" + chmod +x "$dir/install.sh" + [[ "$variant" == "CPU" ]] && : > "$dir/cpu-only" + return 0 +} + +# Each case gets its own HOME so manifests never leak between tests. +new_home() { + local h="${SANDBOX}/home-$1" + mkdir -p "$h" + printf '%s' "$h" +} + +run_installer() { # run_installer [args...] + local home="$1" pkg="$2"; shift 2 + env HOME="$home" \ + XDG_CONFIG_HOME="$home/.config" \ + XDG_DATA_HOME="$home/.local/share" \ + STEMDECK_INSTALL_ARCH="${FORCE_ARCH:-x86_64}" \ + bash "$pkg/install.sh" "$@" 2>&1 +} + +manifest_of() { cat "$1/.config/stemdeck/install-manifest" 2>/dev/null || true; } +mvalue() { manifest_of "$1" | sed -n "s/^$2=//p" | head -1; } + +# --------------------------------------------------------------------------- +# 1. Fresh install +# --------------------------------------------------------------------------- + +H="$(new_home fresh)" +PKG="${SANDBOX}/pkg-1" +make_package "$PKG" "0.8.0-alpha.17" +OUT="$(run_installer "$H" "$PKG" --local --yes)" +DIR="$H/.local/opt/stemdeck" + +check "fresh install: exit 0" "$([[ $? -eq 0 ]] && echo 1 || echo 0)" +check "fresh install: binary in place" "$([[ -x "$DIR/StemDeck" ]] && echo 1 || echo 0)" +check "fresh install: backend copied" "$([[ -d "$DIR/backend/app" ]] && echo 1 || echo 0)" +check "fresh install: desktop entry written" \ + "$([[ -f "$H/.local/share/applications/stemdeck.desktop" ]] && echo 1 || echo 0)" +check "fresh install: icon written" \ + "$([[ -f "$H/.local/share/icons/stemdeck.png" ]] && echo 1 || echo 0)" +check "fresh install: version read from the package, not hardcoded" \ + "$([[ "$(mvalue "$H" Version)" == "0.8.0-alpha.17" ]] && echo 1 || echo 0)" "got '$(mvalue "$H" Version)'" +check "fresh install: variant read from the cpu-only marker" \ + "$([[ "$(mvalue "$H" Variant)" == "CPU" ]] && echo 1 || echo 0)" "got '$(mvalue "$H" Variant)'" +check "fresh install: no staging dirs left behind" \ + "$([[ ! -e "${DIR}.new" && ! -e "${DIR}.old" ]] && echo 1 || echo 0)" + +# The bug that made the fork's entry unusable on a custom path. +EXEC_LINE="$(sed -n 's/^Exec=//p' "$H/.local/share/applications/stemdeck.desktop")" +check "fresh install: Exec is quoted" \ + "$([[ "$EXEC_LINE" == '"'*'"' ]] && echo 1 || echo 0)" "Exec=$EXEC_LINE" + +# --------------------------------------------------------------------------- +# 2. NVIDIA variant is detected from the package, never asked +# --------------------------------------------------------------------------- + +H="$(new_home nvidia)" +PKG="${SANDBOX}/pkg-nv" +make_package "$PKG" "0.8.0-alpha.17" "NVIDIA" +run_installer "$H" "$PKG" --local --yes >/dev/null +check "NVIDIA package records the NVIDIA variant" \ + "$([[ "$(mvalue "$H" Variant)" == "NVIDIA" ]] && echo 1 || echo 0)" "got '$(mvalue "$H" Variant)'" + +# --------------------------------------------------------------------------- +# 3. Upgrade replaces the install and updates the manifest +# --------------------------------------------------------------------------- + +H="$(new_home upgrade)" +PKG_OLD="${SANDBOX}/pkg-old"; make_package "$PKG_OLD" "0.8.0-alpha.16" +PKG_NEW="${SANDBOX}/pkg-new"; make_package "$PKG_NEW" "0.8.0-alpha.17" +run_installer "$H" "$PKG_OLD" --local --yes >/dev/null +printf 'marker-new\n' > "$PKG_NEW/NEWFILE" +OUT="$(run_installer "$H" "$PKG_NEW" --yes)" +DIR="$H/.local/opt/stemdeck" +check "upgrade: manifest now records the new version" \ + "$([[ "$(mvalue "$H" Version)" == "0.8.0-alpha.17" ]] && echo 1 || echo 0)" "got '$(mvalue "$H" Version)'" +check "upgrade: new content is present" "$([[ -f "$DIR/NEWFILE" ]] && echo 1 || echo 0)" +check "upgrade: reused the recorded location without asking" \ + "$([[ -x "$DIR/StemDeck" ]] && echo 1 || echo 0)" + +# --------------------------------------------------------------------------- +# 4. THE BLOCKER: a failed upgrade must leave the working install alone +# --------------------------------------------------------------------------- + +H="$(new_home failed)" +PKG_OK="${SANDBOX}/pkg-ok"; make_package "$PKG_OK" "0.8.0-alpha.16" +run_installer "$H" "$PKG_OK" --local --yes >/dev/null +DIR="$H/.local/opt/stemdeck" +printf 'user-was-here\n' > "$DIR/sentinel" + +# A package that passes the up-front check but cannot be copied: the executable +# is unreadable, so cp fails partway. +PKG_BAD="${SANDBOX}/pkg-bad"; make_package "$PKG_BAD" "0.8.0-alpha.17" +mkdir -p "$PKG_BAD/backend/app/unreadable" +chmod 000 "$PKG_BAD/backend/app/unreadable" + +OUT="$(run_installer "$H" "$PKG_BAD" --yes)"; RC=$? +chmod 755 "$PKG_BAD/backend/app/unreadable" 2>/dev/null || true + +check "failed upgrade: the install directory survives" \ + "$([[ -x "$DIR/StemDeck" ]] && echo 1 || echo 0)" +check "failed upgrade: the user's files survive" \ + "$([[ -f "$DIR/sentinel" ]] && echo 1 || echo 0)" +check "failed upgrade: the desktop entry survives" \ + "$([[ -f "$H/.local/share/applications/stemdeck.desktop" ]] && echo 1 || echo 0)" +check "failed upgrade: the manifest survives, still on the old version" \ + "$([[ "$(mvalue "$H" Version)" == "0.8.0-alpha.16" ]] && echo 1 || echo 0)" "got '$(mvalue "$H" Version)'" +check "failed upgrade: no staging dirs left behind" \ + "$([[ ! -e "${DIR}.new" ]] && echo 1 || echo 0)" + +# --------------------------------------------------------------------------- +# 5. A missing manifest key must not kill the script +# --------------------------------------------------------------------------- + +H="$(new_home corrupt)" +PKG="${SANDBOX}/pkg-c"; make_package "$PKG" "0.8.0-alpha.17" +run_installer "$H" "$PKG" --local --yes >/dev/null +# Strip Variant, as an older installer's manifest would lack newer keys. +grep -v '^Variant=' "$H/.config/stemdeck/install-manifest" > "$H/m.tmp" +mv "$H/m.tmp" "$H/.config/stemdeck/install-manifest" +OUT="$(run_installer "$H" "$PKG" --yes)"; RC=$? +check "missing manifest key: installer still runs" "$([[ $RC -eq 0 ]] && echo 1 || echo 0)" "rc=$RC" +check "missing manifest key: repaired on write" \ + "$([[ "$(mvalue "$H" Variant)" == "CPU" ]] && echo 1 || echo 0)" + +# A manifest with no InstallDir is corrupt: uninstall must say so, not flail. +H="$(new_home corrupt2)" +mkdir -p "$H/.config/stemdeck" +printf 'Version=0.8.0-alpha.17\n' > "$H/.config/stemdeck/install-manifest" +PKG="${SANDBOX}/pkg-c2"; make_package "$PKG" "0.8.0-alpha.17" +OUT="$(run_installer "$H" "$PKG" --uninstall --yes)"; RC=$? +check "corrupt manifest: uninstall refuses with a message" \ + "$([[ $RC -ne 0 ]] && grep -qi "corrupt" <<<"$OUT" && echo 1 || echo 0)" "rc=$RC out=$(head -3 <<<"$OUT" | tr '\n' ' ')" + +# --------------------------------------------------------------------------- +# 6. Install path containing a space +# --------------------------------------------------------------------------- + +H="$(new_home spaces)" +PKG="${SANDBOX}/pkg-sp"; make_package "$PKG" "0.8.0-alpha.17" +PREFIX="${SANDBOX}/My Apps" +mkdir -p "$PREFIX" +OUT="$(run_installer "$H" "$PKG" --prefix "$PREFIX" --yes)"; RC=$? +check "spaces: install succeeds" "$([[ $RC -eq 0 ]] && echo 1 || echo 0)" "$(tail -2 <<<"$OUT")" +check "spaces: binary in place" "$([[ -x "$PREFIX/stemdeck/StemDeck" ]] && echo 1 || echo 0)" +EXEC_LINE="$(sed -n 's/^Exec=//p' "$H/.local/share/applications/stemdeck.desktop")" +check "spaces: Exec parses as a single argument" \ + "$(python3 -c " +import shlex,sys +parts=shlex.split(sys.argv[1]) +print(1 if len(parts)==1 and parts[0].endswith('/stemdeck/StemDeck') else 0)" "$EXEC_LINE")" "Exec=$EXEC_LINE" + +# --------------------------------------------------------------------------- +# 7. Uninstall +# --------------------------------------------------------------------------- + +H="$(new_home uninstall)" +PKG="${SANDBOX}/pkg-u"; make_package "$PKG" "0.8.0-alpha.17" +run_installer "$H" "$PKG" --local --yes >/dev/null +DIR="$H/.local/opt/stemdeck" +# Stand in for the user's real data, which lives outside the install dir. +mkdir -p "$H/Documents/StemDeck/jobs/abc" "$H/.local/share/stemdeck/models" +printf 'stems' > "$H/Documents/StemDeck/jobs/abc/vocals.wav" +printf 'model' > "$H/.local/share/stemdeck/models/htdemucs" + +OUT="$(run_installer "$H" "$PKG" --uninstall --yes)"; RC=$? +check "uninstall: exit 0" "$([[ $RC -eq 0 ]] && echo 1 || echo 0)" +check "uninstall: install directory gone" "$([[ ! -e "$DIR" ]] && echo 1 || echo 0)" +check "uninstall: desktop entry gone" \ + "$([[ ! -f "$H/.local/share/applications/stemdeck.desktop" ]] && echo 1 || echo 0)" +check "uninstall: icon gone" "$([[ ! -f "$H/.local/share/icons/stemdeck.png" ]] && echo 1 || echo 0)" +check "uninstall: manifest gone" \ + "$([[ ! -f "$H/.config/stemdeck/install-manifest" ]] && echo 1 || echo 0)" +check "uninstall: stems untouched" \ + "$([[ -f "$H/Documents/StemDeck/jobs/abc/vocals.wav" ]] && echo 1 || echo 0)" +check "uninstall: runtime and models untouched" \ + "$([[ -f "$H/.local/share/stemdeck/models/htdemucs" ]] && echo 1 || echo 0)" + +# --------------------------------------------------------------------------- +# 8. Legacy in-install data/ is never destroyed +# --------------------------------------------------------------------------- + +H="$(new_home legacy)" +PKG_A="${SANDBOX}/pkg-la"; make_package "$PKG_A" "0.8.0-alpha.16" +PKG_B="${SANDBOX}/pkg-lb"; make_package "$PKG_B" "0.8.0-alpha.17" +run_installer "$H" "$PKG_A" --local --yes >/dev/null +DIR="$H/.local/opt/stemdeck" +mkdir -p "$DIR/data/jobs/old" +printf 'irreplaceable' > "$DIR/data/jobs/old/vocals.wav" + +run_installer "$H" "$PKG_B" --yes >/dev/null +check "legacy data: carried across an upgrade" \ + "$([[ -f "$DIR/data/jobs/old/vocals.wav" ]] && echo 1 || echo 0)" + +OUT="$(run_installer "$H" "$PKG_B" --uninstall --yes)" +check "legacy data: uninstall refuses to delete it" \ + "$([[ -f "$DIR/data/jobs/old/vocals.wav" ]] && echo 1 || echo 0)" +check "legacy data: uninstall says why the folder was kept" \ + "$(grep -qi "data/" <<<"$OUT" && echo 1 || echo 0)" + +# --------------------------------------------------------------------------- +# 9. Rejecting a package that is not one +# --------------------------------------------------------------------------- + +H="$(new_home badpkg)" +PKG="${SANDBOX}/pkg-broken" +mkdir -p "$PKG" +cp "$INSTALLER" "$PKG/install.sh" +OUT="$(run_installer "$H" "$PKG" --local --yes)"; RC=$? +check "incomplete package: refused" "$([[ $RC -ne 0 ]] && echo 1 || echo 0)" +check "incomplete package: nothing installed" \ + "$([[ ! -e "$H/.local/opt/stemdeck" ]] && echo 1 || echo 0)" +check "incomplete package: no manifest written" \ + "$([[ ! -f "$H/.config/stemdeck/install-manifest" ]] && echo 1 || echo 0)" + +# --------------------------------------------------------------------------- +# 10. Refusing to install over the copy we are running from +# --------------------------------------------------------------------------- + +H="$(new_home selfinstall)" +PKG="${SANDBOX}/pkg-self"; make_package "$PKG" "0.8.0-alpha.17" +run_installer "$H" "$PKG" --local --yes >/dev/null +DIR="$H/.local/opt/stemdeck" +OUT="$(env HOME="$H" XDG_CONFIG_HOME="$H/.config" XDG_DATA_HOME="$H/.local/share" \ + STEMDECK_INSTALL_ARCH=x86_64 bash "$DIR/install.sh" --yes 2>&1)"; RC=$? +check "self-install: refused with a message" \ + "$([[ $RC -ne 0 ]] && grep -qi "extracted tarball" <<<"$OUT" && echo 1 || echo 0)" "rc=$RC" +check "self-install: install left intact" "$([[ -x "$DIR/StemDeck" ]] && echo 1 || echo 0)" + +# --------------------------------------------------------------------------- +# 10b. The generated launcher is a valid desktop entry +# +# Only checkable where desktop-file-utils exists, which is CI. A malformed entry +# is silently ignored by the desktop environment, so nothing else would notice. +# --------------------------------------------------------------------------- + +if command -v desktop-file-validate >/dev/null 2>&1; then + H="$(new_home validate)" + PKG="${SANDBOX}/pkg-val"; make_package "$PKG" "0.8.0-alpha.17" + run_installer "$H" "$PKG" --local --yes >/dev/null + ENTRY="$H/.local/share/applications/stemdeck.desktop" + if OUT="$(desktop-file-validate "$ENTRY" 2>&1)"; then + ok "desktop entry validates" + else + bad "desktop entry validates" "$(head -3 <<<"$OUT" | tr '\n' ' ')" + fi +else + echo "SKIP desktop entry validates (desktop-file-validate not installed)" +fi + +# --------------------------------------------------------------------------- +# 11. A machine the package cannot run on +# --------------------------------------------------------------------------- + +H="$(new_home arch)" +PKG="${SANDBOX}/pkg-arch"; make_package "$PKG" "0.8.0-alpha.17" +OUT="$(FORCE_ARCH=aarch64 run_installer "$H" "$PKG" --local --yes)"; RC=$? +check "wrong arch: refused" "$([[ $RC -ne 0 ]] && echo 1 || echo 0)" +check "wrong arch: names the machine type" \ + "$(grep -q "aarch64" <<<"$OUT" && echo 1 || echo 0)" "$(head -2 <<<"$OUT" | tr '\n' ' ')" +check "wrong arch: nothing installed" \ + "$([[ ! -e "$H/.local/opt/stemdeck" ]] && echo 1 || echo 0)" + +# --------------------------------------------------------------------------- +# 12. Semver comparison +# +# sort -V gets the pre-release cases wrong, which is why this is hand-rolled. +# --------------------------------------------------------------------------- + +vcmp() { + bash -c ' + set -euo pipefail + # Sourcing runs no install logic: main() is guarded on BASH_SOURCE. + source "$1" + version_cmp "$2" "$3" + ' _ "$INSTALLER" "$1" "$2" +} + +expect_cmp() { + local got; got="$(vcmp "$1" "$2")" + check "semver: $1 vs $2 -> $3" "$([[ "$got" == "$3" ]] && echo 1 || echo 0)" "got $got" +} + +expect_cmp "0.8.0-alpha.17" "0.8.0-alpha.7" "1" +expect_cmp "0.8.0-alpha.7" "0.8.0-alpha.17" "-1" +expect_cmp "0.8.0-alpha.17" "0.8.0-alpha.17" "0" +expect_cmp "0.8.0-alpha.18" "0.8.0-alpha.17" "1" +expect_cmp "0.9.0" "0.8.0" "1" +expect_cmp "1.0.0" "0.9.9" "1" +# The case sort -V gets wrong: a stable release outranks its own pre-releases. +expect_cmp "0.8.0" "0.8.0-alpha.17" "1" +expect_cmp "0.8.0-alpha.17" "0.8.0" "-1" +expect_cmp "0.8.0-alpha" "0.8.0-alpha.1" "-1" +expect_cmp "0.8.0-beta.1" "0.8.0-alpha.9" "1" + +# --------------------------------------------------------------------------- + +echo +echo "$((PASS))/$((PASS + FAIL)) checks passed" +[[ $FAIL -eq 0 ]] || exit 1 diff --git a/tests/test_packaging_linux.py b/tests/test_packaging_linux.py index 57b79d77..cd4185cc 100644 --- a/tests/test_packaging_linux.py +++ b/tests/test_packaging_linux.py @@ -65,7 +65,27 @@ def test_the_icon_the_packaging_script_copies_exists(): assert ICON.is_file() -def test_make_portable_stages_both_assets(): +def test_make_portable_stages_the_desktop_assets(): script = MAKE_PORTABLE.read_text(encoding="utf-8") assert "packaging/stemdeck.png" in script assert "packaging/stemdeck.desktop.in" in script + + +def test_make_portable_stages_the_installer(): + """Without this the installer is not in the tarball, and the README tells + users to run a file that is not there.""" + script = MAKE_PORTABLE.read_text(encoding="utf-8") + assert "packaging/linux/install.sh" in script + assert 'chmod +x "$STAGE/install.sh"' in script + + +def test_the_installer_exists_and_is_executable(): + installer = ROOT / "packaging" / "linux" / "install.sh" + assert installer.is_file() + assert installer.stat().st_mode & 0o111, "install.sh must be executable in the repo" + + +def test_readme_documents_the_installer(): + readme = (ROOT / "packaging" / "linux" / "README-LINUX.txt").read_text(encoding="utf-8") + assert "./install.sh" in readme + assert "--uninstall" in readme