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