Skip to content

feat(linux): packaged AppImage + release workflow (#168) - #171

Merged
jonocodes merged 4 commits into
mainfrom
feat/linux-appimage-168
Sep 30, 2026
Merged

jonocodes merged 4 commits into
mainfrom
feat/linux-appimage-168

Conversation

@jonocodes

@jonocodes jonocodes commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Phase 0 of #168.

What

Zero-toolchain Linux install. A self-contained AppImage carries a private Python runtime, the built client, and the layouts; a small sudo helper owns the parts an AppImage cannot (udev rule + input group) plus the user-level pieces (desktop focus watcher, icon, XDG autostart).

Changes

  • packaging/linux/ — PyInstaller onedir spec + launcher.py, AppDir glue (AppRun, deckd.desktop, committed PNG icons), and install-system-integration.sh (idempotent, state-tracked, with --uninstall).
  • daemon/deckd/app_bundle.py — platform-agnostic packaging helpers extracted from macos_app so the macOS and Linux bundles can't drift; macos_app re-exports them unchanged.
  • daemon/deckd/linux_app.py — XDG data dir/log, layouts.linux overlay, first-run seeding, build_argv, and --extract-integration (copy the AppDir's integration assets out).
  • .github/workflows/release-linux.yml — x86_64 + aarch64 matrix, --help + extraction smoke tests, attaches the AppImages and 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.

Decision

AppImage, not Flatpak/Snap: a sandbox cannot write the udev rule, see /dev/uinput, reach the session bus unfiltered, or install the compositor plugin. The root step is unavoidable on every channel, so it's an explicit sudo helper. Details and rejected options in ADR-0012.

Verification (NixOS 26.05 / GNOME 50 Wayland, x86_64)

  • just build-linux-appimage → dist/deckd-0.0.1-x86_64.AppImage (22.9 MB). Boots (--help), serves /health and the bundled client, seeds layouts into ~/.local/share/deckd/layouts, writes deckd.log.
  • Input injection end-to-end: WS key/jog produce real evdev events; typed deckd injected this through the packaged daemon into a focused Text Editor on the live session, sent ctrl+s, and the saved file contained exactly that text.
  • Live focus (v6 extension loaded at shell startup): daemon reports started_ok: true, focus changes select layouts (org.gnome.Console → layout=org.gnome.Console), RaiseApp/RaiseWindow work, scripts/smoke_focus_live.py passes.
  • Install helper, install→uninstall lifecycle sandboxed as namespace-root: udev rule + extension + icon + autostart installed; --uninstall removes only what it created (a pre-existing input membership/extension is preserved).
  • pytest 776 passed, pyright daemon clean, bash -n + flag checks, workflow YAML parses.

Fixes found during verification

  • 6f12aab — binfmt AppImage wrappers (NixOS programs.appimage) bypass --appimage-extract; the launcher now offers --extract-integration and the helper falls back to it. --uninstall is state-gated (/var/lib/deckd/system-integration.<user>.state) so it can't clobber a NixOS/home-manager install.
  • 40a8cd0 — ship the app icon: committed PNGs at the AppDir root (PNG .DirIcon), hicolor 192/512 copies, and an integration-tree copy the helper installs into the user's theme with Icon=deckd.

Not verified

  • Autostart-at-login with the real helper: needs a sudo run plus a login on a machine without an existing deckd service; the sandbox verified the entry it writes.
  • The release workflow hasn't run on GitHub yet; the first v* tag exercises it.

Follow-up

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.
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.<user>.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.
@jonocodes

Copy link
Copy Markdown
Owner Author

Linux verification (NixOS 26.05 / GNOME 50 Wayland, x86_64)

Worked through the "Not verified (needs a Linux box)" list.

Built: just build-linux-appimage → dist/deckd-0.0.1-x86_64.AppImage (22.9 MB). Local note: PyInstaller needs objdump on PATH (binutils); Ubuntu CI gets it from build-essential.

Booted the frozen payload (--help, then a real run):

  • /health ok, bundled client served (GET / → 200), layouts seeded once into ~/.local/share/deckd/layouts, deckd.log written.
  • Its uinput device is created; injected shift, ctrl+t, and a jog over the WS protocol and read the events back from the created /dev/input/eventN — key press/release and REL_WHEEL all land.

Install helper (sandboxed as namespace-root; privileged side effects redirected to temp paths):

  • install: udev rule + GNOME extension (v6) + autostart written; pre-existing input membership left alone.
  • --uninstall: removes exactly what it installed; the pre-existing group membership/extension are untouched.

Live desktop session (v6 extension loaded at shell startup):

  • Packaged daemon: focus.backend=GnomeShellFocusBackend, started_ok: true; opening Console produced focus -> AppInfo(app_id='org.gnome.Console', ...) (layout=org.gnome.Console).
  • RaiseApp("Paseo") (v6-only) → true; RaiseWindow(<id>) → true; bogus id declined.
  • scripts/smoke_focus_live.py against the live bus: PASS.
  • Real input injection: typed deckd injected this through the packaged daemon into a focused Text Editor, sent ctrl+s, and the saved file contained exactly that text.

Two fixes found by this run (6f12aab):

  1. binfmt AppImage wrappers break --appimage-extract. NixOS's programs.appimage (and any binfmt handler) runs the payload directly, so the embedded runtime never sees the flag and the helper found no assets. Added a --extract-integration DIR flag to the frozen launcher and a fallback in the helper; the release workflow now smoke-tests it. This also affects FUSE-less systems that rely on the runtime's extract-and-run fallback.
  2. --uninstall was unconditional — it removed the user's input membership and focus extension even when they pre-existed (NixOS/home-manager or a source install on the same box). Install now records what it changed in /var/lib/deckd/system-integration.<user>.state and uninstall only undoes that.

Not tested: autostart-at-login with the real helper. It needs a sudo run plus another login, and on this machine it would collide with an existing Nix deckd.service on :8765 — the sandbox run verified the entry it writes.

pytest 773 passed; pyright daemon clean; bash -n + flag checks on the helper.

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.
@jonocodes

Copy link
Copy Markdown
Owner Author

Icon: merged main (branding, 585eec3) and used the committed PNGs in the AppImage build instead of rendering the SVG at build time. The AppDir now carries deckd.png at the root (appimagetool makes a PNG .DirIcon), 192/512 copies under usr/share/icons/hicolor, and one in usr/share/deckd/integration. The install helper copies it to ~/.local/share/icons/hicolor/512x512/apps/deckd.png and sets Icon=deckd on the autostart entry (state-gated, so --uninstall removes it). Also dropped the now-unused librsvg2-bin CI dep and extended the extraction smoke test. Commit 40a8cd0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant