diff --git a/daemon/deckd/__main__.py b/daemon/deckd/__main__.py index ec70c92..2801134 100644 --- a/daemon/deckd/__main__.py +++ b/daemon/deckd/__main__.py @@ -81,6 +81,23 @@ def _overlay_dir_for(layouts_dir: Path) -> Path: return layouts_dir.parent / f"{layouts_dir.name}.{suffix}" +def _sink_failure_hint(exc: BaseException) -> str: + """One-line, copy-pasteable hint for the common Linux uinput failures. + + The packaged AppImage's user has no checkout to read, so the permission + hint names the helper script they were given. Empty for anything else — + the bare exception is enough there. + """ + if isinstance(exc, PermissionError): + return ( + " — /dev/uinput is not accessible: install the udev rule or run " + "the Linux AppImage's install-system-integration.sh" + ) + if isinstance(exc, FileNotFoundError): + return " — /dev/uinput is missing: load the uinput kernel module" + return "" + + def _build_sinks() -> tuple[object | None, ScrollSink, KeySink]: """Pick the (device, scroll, key) sinks for this process. @@ -106,8 +123,11 @@ def _build_sinks() -> tuple[object | None, ScrollSink, KeySink]: sink = UinputSink() return sink, sink, sink except Exception as exc: + hint = _sink_failure_hint(exc) if sys.platform != "darwin" else "" logging.getLogger("deckd").warning( - "platform sink unavailable; falling back to logging only: %s", exc + "platform sink unavailable; falling back to logging only: %s%s", + exc, + hint, ) return None, LoggingScrollSink(), LoggingKeySink() diff --git a/docs/GUIDE.md b/docs/GUIDE.md index abb5cbe..06b59d1 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -683,14 +683,17 @@ If `libfuse2` is missing (Ubuntu 22.04+/Debian 12 no longer ship it), run with ` #### 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: +Two things the AppImage can't do by itself. `/dev/uinput` needs a udev rule (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, pointing at the AppImage: `pkexec` on a desktop gives the native password dialog, `sudo` is the headless/SSH fallback. ```sh chmod +x deckd-install-system-integration.sh -sudo ./deckd-install-system-integration.sh ./deckd--x86_64.AppImage +pkexec ./deckd-install-system-integration.sh ./deckd--x86_64.AppImage +# headless: 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`), 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). +> **NixOS:** `pkexec` resets `PATH` to a distro default that has no system profile, so use `sudo`, or name the interpreter explicitly: `pkexec /run/current-system/sw/bin/bash ./deckd-install-system-integration.sh ./deckd--x86_64.AppImage` (the helper appends the profile to `PATH` for its own tools). Better yet, use the flake — see the [NixOS note](#nix-flake-nixos-and-home-manager) below. + +It installs the udev rule, the GNOME Shell extension or KWin script for the detected desktop (override with `--desktop gnome|kde`), the deckd mark into `~/.local/share/icons/hicolor` (`Icon=deckd` on the autostart entry resolves to it), and `~/.config/autostart/deckd.desktop` so deckd starts with your session. The rule carries `TAG+="uaccess"`, so the active session user gets `/dev/uinput` access — **no relogin needed**. For a linger/headless setup (a service that runs with no active session), pass `--add-group` to also add the user to `input`; that one does need a logout. 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/docs/adr/0012-linux-distribution-appimage.md b/docs/adr/0012-linux-distribution-appimage.md index 5095bd3..2a13a94 100644 --- a/docs/adr/0012-linux-distribution-appimage.md +++ b/docs/adr/0012-linux-distribution-appimage.md @@ -12,8 +12,9 @@ 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. + `/etc/udev/rules.d` (a **root** action). The rule's `TAG+="uaccess"` + covers the active session user; `input`-group membership is the optional + fallback for linger/headless setups. 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 @@ -74,12 +75,15 @@ 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: +`install-system-integration.sh` helper that is idempotent and has a matching +`--uninstall`. It is elevated once — `pkexec` on a desktop (native password +dialog) or `sudo` headless — and 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. +- the current user into the `input` group only with `--add-group`: the rule's + uaccess ACL already covers the active session, so the group is for + linger/headless setups. It also installs the user-level pieces — the focus watcher and an XDG autostart entry — so a single "install" flow covers everything the AppImage @@ -111,9 +115,11 @@ The channel is a thin skin over that tree. AppImage is simply the first skin. - **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. +- **Helper UX**: a shell script elevated once — transparent, + headless-friendly, no *hard* PolicyKit dependency. `sudo` remains the + mechanism; `pkexec` is the documented desktop front-end for the native + password dialog (#173). uaccess first, `--add-group` opt-in. 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. @@ -127,7 +133,7 @@ The channel is a thin skin over that tree. AppImage is simply the first skin. 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. + manager; users on AppImage grant it once with a `sudo`/`pkexec` 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 diff --git a/packaging/linux/install-system-integration.sh b/packaging/linux/install-system-integration.sh index 2c30189..68d0f68 100755 --- a/packaging/linux/install-system-integration.sh +++ b/packaging/linux/install-system-integration.sh @@ -1,11 +1,24 @@ #!/usr/bin/env bash -# deckd Linux system integration (issue #168). +# deckd Linux system integration (issues #168, #173). # -# 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. +# The AppImage cannot write /etc/udev/rules.d — that is a root action on every +# channel. This script does the root step (the udev rule) *and* the user-level +# pieces the AppImage can't do by itself (the desktop focus watcher, the app +# icon, and an XDG autostart entry), so one elevated run finishes the install. +# +# On a desktop, run it with `pkexec` for the native password dialog; on a +# headless/SSH box, use `sudo`: +# +# pkexec ./install-system-integration.sh ./deckd--x86_64.AppImage +# sudo ./install-system-integration.sh ./deckd--x86_64.AppImage +# +# On NixOS, pkexec's sanitised PATH has no system profile: use `sudo`, or +# `pkexec /run/current-system/sw/bin/bash ./install-system-integration.sh ...`. +# +# The udev rule carries TAG+="uaccess", so the *active* session user gets an +# ACL on /dev/uinput and the autostart case needs no relogin. `--add-group` +# additionally adds the user to the `input` group for linger/headless setups +# (that one does need a logout). # # It reads its assets from an *extracted* AppImage layout: # @@ -22,16 +35,21 @@ # bundled launcher's `--extract-integration`. # # 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 +# pkexec ./install-system-integration.sh ./deckd--x86_64.AppImage +# 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) +# --user NAME Target user (default: $SUDO_USER / $PKEXEC_UID, 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 +# --add-group Also add the user to the `input` group (for linger / +# headless setups; needs a logout). Default: rely on the +# rule's uaccess ACL for the active session. # --uninstall Remove what this helper installed (recorded in # /var/lib/deckd/system-integration..state): the udev # rule, the focus watcher, the app icon, the autostart @@ -44,6 +62,16 @@ set -euo pipefail +# pkexec resets PATH to the distro default; NixOS keeps its tools in the +# system profile instead. Append it when present (a no-op elsewhere, and it +# never shadows the caller's PATH) so an explicitly-interpreted +# `pkexec /run/current-system/sw/bin/bash helper ...` still finds udevadm, +# usermod, grep, ... +if [ -d /run/current-system/sw/bin ]; then + PATH="$PATH:/run/current-system/sw/bin" + export PATH +fi + UDEV_DEST="/etc/udev/rules.d/70-deckd-uinput.rules" STATE_DIR="/var/lib/deckd" GNOME_UUID="deckd-focus@local" @@ -63,6 +91,7 @@ ASSETS_DIR="" TARGET_USER="" DESKTOP="auto" AUTOSTART=1 +ADD_GROUP=0 UNINSTALL=0 while [ $# -gt 0 ]; do @@ -72,6 +101,7 @@ while [ $# -gt 0 ]; do --user) TARGET_USER="${2:?--user needs a name}"; shift 2 ;; --desktop) DESKTOP="${2:?--desktop needs a mode}"; shift 2 ;; --no-autostart) AUTOSTART=0; shift ;; + --add-group) ADD_GROUP=1; shift ;; --uninstall) UNINSTALL=1; shift ;; -h|--help) usage 0 ;; -*) die "unknown option: $1" ;; @@ -79,12 +109,24 @@ while [ $# -gt 0 ]; do esac done -[ "$(id -u)" -eq 0 ] || die "must run as root: sudo $0 ..." +[ "$(id -u)" -eq 0 ] || die "must run as root: sudo $0 ... (or pkexec $0 ...)" -# The user the desktop belongs to. Under sudo that's SUDO_USER; otherwise fall -# back to the owner of the invoking terminal. +# How we were elevated, for copy-pasteable follow-up commands. pkexec sets +# PKEXEC_UID; sudo sets SUDO_USER. Neither means a plain root shell. +if [ -n "${PKEXEC_UID:-}" ]; then + ELEVATE="pkexec" +else + ELEVATE="sudo" +fi + +# The user the desktop belongs to. pkexec reports their uid in PKEXEC_UID; +# sudo in 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:-}")}" + if [ -n "${PKEXEC_UID:-}" ]; then + TARGET_USER="$(getent passwd "$PKEXEC_UID" | cut -d: -f1)" + else + TARGET_USER="${SUDO_USER:-$(logname 2>/dev/null || echo "${USER:-}")}" + fi fi [ -n "$TARGET_USER" ] && [ "$TARGET_USER" != "root" ] \ || die "could not determine the target user; pass --user NAME" @@ -102,7 +144,7 @@ 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" -- "$@" + runuser -u "$TARGET_USER" -- env HOME="$HOME_DIR" "$@" else return 127 fi @@ -156,7 +198,14 @@ EOF # --- assets ---------------------------------------------------------------- extract_dir="" -cleanup() { [ -n "$extract_dir" ] && rm -rf "$extract_dir"; } +cleanup() { + # Must return 0: a failing EXIT trap under `set -e` makes the whole + # helper exit non-zero even after a successful install (--assets leaves + # extract_dir empty). + if [ -n "$extract_dir" ]; then + rm -rf "$extract_dir" + fi +} trap cleanup EXIT # Set ASSETS_DIR in the current shell (not a command substitution) so the @@ -199,14 +248,23 @@ install_udev() { 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" - [ "$S_GROUP" = 1 ] || S_GROUP=0 # pre-existing; uninstall must leave it - else - note "+ adding $TARGET_USER to the input group" + + local in_group=0 + id -nG "$TARGET_USER" | tr ' ' '\n' | grep -qx input && in_group=1 + + if [ "$ADD_GROUP" -eq 1 ] && [ "$in_group" -eq 0 ]; then + note "+ adding $TARGET_USER to the input group (--add-group)" usermod -aG input "$TARGET_USER" S_GROUP=1 note "! log out and back in for the group change to apply" + elif [ "$in_group" -eq 1 ]; then + note "= $TARGET_USER is already in the input group" + # Keep a previous run's record so --uninstall still removes it. + [ "$S_GROUP" = 1 ] || S_GROUP=0 + else + note "= /dev/uinput access comes from the rule's uaccess ACL for the" + note " active session — no relogin needed. --add-group covers" + note " linger/headless setups (that one needs a logout)." fi } @@ -352,7 +410,11 @@ if [ "$UNINSTALL" -eq 1 ]; then [ "$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." + if [ "$S_GROUP" = 1 ]; then + echo "Done. Log out and back in to apply the group change." + else + echo "Done." + fi exit 0 fi @@ -369,5 +431,7 @@ write_state echo echo "Done." echo " - Run the AppImage (or log out/in for the autostart entry)." -echo " - Log out and back in so the input-group change takes effect." -echo " - Uninstall: sudo $0 --uninstall" +if [ "$S_GROUP" = 1 ]; then + echo " - Log out and back in so the input-group change takes effect." +fi +echo " - Uninstall: $ELEVATE $0 --uninstall" diff --git a/tests/test_input.py b/tests/test_input.py index 91f61f1..b7c153f 100644 --- a/tests/test_input.py +++ b/tests/test_input.py @@ -8,6 +8,8 @@ """ from __future__ import annotations +import logging + import pytest from deckd.input import ( @@ -159,3 +161,35 @@ def boom() -> None: assert sink is None assert isinstance(scroll_sink, LoggingScrollSink) assert isinstance(key_sink, LoggingKeySink) + + +def test_sink_failure_hint_names_the_fix_for_uinput_access() -> None: + """#173: the packaged user has no checkout to read, so the warning says + what to do about a locked-down /dev/uinput.""" + import deckd.__main__ as main_mod + + hint = main_mod._sink_failure_hint(PermissionError(13, "Permission denied")) + assert "install-system-integration" in hint + + hint = main_mod._sink_failure_hint(FileNotFoundError(2, "No such file")) + assert "kernel module" in hint + + # Anything else: the bare exception is enough. + assert main_mod._sink_failure_hint(RuntimeError("no uinput here")) == "" + + +def test_build_sinks_warning_carries_the_hint( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + import deckd.__main__ as main_mod + + monkeypatch.delenv("DECKD_FAKE_INPUT", raising=False) + monkeypatch.setattr(main_mod.sys, "platform", "linux") + + def boom() -> None: + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(main_mod, "UinputSink", boom) + with caplog.at_level(logging.WARNING, logger="deckd"): + main_mod._build_sinks() + assert "install-system-integration" in caplog.text