diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 922cdf0..580b920 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 1529c43..5847b13 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 0000000..b126555 --- /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 45d70bc..ab3b66f 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 0000000..cbf9d38 --- /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 57b79d7..cd4185c 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