Files
Tha.Les 5565b216ba feat(linux): add an optional installer for desktop integration (#364)
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 <target>.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
2026-08-12 19:08:49 +01:00

92 lines
3.5 KiB
Python

"""The Linux desktop-entry assets staged into the portable tarball (#360).
These are plain data files with no code path to exercise them until the
installer lands (#361), so the things worth pinning are the ones that fail
silently at the user's end: a launcher that will not start, or an icon the
packaging script cannot find.
"""
from __future__ import annotations
import shlex
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
TEMPLATE = ROOT / "packaging" / "linux" / "stemdeck.desktop.in"
ICON = ROOT / "desktop" / "src-tauri" / "icons" / "icon.png"
MAKE_PORTABLE = ROOT / "scripts" / "linux" / "make-portable.sh"
def _entries() -> dict[str, str]:
lines = TEMPLATE.read_text(encoding="utf-8").splitlines()
assert lines[0] == "[Desktop Entry]", "the group header must come first"
return dict(line.split("=", 1) for line in lines[1:] if line and not line.startswith("#"))
def test_exec_is_quoted_so_a_path_with_spaces_still_launches():
"""The freedesktop spec splits Exec on whitespace.
The reference installer in #342 emitted an unquoted Exec, so installing to
a directory such as ~/My Apps produced an entry that tried to run a binary
called ".../My". Quoting is the whole fix, and it is invisible until
someone picks a custom path.
"""
exec_line = _entries()["Exec"].replace("@EXEC@", "/home/u/My Apps/StemDeck-Linux-x64/StemDeck")
assert shlex.split(exec_line) == ["/home/u/My Apps/StemDeck-Linux-x64/StemDeck"]
def test_placeholders_are_present_for_the_installer_to_substitute():
entries = _entries()
assert "@EXEC@" in entries["Exec"]
assert entries["Icon"] == "@ICON@"
@pytest.mark.parametrize("key", ["Type", "Name", "Exec", "Icon", "Categories"])
def test_required_keys_are_present(key):
assert key in _entries()
def test_type_and_categories_are_registered_values():
entries = _entries()
assert entries["Type"] == "Application"
cats = [c for c in entries["Categories"].split(";") if c]
# A registered main category is required; Audio and Music are additional
# ones that only carry meaning alongside it.
assert "AudioVideo" in cats
assert "Multimedia" not in cats, "Multimedia is not a registered category"
assert entries["Categories"].endswith(";"), "the list must be semicolon-terminated"
def test_the_icon_the_packaging_script_copies_exists():
"""make-portable.sh copies this by path. A move would break the Linux build
at package time, long after the change that caused it."""
assert ICON.is_file()
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