Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion daemon/deckd/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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()

Expand Down
9 changes: 6 additions & 3 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>-x86_64.AppImage
pkexec ./deckd-install-system-integration.sh ./deckd-<version>-x86_64.AppImage
# headless: sudo ./deckd-install-system-integration.sh ./deckd-<version>-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-<version>-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.<user>.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.

Expand Down
24 changes: 15 additions & 9 deletions docs/adr/0012-linux-distribution-appimage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
112 changes: 88 additions & 24 deletions packaging/linux/install-system-integration.sh
Original file line number Diff line number Diff line change
@@ -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-<version>-x86_64.AppImage
# sudo ./install-system-integration.sh ./deckd-<version>-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:
#
Expand All @@ -22,16 +35,21 @@
# bundled launcher's `--extract-integration`.
#
# Usage:
# sudo ./install-system-integration.sh ./deckd-<version>-x86_64.AppImage
# sudo ./install-system-integration.sh --assets /path/to/integration
# sudo ./install-system-integration.sh --uninstall
# pkexec ./install-system-integration.sh ./deckd-<version>-x86_64.AppImage
# sudo ./install-system-integration.sh ./deckd-<version>-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.<user>.state): the udev
# rule, the focus watcher, the app icon, the autostart
Expand All @@ -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"
Expand All @@ -63,6 +91,7 @@ ASSETS_DIR=""
TARGET_USER=""
DESKTOP="auto"
AUTOSTART=1
ADD_GROUP=0
UNINSTALL=0

while [ $# -gt 0 ]; do
Expand All @@ -72,19 +101,32 @@ 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" ;;
*) APPIMAGE="$1"; shift ;; # bare arg: the AppImage
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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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

Expand All @@ -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"
34 changes: 34 additions & 0 deletions tests/test_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"""
from __future__ import annotations

import logging

import pytest

from deckd.input import (
Expand Down Expand Up @@ -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
Loading