From d286402f05a8e562c448287b92081019498a104d Mon Sep 17 00:00:00 2001 From: Jono Date: Fri, 25 Sep 2026 18:56:47 -0700 Subject: [PATCH 1/3] feat(linux): packaged AppImage + release workflow (#168) Zero-toolchain Linux install: a self-contained AppImage carrying a private Python runtime, the built client, and the layouts, plus a `sudo` helper that installs the pieces an AppImage cannot own. - packaging/linux: PyInstaller onedir spec + launcher, AppDir glue (AppRun, deckd.desktop), and install-system-integration.sh (udev rule + input group; GNOME/KWin focus watcher; XDG autostart; --uninstall). - daemon/deckd/app_bundle.py: platform-agnostic packaging helpers extracted from macos_app so the two bundles cannot drift; macos_app re-exports them. - daemon/deckd/linux_app.py: XDG data dir/log, layouts.linux overlay, argv. - .github/workflows/release-linux.yml: x86_64 + aarch64 matrix, --help smoke test, attaches the AppImages + the helper script to the tag. - Justfile: build-linux-appimage, install-system-integration. - docs: ADR-0012 (AppImage primary; Flatpak/Snap rejected) and a GUIDE section. Channel rationale in docs/adr/0012-linux-distribution-appimage.md. --- .github/workflows/release-linux.yml | 110 ++++++++ Justfile | 82 ++++++ README.md | 2 +- daemon/deckd/app_bundle.py | 116 ++++++++ daemon/deckd/linux_app.py | 98 +++++++ daemon/deckd/macos_app.py | 140 +++------- docs/GUIDE.md | 38 +++ docs/adr/0012-linux-distribution-appimage.md | 134 +++++++++ docs/adr/README.md | 6 + packaging/linux/appimage/AppRun | 13 + packaging/linux/appimage/deckd.desktop | 8 + packaging/linux/deckd.spec | 86 ++++++ packaging/linux/install-system-integration.sh | 263 ++++++++++++++++++ packaging/linux/launcher.py | 26 ++ tests/test_linux_app.py | 92 ++++++ 15 files changed, 1112 insertions(+), 102 deletions(-) create mode 100644 .github/workflows/release-linux.yml create mode 100644 daemon/deckd/app_bundle.py create mode 100644 daemon/deckd/linux_app.py create mode 100644 docs/adr/0012-linux-distribution-appimage.md create mode 100755 packaging/linux/appimage/AppRun create mode 100644 packaging/linux/appimage/deckd.desktop create mode 100644 packaging/linux/deckd.spec create mode 100755 packaging/linux/install-system-integration.sh create mode 100644 packaging/linux/launcher.py create mode 100644 tests/test_linux_app.py diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml new file mode 100644 index 0000000..a8cb738 --- /dev/null +++ b/.github/workflows/release-linux.yml @@ -0,0 +1,110 @@ +name: Release Linux AppImage + +# Phase 2 of #168: build the self-contained deckd AppImage on a Linux runner +# and attach it to the GitHub release. +# +# The build itself is `just build-linux-appimage` — the same recipe a +# contributor runs locally (docs/GUIDE.md § "Linux AppImage") — so CI and the +# documented path can't drift. This job only supplies the runner, the +# toolchain, and the publish step. +# +# Two architectures, one matrix: x86_64 on ubuntu-24.04 (arm64 hosted runners +# are free for public repos). aarch64 has no evdev-binary wheel, so the recipe +# source-builds python-evdev before freezing. +# +# The AppImage is unsigned; users run it directly (`chmod +x`, then execute). +# The root-only uinput step rides along as an install helper bundled inside the +# AppImage at usr/share/deckd/integration/install-system-integration.sh. + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Release tag to attach the AppImage to (e.g. v0.1.0)" + required: true + type: string + +permissions: + contents: write + +# Serialise publishes per tag: a second run must not `--clobber` an AppImage +# the first is still uploading. In-flight runs are not cancelled. +concurrency: + group: release-linux-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + appimage: + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + arch: x86_64 + - runner: ubuntu-24.04-arm + arch: aarch64 + runs-on: ${{ matrix.runner }} + env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: client/package-lock.json + + - uses: extractions/setup-just@v4 + + # librsvg2-bin gives the recipe rsvg-convert for the icon; build-essential + # provides the compiler evdev's source build needs on aarch64. + - name: Install build tools + run: sudo apt-get update && sudo apt-get install -y librsvg2-bin build-essential + + # evdev (uinput) + dbus-fast ([dbus]) + PyInstaller. Installing these + # first also stops the recipe's `uv pip install` fallback from firing. + - name: Install Python deps + run: pip install -e ".[uinput,dbus,packaging]" + + # `npm run build` already runs `tsc --noEmit`, so a type error fails + # the release before a broken bundle is frozen in. + - name: Build client + run: npm ci && npm run build + working-directory: client + + # The tag (minus a leading `v`) is the release version, so the AppImage + # name and the daemon's version agree with the tag. + - name: Resolve version from the tag + run: echo "DECKD_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV" + + - name: Build AppImage + run: just build-linux-appimage + + # Boot the frozen payload: --help parses args and exits before the + # server starts, which proves the bundle's interpreter and imports work. + - name: Smoke-test the AppImage + run: APPIMAGE_EXTRACT_AND_RUN=1 dist/deckd-*.AppImage --help >/dev/null + + - uses: actions/upload-artifact@v4 + with: + name: deckd-linux-appimage-${{ matrix.arch }} + path: | + dist/deckd-*.AppImage + dist/deckd-install-system-integration.sh + if-no-files-found: error + + - name: Attach the AppImage to the release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view "$RELEASE_TAG" >/dev/null 2>&1 \ + || gh release create "$RELEASE_TAG" --title "$RELEASE_TAG" --generate-notes + gh release upload "$RELEASE_TAG" \ + dist/deckd-*.AppImage dist/deckd-install-system-integration.sh --clobber diff --git a/Justfile b/Justfile index cc92e12..d4b0c0c 100644 --- a/Justfile +++ b/Justfile @@ -482,6 +482,88 @@ build-macos-dmg: build-macos-app -ov -format UDZO "dist/deckd-${version}.dmg" echo "Built dist/deckd-${version}.dmg" +# Build the self-contained Linux AppImage (dist/deckd--.AppImage, +# issue #168). Linux only: appimagetool wraps a PyInstaller onedir tree in a +# squashfs. Installs the [uinput,dbus,packaging] extras on demand and builds +# the client first if it's missing. The udev rule and focus-watcher sources ride +# along under usr/share/deckd/integration for the install helper. +build-linux-appimage: + #!/usr/bin/env bash + set -euo pipefail + if [ "$(uname)" != "Linux" ]; then + echo "build-linux-appimage needs Linux; an AppImage can't be built on $(uname)." >&2 + exit 1 + fi + arch="$(uname -m)" + if ! command -v pyinstaller >/dev/null 2>&1; then + echo "installing packaging deps..." + uv pip install -e ".[uinput,dbus,packaging]" + fi + if [ "$arch" != "x86_64" ]; then + echo "note: $arch has no evdev-binary wheel; ensuring a source build." >&2 + PYTHON="$(command -v python)" bash scripts/install_evdev_source.sh \ + || echo "warn: evdev source build failed; key injection will no-op." >&2 + fi + if [ ! -f client/dist/index.html ]; then + echo "client/dist missing; building client..." + just build-client + fi + pyinstaller --noconfirm --clean packaging/linux/deckd.spec + + version="${DECKD_VERSION:-$(just version)}" + work="$(mktemp -d)" + trap 'rm -rf "$work"' EXIT + appdir="$work/deckd.AppDir" + mkdir -p "$appdir/usr/bin" "$appdir/usr/share/deckd/integration/gnome-shell" \ + "$appdir/usr/share/deckd/integration/kwin-script" + cp -R dist/deckd/. "$appdir/usr/bin/" + cp packaging/udev/70-deckd-uinput.rules "$appdir/usr/share/deckd/integration/" + cp -R packaging/gnome-shell/deckd-focus@local "$appdir/usr/share/deckd/integration/gnome-shell/" + cp -R packaging/kwin-script/deckd-focus "$appdir/usr/share/deckd/integration/kwin-script/" + cp packaging/linux/install-system-integration.sh "$appdir/usr/share/deckd/integration/" + cp packaging/linux/appimage/AppRun "$appdir/AppRun" + chmod +x "$appdir/AppRun" + cp packaging/linux/appimage/deckd.desktop "$appdir/" + # appimagetool wants deckd.png (or deckd.svg) at the AppDir root. + if command -v rsvg-convert >/dev/null 2>&1; then + rsvg-convert -w 512 -h 512 -o "$appdir/deckd.png" client/public/icon.svg + elif command -v magick >/dev/null 2>&1; then + magick -background none client/public/icon.svg -resize 512x512 "$appdir/deckd.png" + elif command -v convert >/dev/null 2>&1; then + convert -background none client/public/icon.svg -resize 512x512 "$appdir/deckd.png" + else + cp client/public/icon.svg "$appdir/deckd.svg" + fi + + tooling="${XDG_CACHE_HOME:-$HOME/.cache}/deckd/appimagetool-${arch}.AppImage" + if [ ! -x "$tooling" ]; then + echo "fetching appimagetool (${arch})..." + mkdir -p "$(dirname "$tooling")" + curl -fsSL -o "$tooling" \ + "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage" + chmod +x "$tooling" + fi + export ARCH="$arch" + APPIMAGE_EXTRACT_AND_RUN=1 "$tooling" "$appdir" "dist/deckd-${version}-${arch}.AppImage" + cp packaging/linux/install-system-integration.sh dist/deckd-install-system-integration.sh + echo "Built dist/deckd-${version}-${arch}.AppImage (+ dist/deckd-install-system-integration.sh)" + +# Stage the integration assets from a checkout and run the privileged helper +# for the current user (issue #168): udev rule + input group (root), plus the +# focus watcher and an XDG autostart entry (user). Prompts for sudo. Useful for +# a source install and for testing the helper without building an AppImage. +# Extra args pass through (e.g. `--desktop gnome`, `--uninstall`). +install-system-integration *args: + #!/usr/bin/env bash + set -euo pipefail + stage="$(mktemp -d)" + trap 'rm -rf "$stage"' EXIT + mkdir -p "$stage/gnome-shell" "$stage/kwin-script" + cp packaging/udev/70-deckd-uinput.rules "$stage/" + cp -R packaging/gnome-shell/deckd-focus@local "$stage/gnome-shell/" + cp -R packaging/kwin-script/deckd-focus "$stage/kwin-script/" + sudo packaging/linux/install-system-integration.sh --assets "$stage" {{args}} + # Run the Nix flake checks: builds packages.deckd and the focus-watcher # bundles, evaluates the NixOS + home-manager modules, unit-tests the # activation scripts in a sandbox, and boots the packaged daemon on diff --git a/README.md b/README.md index aace39b..eee5933 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Pre-alpha, but usable day-to-day. Here's what deckd can do today and what's stil - [ ] **Multi-daemon chooser** — pair and pick between several desktops. - [ ] **Reliable web-app detection** — a browser extension reporting the active tab's real URL, so sites match by domain/path instead of the current window-title heuristic ([#90](https://github.com/jonocodes/deckd/issues/90)). - [ ] **Windows support** -- [ ] **Packing and deployment** ([#165](https://github.com/jonocodes/deckd/issues/165)) +- [ ] **Packing and deployment** — self-contained macOS DMG ([#165](https://github.com/jonocodes/deckd/issues/165)) and Linux AppImage ([#168](https://github.com/jonocodes/deckd/issues/168)) ## Inspiration and Comparison diff --git a/daemon/deckd/app_bundle.py b/daemon/deckd/app_bundle.py new file mode 100644 index 0000000..11327b7 --- /dev/null +++ b/daemon/deckd/app_bundle.py @@ -0,0 +1,116 @@ +"""Platform-independent helpers for the packaged desktop artifacts. + +Both the macOS app bundle (#165) and the Linux AppImage (#168) need the same +mechanical pieces: locate the frozen payload, find the bundled client and +layouts, seed layouts into a writable directory on first run, read the version +seam, and build the daemon argv. Those live here so they can be unit-tested on +any host, independent of the platform that ships them. + +``deckd.macos_app`` and ``deckd.linux_app`` re-export what their wrappers need. +""" +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +DEFAULT_PORT = 8765 + + +def resource_root() -> Path: + """Directory holding the bundled payload. + + PyInstaller sets ``sys._MEIPASS`` to the onedir payload; in a source + checkout we fall back to the repo root so the packaging entry points can be + exercised without freezing. + """ + meipass = getattr(sys, "_MEIPASS", None) + if meipass: + return Path(meipass) + return Path(__file__).resolve().parents[2] + + +def client_dist(root: Path) -> Path: + """Bundled client build (``client/dist`` copied to ``web``).""" + return root / "web" + + +def layouts_src(root: Path) -> Path: + """Bundled layouts directory.""" + return root / "layouts" + + +def overlay_src(root: Path, suffix: str) -> Path: + """Bundled per-platform overlay layouts (``layouts.``).""" + return root / f"layouts.{suffix}" + + +def bundle_version(pyproject: Path | None = None) -> str: + """The version stamped into the artifact. + + ``DECKD_VERSION`` wins when set — release CI passes the git tag (minus a + leading ``v``), so the tag is the single source for a release and the + artifact name matches. Local builds fall back to ``version`` in + ``pyproject.toml``. + """ + override = os.environ.get("DECKD_VERSION", "").strip() + if override: + return override + path = pyproject or Path(__file__).resolve().parents[2] / "pyproject.toml" + for line in path.read_text().splitlines(): + if line.startswith("version = "): + return line.split("=", 1)[1].strip().strip('"') + raise ValueError(f"no version found in {path}") + + +def seed_layouts( + src: Path, dest: Path, *, overlay: Path | None = None, overlay_suffix: str = "macos" +) -> bool: + """Copy bundled layouts into the writable data dir on first run. + + Returns ``True`` when it seeded, ``False`` when ``dest`` already existed. + An existing directory is never overwritten, so a user's hand-edited + layouts survive an upgrade (mirrors the Nix module's seed-once behaviour). + The per-platform overlay is copied to the sibling ``.`` + directory the daemon auto-discovers. + """ + if dest.exists(): + return False + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(src, dest) + if overlay is not None and overlay.is_dir(): + overlay_dest = dest.parent / f"{dest.name}.{overlay_suffix}" + if not overlay_dest.exists(): + shutil.copytree(overlay, overlay_dest) + return True + + +def app_argv( + *, + layouts_dir: Path, + client_dist: Path, + port: int = DEFAULT_PORT, + bind: list[str] | None = None, + password_file: Path | None = None, + log_file: Path | None = None, + verbose: bool = False, +) -> list[str]: + """Build the daemon argv the packaged entry points pass to ``parse_args``. + + Localhost-only unless ``bind`` is given, so the default stays safe. + """ + argv = [ + "--layouts-dir", str(layouts_dir), + "--client-dist", str(client_dist), + "--port", str(port), + ] + for addr in bind or []: + argv += ["--bind", addr] + if password_file is not None: + argv += ["--password-file", str(password_file)] + if log_file is not None: + argv += ["--log-file", str(log_file)] + if verbose: + argv.append("--verbose") + return argv diff --git a/daemon/deckd/linux_app.py b/daemon/deckd/linux_app.py new file mode 100644 index 0000000..a800aec --- /dev/null +++ b/daemon/deckd/linux_app.py @@ -0,0 +1,98 @@ +"""Helpers for the packaged Linux AppImage (issue #168). + +Linux-specific pieces of the AppImage launcher: the writable XDG data dir and +log file, the ``layouts.linux`` overlay, and first-run seeding. The +platform-independent mechanics live in ``deckd.app_bundle``; the frozen entry +point is ``packaging/linux/launcher.py``. + +Everything here runs on any host (it is pure path/argv logic), so it is +unit-tested alongside ``deckd.macos_app``; the AppImage build itself needs +Linux tooling. +""" +from __future__ import annotations + +import os +from pathlib import Path + +from .app_bundle import ( + DEFAULT_PORT, + app_argv, + bundle_version, + client_dist, + layouts_src, + resource_root, +) +from .app_bundle import overlay_src as _overlay_src +from .app_bundle import seed_layouts as _seed_layouts + +__all__ = [ + "APP_NAME", + "DEFAULT_PORT", + "app_argv", + "build_argv", + "bundle_version", + "client_dist", + "data_dir", + "default_log_file", + "layouts_src", + "overlay_src", + "prepare", + "resource_root", + "seed_layouts", +] + +APP_NAME = "deckd" + + +def data_dir() -> Path: + """``$XDG_DATA_HOME/deckd`` (``~/.local/share/deckd``) — writable data. + + Layouts must be writable (the editor saves back to disk) but the AppImage + payload is a read-only squashfs mount, so layouts are seeded here on first + run. The shared password stays on the config side + (``$XDG_CONFIG_HOME/deckd/password``), which the daemon already defaults to. + """ + base = os.environ.get("XDG_DATA_HOME", "").strip() + root = Path(base) if base else Path.home() / ".local" / "share" + return root / APP_NAME + + +def default_log_file() -> Path: + """``/deckd.log`` — where the AppImage tees its logs. + + Unlike a source install there is no journal: an AppImage launched from an + XDG autostart entry has nowhere to put stderr, so the daemon writes a log + file the user can tail. + """ + return data_dir() / f"{APP_NAME}.log" + + +def overlay_src(root: Path) -> Path: + """Bundled Linux overlay layouts (``layouts.linux``).""" + return _overlay_src(root, "linux") + + +def seed_layouts(src: Path, dest: Path, *, overlay: Path | None = None) -> bool: + """Seed layouts + the ``.linux`` overlay into the writable data dir.""" + return _seed_layouts(src, dest, overlay=overlay, overlay_suffix="linux") + + +def prepare() -> tuple[Path, Path, Path]: + """Seed writable data, returning (layouts_dir, client_dist, log_file).""" + root = resource_root() + layouts_dir = data_dir() / "layouts" + seed_layouts(layouts_src(root), layouts_dir, overlay=overlay_src(root)) + log_file = default_log_file() + log_file.parent.mkdir(parents=True, exist_ok=True) + return layouts_dir, client_dist(root), log_file + + +def build_argv(extra: list[str] | None = None) -> list[str]: + """Daemon argv for the AppImage: seeded layouts, bundled client, log file. + + ``extra`` is passed through so the user can add flags (e.g. + ``--bind 0.0.0.0`` to expose the surface on the LAN). Localhost-only by + default. + """ + layouts_dir, web, log_file = prepare() + return app_argv(layouts_dir=layouts_dir, client_dist=web, log_file=log_file) + list(extra or []) diff --git a/daemon/deckd/macos_app.py b/daemon/deckd/macos_app.py index 564c25f..03ea66f 100644 --- a/daemon/deckd/macos_app.py +++ b/daemon/deckd/macos_app.py @@ -1,10 +1,14 @@ """Helpers for the packaged macOS app bundle (issue #165). -Platform-independent pieces of the menu-bar wrapper: resource discovery, -first-run layout seeding, argv construction for the embedded server, and a -background-thread server runner. These live in the daemon package (rather -than ``packaging/macos/menubar.py``) so they can be unit-tested on Linux — -AppKit itself cannot be. +macOS-specific pieces of the menu-bar wrapper: the bundle id, the writable +Application Support / Logs locations, the ``Info.plist``, and the +background-thread server runner the AppKit wrapper needs. The platform- +independent packaging mechanics (payload discovery, layout seeding, argv and +version) live in ``deckd.app_bundle`` and are re-exported here so +``packaging/macos/menubar.py`` keeps importing them from one place. + +Everything in this module runs on the Linux dev/CI hosts; AppKit itself does +not, which is why ``menubar.py`` stays a thin wrapper. The wrapper that uses them is ``packaging/macos/menubar.py``, frozen by ``packaging/macos/deckd.spec`` into ``deckd.app``. @@ -14,16 +18,40 @@ import argparse import asyncio import logging -import os -import shutil import sys import threading from pathlib import Path +from .app_bundle import ( + DEFAULT_PORT, + app_argv, + bundle_version, + client_dist, + layouts_src, + resource_root, + seed_layouts, +) + +__all__ = [ + "BUNDLE_ID", + "DEFAULT_PORT", + "APP_SUPPORT_DIRNAME", + "ServerRunner", + "app_argv", + "app_support_dir", + "bundle_info_plist", + "bundle_version", + "client_dist", + "default_log_file", + "layouts_src", + "overlay_src", + "resource_root", + "seed_layouts", +] + log = logging.getLogger("deckd.macos_app") BUNDLE_ID = "com.deckd.daemon" -DEFAULT_PORT = 8765 # The daemon already defaults its password to ``~/.config/deckd/password``; # keep that so the app and a CLI run share one secret. Layouts, however, @@ -32,32 +60,11 @@ APP_SUPPORT_DIRNAME = "deckd" -def resource_root() -> Path: - """Directory holding the bundled Resources. - - PyInstaller sets ``sys._MEIPASS`` to the onedir payload (inside - ``deckd.app/Contents/Frameworks``); in a source checkout we fall back to - the repo root so ``menubar.py`` can be exercised without freezing. - """ - meipass = getattr(sys, "_MEIPASS", None) - if meipass: - return Path(meipass) - return Path(__file__).resolve().parents[2] - - -def client_dist(root: Path) -> Path: - """Bundled client build (``client/dist`` copied to ``web``).""" - return root / "web" - - -def layouts_src(root: Path) -> Path: - """Bundled layouts directory.""" - return root / "layouts" - - def overlay_src(root: Path) -> Path: """Bundled macOS overlay layouts (``layouts.macos``).""" - return root / "layouts.macos" + from .app_bundle import overlay_src as _overlay_src + + return _overlay_src(root, "macos") def app_support_dir() -> Path: @@ -70,24 +77,6 @@ def default_log_file() -> Path: return Path.home() / "Library" / "Logs" / "deckd.log" -def bundle_version(pyproject: Path | None = None) -> str: - """The version stamped into the bundle (issue #165). - - ``DECKD_VERSION`` wins when set — release CI passes the git tag (minus a - leading ``v``), so the tag is the single source for a release and the DMG - name, ``CFBundleShortVersionString``, and volume name all agree. Local - builds fall back to ``version`` in ``pyproject.toml``. - """ - override = os.environ.get("DECKD_VERSION", "").strip() - if override: - return override - path = pyproject or Path(__file__).resolve().parents[2] / "pyproject.toml" - for line in path.read_text().splitlines(): - if line.startswith("version = "): - return line.split("=", 1)[1].strip().strip('"') - raise ValueError(f"no version found in {path}") - - def bundle_info_plist(version: str) -> dict[str, object]: """Info.plist entries for ``deckd.app`` (issue #165). @@ -116,57 +105,6 @@ def bundle_info_plist(version: str) -> dict[str, object]: } -def seed_layouts(src: Path, dest: Path, *, overlay: Path | None = None) -> bool: - """Copy bundled layouts into the writable data dir on first run. - - Returns ``True`` when it seeded, ``False`` when ``dest`` already existed. - An existing directory is never overwritten, so a user's hand-edited - layouts survive an app upgrade (mirrors the Nix module's seed-once - behaviour). The per-platform overlay is copied to the sibling - ``.macos`` directory the daemon auto-discovers. - """ - if dest.exists(): - return False - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copytree(src, dest) - if overlay is not None and overlay.is_dir(): - overlay_dest = dest.parent / f"{dest.name}.macos" - if not overlay_dest.exists(): - shutil.copytree(overlay, overlay_dest) - return True - - -def app_argv( - *, - layouts_dir: Path, - client_dist: Path, - port: int = DEFAULT_PORT, - bind: list[str] | None = None, - password_file: Path | None = None, - log_file: Path | None = None, - verbose: bool = False, -) -> list[str]: - """Build the daemon argv the app passes to ``parse_args``. - - Localhost-only unless ``bind`` is given (the menu's LAN toggle supplies - ``["0.0.0.0"]``), so the default stays safe. - """ - argv = [ - "--layouts-dir", str(layouts_dir), - "--client-dist", str(client_dist), - "--port", str(port), - ] - for addr in bind or []: - argv += ["--bind", addr] - if password_file is not None: - argv += ["--password-file", str(password_file)] - if log_file is not None: - argv += ["--log-file", str(log_file)] - if verbose: - argv.append("--verbose") - return argv - - class ServerRunner: """Run the deckd asyncio server on a background thread. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 6f3d8e3..1ae622d 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -662,6 +662,44 @@ The tag (minus a leading `v`) is the version for that release: it names the DMG The DMG is **arm64 only** (Apple Silicon): `macos-14` runners are Apple Silicon. An Intel / `universal2` build is a follow-up ([#165](https://github.com/jonocodes/deckd/issues/165), "Open questions"). +### Linux AppImage (experimental, [#168](https://github.com/jonocodes/deckd/issues/168)) + +Beyond the source-checkout + systemd user unit path above, deckd can be built as a self-contained AppImage: a private Python runtime, the built client, and the layouts in one file. The target machine needs no Python, Node, or checkout. Build it **on Linux** (appimagetool wraps a PyInstaller tree in a squashfs): + +```sh +just build-linux-appimage # -> dist/deckd--.AppImage +``` + +The AppImage is **unsigned** and deliberately **not sandboxed**: a sandbox cannot write the udev rule, see `/dev/uinput`, reach the session bus, or install the compositor plugin (see [ADR-0012](adr/0012-linux-distribution-appimage.md), which rules out Flatpak/Snap). Download it, make it executable, and run it: + +```sh +chmod +x deckd--x86_64.AppImage +./deckd--x86_64.AppImage +``` + +It seeds layouts into `~/.local/share/deckd/layouts` on first run and logs to `~/.local/share/deckd/deckd.log`. It stays localhost-only until you add `--bind 0.0.0.0`, exactly like the daemon. + +If `libfuse2` is missing (Ubuntu 22.04+/Debian 12 no longer ship it), run with `--appimage-extract-and-run` or set `APPIMAGE_EXTRACT_AND_RUN=1`. + +#### uinput, the focus watcher, and autostart + +Two things the AppImage can't do by itself. `/dev/uinput` needs a udev rule and the `input` group (a root action), and the focus watcher is desktop-specific. The AppImage carries both — the rule and the GNOME/KWin sources sit under `usr/share/deckd/integration` — and a `deckd-install-system-integration.sh` is attached next to every release. Run it once with `sudo`, pointing at the AppImage: + +```sh +chmod +x deckd-install-system-integration.sh +sudo ./deckd-install-system-integration.sh ./deckd--x86_64.AppImage +``` + +It installs the udev rule and adds you to `input`, installs the GNOME Shell extension or KWin script for the detected desktop (override with `--desktop gnome|kde`), and writes `~/.config/autostart/deckd.desktop` so deckd starts with your session. Re-run with `--uninstall` to remove all of it. **Log out and back in** for the group change to take effect. The autostart entry points at the AppImage's path, so keep it where it is (or re-run the helper after moving it). + +From a source checkout the same helper is `just install-system-integration` (it stages the assets and calls the script with `sudo`; pass `--desktop gnome`, `--uninstall`, etc. as extra args). + +#### Releasing an AppImage + +Pushing a `v*` tag runs [`.github/workflows/release-linux.yml`](../.github/workflows/release-linux.yml): it builds **x86_64** on `ubuntu-24.04` and **aarch64** on `ubuntu-24.04-arm`, smoke-tests each payload (`--help`), and attaches both AppImages plus `deckd-install-system-integration.sh` to the GitHub release for that tag. A manual **Run workflow** can attach to an existing tag. aarch64 source-builds `python-evdev` (there is no `evdev-binary` wheel for it). + +The tag minus its leading `v` is the version, so `v2026.09.23` yields `deckd-2026.09.23-x86_64.AppImage` and `deckd-2026.09.23-aarch64.AppImage`. A local build without a tag falls back to `version` in `pyproject.toml`. + **NixOS** users can skip all of the above — the flake's home-manager module owns the same user service, and the NixOS module owns the udev rule and `input` group. See [Nix flake, NixOS, and home-manager](#nix-flake-nixos-and-home-manager). ## Nix flake, NixOS, and home-manager diff --git a/docs/adr/0012-linux-distribution-appimage.md b/docs/adr/0012-linux-distribution-appimage.md new file mode 100644 index 0000000..5095bd3 --- /dev/null +++ b/docs/adr/0012-linux-distribution-appimage.md @@ -0,0 +1,134 @@ +# Linux distribution: AppImage as the primary channel, with a privileged integration helper + +Issue #168 asks which channel gets deckd to Linux users with zero toolchain. +The candidate set was tarball, `.deb`/`.rpm`, AppImage, Flatpak/Snap, and +distro repos. This ADR picks AppImage as the primary artifact, keeps the +relocatable tree + install script as the shared substrate every channel +builds on, and rejects Flatpak/Snap outright. + +## Context + +deckd is a per-user desktop-session daemon. Three of its capabilities decide +the channel: + +1. **Global input injection via `/dev/uinput`** — needs a udev rule in + `/etc/udev/rules.d` and membership in the `input` group. Both are **root** + actions. +2. **Reading other apps' windows through a compositor plugin** — a GNOME + Shell extension or a KWin script, installed and enabled **per user**. +3. **Talking to the session D-Bus bus** — the `dbus:` action primitive plus + MPRIS, on the **unfiltered** session bus. + +Plus user-level autostart (systemd user unit or an XDG `.desktop`) and a +bundled Python runtime + client + layouts. + +The GUIDE already rules out Docker for exactly these reasons +(`docs/GUIDE.md:593`): the daemon needs host `/dev/uinput`, the host +session-bus socket, the host display, and still couldn't host the compositor +plugin. The same argument applies to any sandbox that filters devices and the +bus. + +## Decisions + +### Flatpak and Snap are rejected + +A sandboxed package cannot do any of the three load-bearing things: + +- It cannot write `/etc/udev/rules.d` or change group membership — and + `/dev/uinput` is not on Flatpak's device whitelist, so `--device=all` does + not reliably expose it either. +- Flatpak proxies the session bus through `xdg-dbus-proxy`, so only + allowlisted names are reachable; arbitrary session services and the + compositor plugin's own bus traffic break. +- It cannot install or enable a GNOME Shell extension / KWin script from + inside the sandbox, and cannot read other apps' windows through a + compositor plugin. + +This is the Docker argument restated. Recorded here so it isn't relitigated; +these are properties of the sandbox model, not gaps that a manifest can fill. + +### AppImage is the primary channel + +AppImage is **not sandboxed** by default, so the daemon runs with the user's +full user-level capability: D-Bus, serving the client, and talking to the +compositor all work as they do from a checkout. It is one file and +distro-agnostic, which matches the "download, install, run" goal of #165 and +#168. + +Its limits are the *root* step only: + +- It cannot install the udev rule or add the `input` group. There are no + maintainer scripts. This is true of **every** channel — the root step is + unavoidable — but AppImage gives no package manager to own it. +- The focus watcher is user-level and **can** be installed by AppImage: the + GNOME extension goes to `~/.local/share/gnome-shell/extensions` and the KWin + script to the user's KWin directories, no root needed. +- Autostart can use an XDG `.desktop` in `~/.config/autostart`, which is + weaker than the systemd *user* unit (no `graphical-session.target` + ordering, weaker restart semantics). + +Known papercut: the AppImage type-2 runtime needs FUSE (`libfuse2`), which +Ubuntu 22.04+/Debian 12 no longer ship by default. `--appimage-extract-and-run` +is the documented fallback. + +### The privileged step is a first-run helper, not part of the AppImage + +Because the root step exists on every channel, it is factored into a single +`install-system-integration.sh` helper (run with `sudo`) that is idempotent +and has a matching `--uninstall`. It installs: + +- `/etc/udev/rules.d/70-deckd-uinput.rules` (the existing + `packaging/udev/70-deckd-uinput.rules`), then reloads/triggers udev; +- the current user into the `input` group. + +It also installs the user-level pieces — the focus watcher and an XDG +autostart entry — so a single "install" flow covers everything the AppImage +can do. The helper is bundled inside the AppImage under +`usr/share/deckd/integration/` and attached to the release next to the +AppImage, so no asset needs a separate download path. + +### deb/rpm stay as a later, thin wrap of the same tree + +A `.deb`/`.rpm` does not change the payload; it only moves the root step into +maintainer scripts and gives package-managed uninstall. That is worth having, +but it is a wrapper over the same relocatable tree, not a competing design. +Deferred until the AppImage path is proven on real hardware. + +## Relationship to the substrate + +All channels share Phase 0 of #168: + +1. a build recipe that assembles the runtime + client + layouts into a + relocatable tree; +2. a first-run/install script that performs the privileged + user-level + integration; +3. docs. + +The channel is a thin skin over that tree. AppImage is simply the first skin. + +## Resolved sub-decisions (2026-09-25) + +- **Runtime**: PyInstaller `onedir`, reusing the #165 macOS spec shape; the + AppDir wraps the `onedir` output. Parity with macOS wins over avoiding the + freezer's edge cases (aiohttp, dbus). +- **Helper UX**: a shell script run with `sudo` — transparent, + headless-friendly, no PolicyKit dependency. It prints every change it makes + and ships a matching uninstall. +- **Focus watcher + autostart**: the helper auto-installs both, detecting + GNOME vs KDE and writing `~/.config/autostart/deckd.desktop`. The AppImage + itself never silently writes into the user's shell. +- **Arch**: **x86_64 + aarch64** from the start. aarch64 has no + `evdev-binary` wheel, so the daemon's `uinput` extra falls back to the + sdist `evdev` and CI source-builds it before freezing. + +## Consequences + +- The Linux artifact is one AppImage file, attached to a GitHub release by a + workflow that mirrors `release-macos.yml` and reuses the `DECKD_VERSION` + seam from #165. +- The root step is explicit and documented rather than hidden in a package + manager; users on AppImage grant it once with a `sudo` prompt. +- Flatpak/Snap are off the table, so no manifest or portal work is spent on a + model that cannot work. +- The relocatable tree + integration helper are the reusable asset; deb/rpm + and any future channel build on them without touching the payload. diff --git a/docs/adr/README.md b/docs/adr/README.md index d9dad2e..cf13d77 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -82,3 +82,9 @@ _Superseded by: [0011](0011-reflow.md) — the sizing geometry, the band, and th Sizing picks the row count that makes cells largest, using both axes, so no tuned constants remain. Rows fill to the column count with the remainder in the bottom row; the grid block centres on both axes with rows washed left. `minCell` / `maxCell` gain distinct jobs, and overflow becomes a device setting defaulting to `clip`. _Supersedes: [0010](0010-grid-reflow.md) — keeps the ordered-list model, replaces the geometry_ + +## 0012 — Linux distribution: AppImage primary, Flatpak/Snap rejected + +[0012-linux-distribution-appimage.md](0012-linux-distribution-appimage.md) + +AppImage is the primary Linux artifact (unsandboxed, distro-agnostic); the root-only uinput step is a first-run `pkexec`/`sudo` helper shared by all channels. Flatpak/Snap are rejected — a sandbox cannot write the udev rule, see `/dev/uinput`, reach the session bus unfiltered, or install the compositor plugin. deb/rpm remain a later thin wrap of the same relocatable tree. diff --git a/packaging/linux/appimage/AppRun b/packaging/linux/appimage/AppRun new file mode 100755 index 0000000..1990478 --- /dev/null +++ b/packaging/linux/appimage/AppRun @@ -0,0 +1,13 @@ +#!/bin/sh +# deckd AppImage entry point (issue #168). +# +# The frozen launcher resolves its payload via sys._MEIPASS, so it needs no +# path setup here: it seeds layouts into the writable XDG data dir and runs +# the daemon against the bundled client. Extra args pass through, e.g. +# +# ./deckd--x86_64.AppImage --bind 0.0.0.0 +# +# to expose the surface on the LAN (localhost-only by default). +set -e +HERE="$(dirname "$(readlink -f "$0")")" +exec "$HERE/usr/bin/deckd" "$@" diff --git a/packaging/linux/appimage/deckd.desktop b/packaging/linux/appimage/deckd.desktop new file mode 100644 index 0000000..001031e --- /dev/null +++ b/packaging/linux/appimage/deckd.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=deckd +Comment=App-aware touch control surface +Exec=deckd +Icon=deckd +Categories=Utility; +Terminal=false diff --git a/packaging/linux/deckd.spec b/packaging/linux/deckd.spec new file mode 100644 index 0000000..2001948 --- /dev/null +++ b/packaging/linux/deckd.spec @@ -0,0 +1,86 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller spec: freeze the daemon + client for the Linux AppImage (#168). + +Build on Linux: + + just build-linux-appimage + # or: pyinstaller --noconfirm --clean packaging/linux/deckd.spec + +Output: ``dist/deckd/`` (a PyInstaller onedir tree). The AppImage recipe +copies it into ``deckd.AppDir/usr/bin`` and wraps it with ``appimagetool``. + +The entry point is ``packaging/linux/launcher.py``; the built client and +layouts are copied in as data under ``sys._MEIPASS`` (the ``_internal`` dir), +where ``deckd.app_bundle`` looks for them. The udev rule and focus-watcher +sources are *not* frozen in — they are system integration, not runtime, and +the AppImage recipe places them under ``usr/share/deckd/integration`` where +``install-system-integration.sh`` reads them. +""" +import sys +from pathlib import Path + +ROOT = Path(SPECPATH).resolve().parents[1] # SPECPATH is packaging/linux + +# Importable even when the package isn't pip-installed (e.g. a bare +# ``pyinstaller packaging/linux/deckd.spec`` from a checkout). +sys.path.insert(0, str(ROOT / "daemon")) + +datas = [ + (str(ROOT / "client/dist"), "web"), + (str(ROOT / "layouts"), "layouts"), +] +# Per-platform overlay, if present. The daemon auto-discovers ``layouts.linux`` +# beside the layouts dir (see ``_overlay_dir_for``); app_bundle seeds it. +if (ROOT / "layouts.linux").is_dir(): + datas.append((str(ROOT / "layouts.linux"), "layouts.linux")) + +a = Analysis( + [str(ROOT / "packaging/linux/launcher.py")], + pathex=[str(ROOT / "daemon")], + binaries=[], + datas=datas, + # evdev and dbus-fast are imported lazily / guarded in the daemon, so name + # them explicitly: PyInstaller's static walk can miss a conditional import. + # On aarch64 evdev has no wheel and is source-built before this runs. + hiddenimports=[ + "deckd.linux_app", + "evdev", + "dbus_fast", + "dbus_fast.aio", + "dbus_fast.service", + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=["tkinter", "pytest", "PyQt5", "PySide2", "PySide6"], + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="deckd", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + name="deckd", +) diff --git a/packaging/linux/install-system-integration.sh b/packaging/linux/install-system-integration.sh new file mode 100755 index 0000000..08f1bb2 --- /dev/null +++ b/packaging/linux/install-system-integration.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +# deckd Linux system integration (issue #168). +# +# The AppImage cannot write /etc/udev/rules.d or add groups — that is a root +# action on every channel. This script does the root step (the udev rule + +# `input` group) *and* the user-level pieces the AppImage can't do by itself +# (the desktop focus watcher and an XDG autostart entry), so one `sudo` run +# finishes the install. +# +# It reads its assets from an *extracted* AppImage layout: +# +# /70-deckd-uinput.rules +# /gnome-shell/deckd-focus@local/... +# /kwin-script/deckd-focus/... +# +# which the AppImage ships under usr/share/deckd/integration. Point it at an +# AppImage and it extracts them; or pass --assets DIR for a pre-extracted tree +# (`just install-system-integration` stages one from a checkout). +# +# Usage: +# sudo ./install-system-integration.sh ./deckd--x86_64.AppImage +# sudo ./install-system-integration.sh --assets /path/to/integration +# sudo ./install-system-integration.sh --uninstall +# +# Options: +# --appimage PATH AppImage to extract assets from and to autostart +# --assets DIR Use a pre-extracted integration tree instead of an AppImage +# --user NAME Target user (default: $SUDO_USER, else the login user) +# --desktop MODE auto|gnome|kde|none (default auto) — focus watcher to install +# --no-autostart Do not write ~/.config/autostart/deckd.desktop +# --uninstall Remove the udev rule, focus watcher, autostart entry, +# and the user's `input` membership +# -h, --help +# +# Re-running install is safe: assets are replaced, not appended. + +set -euo pipefail + +UDEV_DEST="/etc/udev/rules.d/70-deckd-uinput.rules" +GNOME_UUID="deckd-focus@local" +KWIN_ID="deckd-focus" + +die() { echo "error: $*" >&2; exit 1; } +note() { echo " $*"; } + +usage() { + awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0" + exit "${1:-0}" +} + +APPIMAGE="" +ASSETS="" +ASSETS_DIR="" +TARGET_USER="" +DESKTOP="auto" +AUTOSTART=1 +UNINSTALL=0 + +while [ $# -gt 0 ]; do + case "$1" in + --appimage) APPIMAGE="${2:?--appimage needs a path}"; shift 2 ;; + --assets) ASSETS="${2:?--assets needs a path}"; shift 2 ;; + --user) TARGET_USER="${2:?--user needs a name}"; shift 2 ;; + --desktop) DESKTOP="${2:?--desktop needs a mode}"; shift 2 ;; + --no-autostart) AUTOSTART=0; shift ;; + --uninstall) UNINSTALL=1; shift ;; + -h|--help) usage 0 ;; + -*) die "unknown option: $1" ;; + *) APPIMAGE="$1"; shift ;; # bare arg: the AppImage + esac +done + +[ "$(id -u)" -eq 0 ] || die "must run as root: sudo $0 ..." + +# The user the desktop belongs to. Under sudo that's SUDO_USER; otherwise fall +# back to the owner of the invoking terminal. +if [ -z "$TARGET_USER" ]; then + TARGET_USER="${SUDO_USER:-$(logname 2>/dev/null || echo "${USER:-}")}" +fi +[ -n "$TARGET_USER" ] && [ "$TARGET_USER" != "root" ] \ + || die "could not determine the target user; pass --user NAME" +id "$TARGET_USER" >/dev/null 2>&1 || die "no such user: $TARGET_USER" +HOME_DIR="$(getent passwd "$TARGET_USER" | cut -d: -f6)" +PRIMARY_GROUP="$(id -gn "$TARGET_USER")" +[ -n "$HOME_DIR" ] || die "could not resolve home for $TARGET_USER" + +# Run a command as the target user, with their home. sudo is the common case; +# runuser (util-linux) is the fallback on systems without sudo. +as_user() { + if command -v sudo >/dev/null 2>&1; then + sudo -u "$TARGET_USER" -H -- "$@" + elif command -v runuser >/dev/null 2>&1; then + runuser -u "$TARGET_USER" -- "$@" + else + return 127 + fi +} + +# chown a path (and its contents) to the target user. +own() { chown -R "$TARGET_USER:$PRIMARY_GROUP" "$@"; } + +# --- assets ---------------------------------------------------------------- + +extract_dir="" +cleanup() { [ -n "$extract_dir" ] && rm -rf "$extract_dir"; } +trap cleanup EXIT + +# Set ASSETS_DIR in the current shell (not a command substitution) so the +# mktemp cleanup trap below still owns extract_dir. +resolve_assets() { + if [ -n "$ASSETS" ]; then + ASSETS_DIR="$ASSETS"; return + fi + [ -n "$APPIMAGE" ] || die "pass an AppImage path or --assets DIR" + [ -x "$APPIMAGE" ] || die "AppImage not executable: $APPIMAGE" + extract_dir="$(mktemp -d)" + ( cd "$extract_dir" && "$APPIMAGE" --appimage-extract 'usr/share/deckd/integration/*' >/dev/null ) + ASSETS_DIR="$extract_dir/squashfs-root/usr/share/deckd/integration" +} + +# --- steps ----------------------------------------------------------------- + +install_udev() { + [ -f "$ASSETS_DIR/70-deckd-uinput.rules" ] || die "assets missing the udev rule" + note "+ installing $UDEV_DEST" + install -m 0644 "$ASSETS_DIR/70-deckd-uinput.rules" "$UDEV_DEST" + if command -v udevadm >/dev/null 2>&1; then + udevadm control --reload-rules + udevadm trigger --subsystem-match=misc --sysname-match=uinput + else + note "udevadm not found; reboot for the rule to take effect" + fi + if id -nG "$TARGET_USER" | tr ' ' '\n' | grep -qx input; then + note "= $TARGET_USER is already in the input group" + else + note "+ adding $TARGET_USER to the input group" + usermod -aG input "$TARGET_USER" + note "! log out and back in for the group change to apply" + fi +} + +remove_udev() { + if [ -f "$UDEV_DEST" ]; then + note "- removing $UDEV_DEST" + rm -f "$UDEV_DEST" + command -v udevadm >/dev/null 2>&1 && udevadm control --reload-rules || true + fi + if id -nG "$TARGET_USER" | tr ' ' '\n' | grep -qx input; then + note "- removing $TARGET_USER from the input group" + gpasswd -d "$TARGET_USER" input >/dev/null + note "! log out and back in for the group change to apply" + fi +} + +detect_desktop() { + [ "$DESKTOP" != auto ] && { echo "$DESKTOP"; return; } + case "${XDG_CURRENT_DESKTOP:-}" in + *GNOME*) echo gnome; return ;; + *KDE*|*Plasma*) echo kde; return ;; + esac + if pgrep -u "$TARGET_USER" -x gnome-shell >/dev/null 2>&1; then echo gnome; return; fi + if pgrep -u "$TARGET_USER" -x plasmashell >/dev/null 2>&1 \ + || pgrep -u "$TARGET_USER" -x kwin_wayland >/dev/null 2>&1; then + echo kde; return + fi + echo none +} + +install_focus_watcher() { + local mode; mode="$(detect_desktop)" + case "$mode" in + gnome) + local dest="$HOME_DIR/.local/share/gnome-shell/extensions/$GNOME_UUID" + [ -d "$ASSETS_DIR/gnome-shell/$GNOME_UUID" ] || die "assets missing the GNOME extension" + note "+ installing GNOME Shell extension $GNOME_UUID" + rm -rf "$dest" + mkdir -p "$(dirname "$dest")" + cp -R "$ASSETS_DIR/gnome-shell/$GNOME_UUID" "$dest" + own "$(dirname "$dest")" + if as_user gnome-extensions enable "$GNOME_UUID" >/dev/null 2>&1; then + note "= extension enabled" + else + note "! log out and back in, then: gnome-extensions enable $GNOME_UUID" + fi + ;; + kde) + local dest="$HOME_DIR/.local/share/kwin/scripts/$KWIN_ID" + [ -d "$ASSETS_DIR/kwin-script/$KWIN_ID" ] || die "assets missing the KWin script" + note "+ installing KWin script $KWIN_ID" + rm -rf "$dest" + mkdir -p "$(dirname "$dest")" + cp -R "$ASSETS_DIR/kwin-script/$KWIN_ID" "$dest" + own "$(dirname "$dest")" + as_user kwriteconfig6 --file kwinrc --group Plugins \ + --key "${KWIN_ID}Enabled" true 2>/dev/null || true + as_user qdbus org.kde.KWin /KWin org.kde.KWin.reconfigure >/dev/null 2>&1 || true + as_user qdbus org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript \ + "$dest/contents/code/main.js" "$KWIN_ID" >/dev/null 2>&1 || true + note "= KWin script installed; log out/in if focus does not track" + ;; + *) + note "! no GNOME/KDE session detected; skipping the focus watcher." + note " Re-run with --desktop gnome|kde once your desktop is up," + note " or install it from your checkout: 'just install-focus-extension' / 'just install-focus-kwin'." + ;; + esac +} + +remove_focus_watcher() { + local ext="$HOME_DIR/.local/share/gnome-shell/extensions/$GNOME_UUID" + local kw="$HOME_DIR/.local/share/kwin/scripts/$KWIN_ID" + [ -d "$ext" ] && { note "- removing GNOME extension $GNOME_UUID"; rm -rf "$ext"; } || true + [ -d "$kw" ] && { note "- removing KWin script $KWIN_ID"; rm -rf "$kw"; } || true +} + +install_autostart() { + [ "$AUTOSTART" -eq 1 ] || return 0 + [ -n "$APPIMAGE" ] || { note "! no AppImage path given; skipping autostart"; return 0; } + local abs; abs="$(readlink -f "$APPIMAGE")" + local dest="$HOME_DIR/.config/autostart/deckd.desktop" + note "+ writing $dest" + mkdir -p "$(dirname "$dest")" + cat > "$dest" <-x86_64.AppImage --bind 0.0.0.0`` exposes the surface on +the LAN. +""" +from __future__ import annotations + +import sys + + +def main() -> None: + from deckd.__main__ import main as deckd_main + from deckd.linux_app import build_argv + + extra = sys.argv[1:] + sys.argv = ["deckd", *build_argv(extra)] + deckd_main() + + +if __name__ == "__main__": + main() diff --git a/tests/test_linux_app.py b/tests/test_linux_app.py new file mode 100644 index 0000000..a2a62f3 --- /dev/null +++ b/tests/test_linux_app.py @@ -0,0 +1,92 @@ +"""Tests for the Linux AppImage helpers (issue #168). + +The AppImage build itself needs Linux tooling, but everything path/argv-shaped +lives in ``deckd.linux_app`` and is exercised here on any host — the same +arrangement as ``test_app.py`` for the macOS bundle. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from deckd import app_bundle, linux_app + + +def _write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +def test_resource_root_dev_fallback(monkeypatch) -> None: + monkeypatch.delattr(app_bundle.sys, "_MEIPASS", raising=False) + root = linux_app.resource_root() + assert (root / "daemon" / "deckd").is_dir() + assert (root / "pyproject.toml").is_file() + + +def test_data_dir_honours_xdg_data_home(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) + assert linux_app.data_dir() == tmp_path / "xdg" / "deckd" + + +def test_data_dir_defaults_to_local_share(monkeypatch, tmp_path: Path) -> None: + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert linux_app.data_dir() == tmp_path / ".local" / "share" / "deckd" + + +def test_default_log_file_lives_in_data_dir(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + assert linux_app.default_log_file() == tmp_path / "deckd" / "deckd.log" + + +def test_overlay_src_is_linux(tmp_path: Path) -> None: + assert linux_app.overlay_src(tmp_path) == tmp_path / "layouts.linux" + + +def test_seed_layouts_uses_linux_overlay_suffix(tmp_path: Path) -> None: + src = tmp_path / "bundled" + _write(src / "default.yaml", "id: default\n") + overlay = tmp_path / "bundled.linux" + _write(overlay / "firefox.yaml", "id: firefox\n") + dest = tmp_path / "data" / "layouts" + + assert linux_app.seed_layouts(src, dest, overlay=overlay) is True + assert (dest / "default.yaml").read_text() == "id: default\n" + # The daemon looks for ``layouts.linux`` beside the layouts dir. + assert (dest.parent / "layouts.linux" / "firefox.yaml").is_file() + + # A user's edits survive: an existing dir is never re-seeded. + assert linux_app.seed_layouts(src, dest, overlay=overlay) is False + + +def test_prepare_seeds_writable_layouts_once(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + monkeypatch.delattr(app_bundle.sys, "_MEIPASS", raising=False) + + layouts_dir, web, log_file = linux_app.prepare() + assert layouts_dir == tmp_path / "deckd" / "layouts" + assert layouts_dir.joinpath("default.yaml").exists() + assert web.name == "web" + assert log_file == tmp_path / "deckd" / "deckd.log" + assert log_file.parent.is_dir() + + +def test_build_argv_passes_extra_through(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + monkeypatch.delattr(app_bundle.sys, "_MEIPASS", raising=False) + + argv = linux_app.build_argv(["--bind", "0.0.0.0"]) + assert argv[argv.index("--client-dist") + 1].endswith("web") + assert argv[argv.index("--log-file") + 1].endswith("deckd.log") + # Extra flags land after the bundled ones, so the user's win. + assert argv[-2:] == ["--bind", "0.0.0.0"] + + +def test_build_argv_localhost_by_default(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + monkeypatch.delattr(app_bundle.sys, "_MEIPASS", raising=False) + + argv = linux_app.build_argv() + assert "--bind" not in argv From 6f12aabd52d52aec9869d9353060edb967cb4050 Mon Sep 17 00:00:00 2001 From: Jono Date: Mon, 28 Sep 2026 13:05:18 -0700 Subject: [PATCH 2/3] fix(linux): extract AppImage integration assets under binfmt wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A binfmt AppImage handler (NixOS's `programs.appimage`, verified on a GNOME/Wayland NixOS box) runs the payload directly, so the embedded runtime's `--appimage-extract` never fires and the install helper died before it found its assets. The frozen launcher now handles `--extract-integration DIR` — copy `usr/share/deckd/integration` out of its own AppDir — and the helper falls back to it when the runtime extraction leaves no tree. The release workflow smoke-tests the flag so a release can't ship without the assets. Also record what an install changed in `/var/lib/deckd/system-integration..state`, so `--uninstall` only removes what this helper created. Previously it removed the user's `input` membership and focus extension unconditionally, which would break a NixOS/home-manager or source install on the same machine. Verified on NixOS/GNOME: built the AppImage, booted it (health, client, layout seeding, log file), injected keys/scroll through its uinput device and read them back at the evdev level, and ran the helper's install→uninstall lifecycle sandboxed as namespace-root (pre-existing group/extension preserved). Live focus-watcher check still pending a desktop session. --- .github/workflows/release-linux.yml | 12 ++ daemon/deckd/linux_app.py | 31 +++++ docs/GUIDE.md | 4 +- packaging/linux/install-system-integration.sh | 110 ++++++++++++++++-- packaging/linux/launcher.py | 21 +++- tests/test_linux_app.py | 40 +++++++ 6 files changed, 204 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index a8cb738..51ff445 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -92,6 +92,18 @@ jobs: - name: Smoke-test the AppImage run: APPIMAGE_EXTRACT_AND_RUN=1 dist/deckd-*.AppImage --help >/dev/null + # The install helper extracts its assets with `--appimage-extract`, and + # falls back to this launcher flag when a binfmt wrapper intercepts the + # AppImage first. Exercise the fallback so a release can't ship without + # the assets it needs. + - name: Smoke-test integration extraction + run: | + tmp="$(mktemp -d)" + APPIMAGE_EXTRACT_AND_RUN=1 dist/deckd-*.AppImage --extract-integration "$tmp" + test -f "$tmp/70-deckd-uinput.rules" + test -d "$tmp/gnome-shell/deckd-focus@local" + test -f "$tmp/install-system-integration.sh" + - uses: actions/upload-artifact@v4 with: name: deckd-linux-appimage-${{ matrix.arch }} diff --git a/daemon/deckd/linux_app.py b/daemon/deckd/linux_app.py index a800aec..701c413 100644 --- a/daemon/deckd/linux_app.py +++ b/daemon/deckd/linux_app.py @@ -12,6 +12,8 @@ from __future__ import annotations import os +import shutil +import sys from pathlib import Path from .app_bundle import ( @@ -28,12 +30,15 @@ __all__ = [ "APP_NAME", "DEFAULT_PORT", + "EXTRACT_INTEGRATION_FLAG", "app_argv", "build_argv", "bundle_version", "client_dist", "data_dir", "default_log_file", + "extract_integration", + "integration_src", "layouts_src", "overlay_src", "prepare", @@ -43,6 +48,13 @@ APP_NAME = "deckd" +# Frozen-launcher-only flag: copy the AppDir's integration assets (udev rule, +# focus watchers, install helper) to a directory and exit. The install helper +# falls back to it when the AppImage runtime won't honour `--appimage-extract` +# — e.g. a binfmt wrapper like NixOS's `programs.appimage` runs the payload +# directly, so the runtime's own extraction flags never reach it. +EXTRACT_INTEGRATION_FLAG = "--extract-integration" + def data_dir() -> Path: """``$XDG_DATA_HOME/deckd`` (``~/.local/share/deckd``) — writable data. @@ -72,6 +84,25 @@ def overlay_src(root: Path) -> Path: return _overlay_src(root, "linux") +def integration_src() -> Path: + """Bundled system-integration assets in the AppDir. + + The AppImage recipe places the udev rule, the GNOME/KWin focus watchers, + and ``install-system-integration.sh`` under ``usr/share/deckd/integration`` + beside the frozen launcher (``usr/bin/deckd``). + """ + return Path(sys.executable).resolve().parents[1] / "share" / "deckd" / "integration" + + +def extract_integration(dest: Path) -> None: + """Copy the bundled integration assets to ``dest`` (created if needed).""" + src = integration_src() + if not src.is_dir(): + raise FileNotFoundError(f"no bundled integration assets at {src}") + dest.mkdir(parents=True, exist_ok=True) + shutil.copytree(src, dest, dirs_exist_ok=True) + + def seed_layouts(src: Path, dest: Path, *, overlay: Path | None = None) -> bool: """Seed layouts + the ``.linux`` overlay into the writable data dir.""" return _seed_layouts(src, dest, overlay=overlay, overlay_suffix="linux") diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 1ae622d..81614d1 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -690,7 +690,9 @@ chmod +x deckd-install-system-integration.sh sudo ./deckd-install-system-integration.sh ./deckd--x86_64.AppImage ``` -It installs the udev rule and adds you to `input`, installs the GNOME Shell extension or KWin script for the detected desktop (override with `--desktop gnome|kde`), and writes `~/.config/autostart/deckd.desktop` so deckd starts with your session. Re-run with `--uninstall` to remove all of it. **Log out and back in** for the group change to take effect. The autostart entry points at the AppImage's path, so keep it where it is (or re-run the helper after moving it). +It installs the udev rule and adds you to `input`, installs the GNOME Shell extension or KWin script for the detected desktop (override with `--desktop gnome|kde`), and writes `~/.config/autostart/deckd.desktop` so deckd starts with your session. **Log out and back in** for the group change to take effect. The autostart entry points at the AppImage's path, so keep it where it is (or re-run the helper after moving it). + +Re-run with `--uninstall` to undo the install. It removes exactly what the helper created — recorded per user in `/var/lib/deckd/system-integration..state` — so a pre-existing `input` membership or focus extension (say, from a NixOS/home-manager install) is left alone. If the AppImage runtime never sees `--appimage-extract` (a binfmt wrapper such as NixOS's `programs.appimage` runs the payload directly), the helper falls back to the bundled launcher's `--extract-integration` and still works. From a source checkout the same helper is `just install-system-integration` (it stages the assets and calls the script with `sudo`; pass `--desktop gnome`, `--uninstall`, etc. as extra args). diff --git a/packaging/linux/install-system-integration.sh b/packaging/linux/install-system-integration.sh index 08f1bb2..7a797d2 100755 --- a/packaging/linux/install-system-integration.sh +++ b/packaging/linux/install-system-integration.sh @@ -15,7 +15,10 @@ # # which the AppImage ships under usr/share/deckd/integration. Point it at an # AppImage and it extracts them; or pass --assets DIR for a pre-extracted tree -# (`just install-system-integration` stages one from a checkout). +# (`just install-system-integration` stages one from a checkout). If the +# AppImage runtime ignores `--appimage-extract` (a binfmt wrapper such as +# NixOS's `programs.appimage` runs the payload directly), it falls back to the +# bundled launcher's `--extract-integration`. # # Usage: # sudo ./install-system-integration.sh ./deckd--x86_64.AppImage @@ -28,8 +31,12 @@ # --user NAME Target user (default: $SUDO_USER, else the login user) # --desktop MODE auto|gnome|kde|none (default auto) — focus watcher to install # --no-autostart Do not write ~/.config/autostart/deckd.desktop -# --uninstall Remove the udev rule, focus watcher, autostart entry, -# and the user's `input` membership +# --uninstall Remove what this helper installed (recorded in +# /var/lib/deckd/system-integration..state): the udev +# rule, the focus watcher, the autostart entry, and the +# user's `input` membership — the last two only if this +# helper created them, so a NixOS/home-manager install +# isn't disturbed. # -h, --help # # Re-running install is safe: assets are replaced, not appended. @@ -37,6 +44,7 @@ set -euo pipefail UDEV_DEST="/etc/udev/rules.d/70-deckd-uinput.rules" +STATE_DIR="/var/lib/deckd" GNOME_UUID="deckd-focus@local" KWIN_ID="deckd-focus" @@ -84,6 +92,9 @@ HOME_DIR="$(getent passwd "$TARGET_USER" | cut -d: -f6)" PRIMARY_GROUP="$(id -gn "$TARGET_USER")" [ -n "$HOME_DIR" ] || die "could not resolve home for $TARGET_USER" +# Per-user record of what this helper installed (see "install state" below). +STATE_FILE="$STATE_DIR/system-integration.$TARGET_USER.state" + # Run a command as the target user, with their home. sudo is the common case; # runuser (util-linux) is the fallback on systems without sudo. as_user() { @@ -99,6 +110,45 @@ as_user() { # chown a path (and its contents) to the target user. own() { chown -R "$TARGET_USER:$PRIMARY_GROUP" "$@"; } +# --- install state --------------------------------------------------------- +# +# What a previous run changed, so --uninstall undoes only our own work: a +# machine that already had the input group (NixOS/home-manager) or the GNOME +# extension (a source install) must not lose them. + +S_UDEV=0 +S_GROUP=0 +S_GNOME=0 +S_KDE=0 +S_AUTOSTART=0 + +state_get() { + [ -f "$STATE_FILE" ] || return 0 + sed -n "s/^$1=//p" "$STATE_FILE" | tail -n1 +} + +load_state() { + local v + v="$(state_get udev)"; [ -n "$v" ] && S_UDEV="$v" + v="$(state_get group)"; [ -n "$v" ] && S_GROUP="$v" + v="$(state_get gnome)"; [ -n "$v" ] && S_GNOME="$v" + v="$(state_get kde)"; [ -n "$v" ] && S_KDE="$v" + v="$(state_get autostart)"; [ -n "$v" ] && S_AUTOSTART="$v" + return 0 +} + +write_state() { + mkdir -p "$STATE_DIR" + cat > "$STATE_FILE" </dev/null ) - ASSETS_DIR="$extract_dir/squashfs-root/usr/share/deckd/integration" + # Preferred: ask the AppImage's own runtime to unpack the integration tree. + if ( cd "$extract_dir" && "$APPIMAGE" --appimage-extract 'usr/share/deckd/integration/*' >/dev/null 2>&1 ) \ + && [ -d "$extract_dir/squashfs-root/usr/share/deckd/integration" ]; then + ASSETS_DIR="$extract_dir/squashfs-root/usr/share/deckd/integration" + return + fi + # Some systems intercept AppImages before their runtime sees the flags + # (NixOS's `programs.appimage` binfmt wrapper, for one), so the extraction + # above is ignored and the payload runs instead. The frozen launcher can + # copy the assets out of its own AppDir either way. + rm -rf "$extract_dir"; extract_dir="$(mktemp -d)" + "$APPIMAGE" --extract-integration "$extract_dir" >/dev/null 2>&1 \ + || die "could not extract integration assets from $APPIMAGE" + [ -f "$extract_dir/70-deckd-uinput.rules" ] \ + || die "no integration assets found in $APPIMAGE" + ASSETS_DIR="$extract_dir" } # --- steps ----------------------------------------------------------------- @@ -124,6 +188,7 @@ install_udev() { [ -f "$ASSETS_DIR/70-deckd-uinput.rules" ] || die "assets missing the udev rule" note "+ installing $UDEV_DEST" install -m 0644 "$ASSETS_DIR/70-deckd-uinput.rules" "$UDEV_DEST" + S_UDEV=1 if command -v udevadm >/dev/null 2>&1; then udevadm control --reload-rules udevadm trigger --subsystem-match=misc --sysname-match=uinput @@ -132,19 +197,24 @@ install_udev() { fi if id -nG "$TARGET_USER" | tr ' ' '\n' | grep -qx input; then note "= $TARGET_USER is already in the input group" + [ "$S_GROUP" = 1 ] || S_GROUP=0 # pre-existing; uninstall must leave it else note "+ adding $TARGET_USER to the input group" usermod -aG input "$TARGET_USER" + S_GROUP=1 note "! log out and back in for the group change to apply" fi } -remove_udev() { +remove_udev_rule() { if [ -f "$UDEV_DEST" ]; then note "- removing $UDEV_DEST" rm -f "$UDEV_DEST" command -v udevadm >/dev/null 2>&1 && udevadm control --reload-rules || true fi +} + +remove_input_group() { if id -nG "$TARGET_USER" | tr ' ' '\n' | grep -qx input; then note "- removing $TARGET_USER from the input group" gpasswd -d "$TARGET_USER" input >/dev/null @@ -176,6 +246,7 @@ install_focus_watcher() { rm -rf "$dest" mkdir -p "$(dirname "$dest")" cp -R "$ASSETS_DIR/gnome-shell/$GNOME_UUID" "$dest" + S_GNOME=1 own "$(dirname "$dest")" if as_user gnome-extensions enable "$GNOME_UUID" >/dev/null 2>&1; then note "= extension enabled" @@ -190,6 +261,7 @@ install_focus_watcher() { rm -rf "$dest" mkdir -p "$(dirname "$dest")" cp -R "$ASSETS_DIR/kwin-script/$KWIN_ID" "$dest" + S_KDE=1 own "$(dirname "$dest")" as_user kwriteconfig6 --file kwinrc --group Plugins \ --key "${KWIN_ID}Enabled" true 2>/dev/null || true @@ -206,10 +278,13 @@ install_focus_watcher() { esac } -remove_focus_watcher() { +remove_gnome_extension() { local ext="$HOME_DIR/.local/share/gnome-shell/extensions/$GNOME_UUID" - local kw="$HOME_DIR/.local/share/kwin/scripts/$KWIN_ID" [ -d "$ext" ] && { note "- removing GNOME extension $GNOME_UUID"; rm -rf "$ext"; } || true +} + +remove_kwin_script() { + local kw="$HOME_DIR/.local/share/kwin/scripts/$KWIN_ID" [ -d "$kw" ] && { note "- removing KWin script $KWIN_ID"; rm -rf "$kw"; } || true } @@ -230,6 +305,7 @@ Terminal=false X-GNOME-Autostart-enabled=true EOF own "$dest" "$(dirname "$dest")" + S_AUTOSTART=1 note "! autostart points at $abs; keep the AppImage there or re-run" } @@ -242,13 +318,24 @@ remove_autostart() { if [ "$UNINSTALL" -eq 1 ]; then echo "Uninstalling deckd system integration for $TARGET_USER" - remove_autostart - remove_focus_watcher - remove_udev + if [ ! -f "$STATE_FILE" ]; then + echo " no install recorded by this helper ($STATE_FILE is missing)." + echo " Nothing to remove." + exit 0 + fi + load_state + [ "$S_AUTOSTART" = 1 ] && remove_autostart || true + [ "$S_GNOME" = 1 ] && remove_gnome_extension || true + [ "$S_KDE" = 1 ] && remove_kwin_script || true + [ "$S_UDEV" = 1 ] && remove_udev_rule || true + [ "$S_GROUP" = 1 ] && remove_input_group || true + rm -f "$STATE_FILE" + rmdir "$STATE_DIR" 2>/dev/null || true echo "Done. Log out and back in to apply the group change." exit 0 fi +load_state resolve_assets [ -d "$ASSETS_DIR" ] || die "assets dir not found: $ASSETS_DIR" @@ -256,6 +343,7 @@ echo "Installing deckd system integration for $TARGET_USER" install_focus_watcher install_autostart install_udev +write_state echo echo "Done." echo " - Run the AppImage (or log out/in for the autostart entry)." diff --git a/packaging/linux/launcher.py b/packaging/linux/launcher.py index b27c147..fe21b18 100644 --- a/packaging/linux/launcher.py +++ b/packaging/linux/launcher.py @@ -7,18 +7,35 @@ Extra CLI args pass straight through, so ``./deckd--x86_64.AppImage --bind 0.0.0.0`` exposes the surface on the LAN. + +One arg is intercepted here rather than passed on: +``--extract-integration DIR`` copies the AppDir's ``usr/share/deckd/integration`` +tree to DIR and exits. ``install-system-integration.sh`` uses it as a fallback +when the AppImage runtime can't be asked to extract itself (a binfmt wrapper +like NixOS's ``programs.appimage`` runs the payload directly, so the runtime's +``--appimage-extract`` never sees the flag). """ from __future__ import annotations import sys +from pathlib import Path def main() -> None: + from deckd import linux_app from deckd.__main__ import main as deckd_main - from deckd.linux_app import build_argv extra = sys.argv[1:] - sys.argv = ["deckd", *build_argv(extra)] + if extra[:1] == [linux_app.EXTRACT_INTEGRATION_FLAG]: + if len(extra) != 2: + raise SystemExit(f"usage: deckd {linux_app.EXTRACT_INTEGRATION_FLAG} DIR") + try: + linux_app.extract_integration(Path(extra[1])) + except (OSError, FileNotFoundError) as exc: + raise SystemExit(f"error: {exc}") from exc + return + + sys.argv = ["deckd", *linux_app.build_argv(extra)] deckd_main() diff --git a/tests/test_linux_app.py b/tests/test_linux_app.py index a2a62f3..c69f44d 100644 --- a/tests/test_linux_app.py +++ b/tests/test_linux_app.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import shutil from pathlib import Path import pytest @@ -90,3 +91,42 @@ def test_build_argv_localhost_by_default(monkeypatch, tmp_path: Path) -> None: argv = linux_app.build_argv() assert "--bind" not in argv + + +def _fake_appdir(tmp_path: Path) -> Path: + """An AppDir skeleton with the frozen launcher and integration assets.""" + appdir = tmp_path / "deckd.AppDir" + (appdir / "usr" / "bin").mkdir(parents=True) + exe = appdir / "usr" / "bin" / "deckd" + exe.write_text("#!/bin/sh\n") + integration = appdir / "usr" / "share" / "deckd" / "integration" + _write(integration / "70-deckd-uinput.rules", "KERNEL==\"uinput\"\n") + _write(integration / "gnome-shell" / "deckd-focus@local" / "extension.js", "// x\n") + return appdir + + +def test_integration_src_is_beside_the_launcher(monkeypatch, tmp_path: Path) -> None: + appdir = _fake_appdir(tmp_path) + monkeypatch.setattr(linux_app.sys, "executable", str(appdir / "usr" / "bin" / "deckd")) + + assert linux_app.integration_src() == appdir / "usr" / "share" / "deckd" / "integration" + + +def test_extract_integration_copies_tree(monkeypatch, tmp_path: Path) -> None: + appdir = _fake_appdir(tmp_path) + monkeypatch.setattr(linux_app.sys, "executable", str(appdir / "usr" / "bin" / "deckd")) + dest = tmp_path / "out" + + linux_app.extract_integration(dest) + + assert (dest / "70-deckd-uinput.rules").is_file() + assert (dest / "gnome-shell" / "deckd-focus@local" / "extension.js").is_file() + + +def test_extract_integration_rejects_missing_assets(monkeypatch, tmp_path: Path) -> None: + appdir = _fake_appdir(tmp_path) + shutil.rmtree(appdir / "usr" / "share") + monkeypatch.setattr(linux_app.sys, "executable", str(appdir / "usr" / "bin" / "deckd")) + + with pytest.raises(FileNotFoundError): + linux_app.extract_integration(tmp_path / "out") From 40a8cd053dcf86d8b2e8a29c24685bff36e0f64b Mon Sep 17 00:00:00 2001 From: Jono Date: Mon, 28 Sep 2026 23:33:50 -0700 Subject: [PATCH 3/3] feat(linux): ship the app icon in the AppImage and install it Merge main for the branding assets, then use the committed PNGs (`just icons`) instead of rendering the SVG at build time: - deckd.png at the AppDir root, so appimagetool's .DirIcon is a PNG rather than an SVG (friendlier to file managers/thumbnailers); - 192/512 copies under usr/share/icons/hicolor so desktop integration resolves the .desktop's Icon=deckd; - one in usr/share/deckd/integration, which the helper copies into the user's hicolor theme and points the autostart entry's Icon=deckd at. The helper records the icon in its install state, so --uninstall removes it. Drops the now-unused librsvg2-bin CI dependency; the release smoke test checks the icon rides along in the integration tree. --- .github/workflows/release-linux.yml | 8 +++-- Justfile | 25 +++++++++------- docs/GUIDE.md | 2 +- packaging/linux/install-system-integration.sh | 30 ++++++++++++++++--- 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 51ff445..324c7ed 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -63,10 +63,11 @@ jobs: - uses: extractions/setup-just@v4 - # librsvg2-bin gives the recipe rsvg-convert for the icon; build-essential - # provides the compiler evdev's source build needs on aarch64. + # build-essential provides the compiler evdev's source build needs on + # aarch64. Icons come from the committed PNGs (`just icons`), so no + # SVG rasterizer is required. - name: Install build tools - run: sudo apt-get update && sudo apt-get install -y librsvg2-bin build-essential + run: sudo apt-get update && sudo apt-get install -y build-essential # evdev (uinput) + dbus-fast ([dbus]) + PyInstaller. Installing these # first also stops the recipe's `uv pip install` fallback from firing. @@ -101,6 +102,7 @@ jobs: tmp="$(mktemp -d)" APPIMAGE_EXTRACT_AND_RUN=1 dist/deckd-*.AppImage --extract-integration "$tmp" test -f "$tmp/70-deckd-uinput.rules" + test -f "$tmp/deckd.png" test -d "$tmp/gnome-shell/deckd-focus@local" test -f "$tmp/install-system-integration.sh" diff --git a/Justfile b/Justfile index 1dcc20e..91031f2 100644 --- a/Justfile +++ b/Justfile @@ -542,16 +542,20 @@ build-linux-appimage: cp packaging/linux/appimage/AppRun "$appdir/AppRun" chmod +x "$appdir/AppRun" cp packaging/linux/appimage/deckd.desktop "$appdir/" - # appimagetool wants deckd.png (or deckd.svg) at the AppDir root. - if command -v rsvg-convert >/dev/null 2>&1; then - rsvg-convert -w 512 -h 512 -o "$appdir/deckd.png" client/public/icon.svg - elif command -v magick >/dev/null 2>&1; then - magick -background none client/public/icon.svg -resize 512x512 "$appdir/deckd.png" - elif command -v convert >/dev/null 2>&1; then - convert -background none client/public/icon.svg -resize 512x512 "$appdir/deckd.png" - else - cp client/public/icon.svg "$appdir/deckd.svg" - fi + # Icons: the committed PNGs are the single source of truth (regenerate with + # `just icons`). appimagetool turns the root deckd.png into .DirIcon, and the + # hicolor copies let desktop integration resolve the .desktop's Icon=deckd. + [ -f client/public/icon-512.png ] \ + || { echo "missing client/public/icon-512.png; run: just icons" >&2; exit 1; } + cp client/public/icon-512.png "$appdir/deckd.png" + for size in 192 512; do + mkdir -p "$appdir/usr/share/icons/hicolor/${size}x${size}/apps" + cp "client/public/icon-${size}.png" \ + "$appdir/usr/share/icons/hicolor/${size}x${size}/apps/deckd.png" + done + # The install helper copies the icon into the user's theme, so it rides + # along in the integration tree it extracts. + cp client/public/icon-512.png "$appdir/usr/share/deckd/integration/deckd.png" tooling="${XDG_CACHE_HOME:-$HOME/.cache}/deckd/appimagetool-${arch}.AppImage" if [ ! -x "$tooling" ]; then @@ -580,6 +584,7 @@ install-system-integration *args: cp packaging/udev/70-deckd-uinput.rules "$stage/" cp -R packaging/gnome-shell/deckd-focus@local "$stage/gnome-shell/" cp -R packaging/kwin-script/deckd-focus "$stage/kwin-script/" + cp client/public/icon-512.png "$stage/deckd.png" sudo packaging/linux/install-system-integration.sh --assets "$stage" {{args}} # Run the Nix flake checks: builds packages.deckd and the focus-watcher diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 81614d1..abb5cbe 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -690,7 +690,7 @@ chmod +x deckd-install-system-integration.sh sudo ./deckd-install-system-integration.sh ./deckd--x86_64.AppImage ``` -It installs the udev rule and adds you to `input`, installs the GNOME Shell extension or KWin script for the detected desktop (override with `--desktop gnome|kde`), and writes `~/.config/autostart/deckd.desktop` so deckd starts with your session. **Log out and back in** for the group change to take effect. The autostart entry points at the AppImage's path, so keep it where it is (or re-run the helper after moving it). +It installs the udev rule and adds you to `input`, installs the GNOME Shell extension or KWin script for the detected desktop (override with `--desktop gnome|kde`), installs the deckd mark into `~/.local/share/icons/hicolor` (`Icon=deckd` on the autostart entry resolves to it), and writes `~/.config/autostart/deckd.desktop` so deckd starts with your session. **Log out and back in** for the group change to take effect. The autostart entry points at the AppImage's path, so keep it where it is (or re-run the helper after moving it). Re-run with `--uninstall` to undo the install. It removes exactly what the helper created — recorded per user in `/var/lib/deckd/system-integration..state` — so a pre-existing `input` membership or focus extension (say, from a NixOS/home-manager install) is left alone. If the AppImage runtime never sees `--appimage-extract` (a binfmt wrapper such as NixOS's `programs.appimage` runs the payload directly), the helper falls back to the bundled launcher's `--extract-integration` and still works. diff --git a/packaging/linux/install-system-integration.sh b/packaging/linux/install-system-integration.sh index 7a797d2..2c30189 100755 --- a/packaging/linux/install-system-integration.sh +++ b/packaging/linux/install-system-integration.sh @@ -10,6 +10,7 @@ # It reads its assets from an *extracted* AppImage layout: # # /70-deckd-uinput.rules +# /deckd.png # /gnome-shell/deckd-focus@local/... # /kwin-script/deckd-focus/... # @@ -33,10 +34,10 @@ # --no-autostart Do not write ~/.config/autostart/deckd.desktop # --uninstall Remove what this helper installed (recorded in # /var/lib/deckd/system-integration..state): the udev -# rule, the focus watcher, the autostart entry, and the -# user's `input` membership — the last two only if this -# helper created them, so a NixOS/home-manager install -# isn't disturbed. +# rule, the focus watcher, the app icon, the autostart +# entry, and the user's `input` membership — the last ones +# only if this helper created them, so a NixOS/home-manager +# install isn't disturbed. # -h, --help # # Re-running install is safe: assets are replaced, not appended. @@ -120,6 +121,7 @@ S_UDEV=0 S_GROUP=0 S_GNOME=0 S_KDE=0 +S_ICON=0 S_AUTOSTART=0 state_get() { @@ -133,6 +135,7 @@ load_state() { v="$(state_get group)"; [ -n "$v" ] && S_GROUP="$v" v="$(state_get gnome)"; [ -n "$v" ] && S_GNOME="$v" v="$(state_get kde)"; [ -n "$v" ] && S_KDE="$v" + v="$(state_get icon)"; [ -n "$v" ] && S_ICON="$v" v="$(state_get autostart)"; [ -n "$v" ] && S_AUTOSTART="$v" return 0 } @@ -144,6 +147,7 @@ udev=$S_UDEV group=$S_GROUP gnome=$S_GNOME kde=$S_KDE +icon=$S_ICON autostart=$S_AUTOSTART EOF chmod 0644 "$STATE_FILE" @@ -288,6 +292,21 @@ remove_kwin_script() { [ -d "$kw" ] && { note "- removing KWin script $KWIN_ID"; rm -rf "$kw"; } || true } +install_icon() { + [ -f "$ASSETS_DIR/deckd.png" ] || { note "! no icon in assets; skipping"; return 0; } + local dest="$HOME_DIR/.local/share/icons/hicolor/512x512/apps/deckd.png" + note "+ installing app icon $dest" + mkdir -p "$(dirname "$dest")" + cp "$ASSETS_DIR/deckd.png" "$dest" + S_ICON=1 + own "$(dirname "$dest")" +} + +remove_icon() { + local dest="$HOME_DIR/.local/share/icons/hicolor/512x512/apps/deckd.png" + [ -f "$dest" ] && { note "- removing app icon $dest"; rm -f "$dest"; } || true +} + install_autostart() { [ "$AUTOSTART" -eq 1 ] || return 0 [ -n "$APPIMAGE" ] || { note "! no AppImage path given; skipping autostart"; return 0; } @@ -300,6 +319,7 @@ install_autostart() { Type=Application Name=deckd Comment=App-aware touch control surface +Icon=deckd Exec="$abs" Terminal=false X-GNOME-Autostart-enabled=true @@ -327,6 +347,7 @@ if [ "$UNINSTALL" -eq 1 ]; then [ "$S_AUTOSTART" = 1 ] && remove_autostart || true [ "$S_GNOME" = 1 ] && remove_gnome_extension || true [ "$S_KDE" = 1 ] && remove_kwin_script || true + [ "$S_ICON" = 1 ] && remove_icon || true [ "$S_UDEV" = 1 ] && remove_udev_rule || true [ "$S_GROUP" = 1 ] && remove_input_group || true rm -f "$STATE_FILE" @@ -341,6 +362,7 @@ resolve_assets echo "Installing deckd system integration for $TARGET_USER" install_focus_watcher +install_icon install_autostart install_udev write_state