diff --git a/Justfile b/Justfile index 62735c5..1222bf8 100644 --- a/Justfile +++ b/Justfile @@ -7,10 +7,24 @@ default: # Per-platform setup recipes. `setup` auto-picks the right one; use the # explicit recipe when you want to override (e.g. cross-checking on a CI box). -# Linux/GNOME dev: [dev,uinput] (evdev-binary is Linux-only). +# Linux/GNOME/KDE dev: [dev,uinput] gives the evdev-backed uinput sink used +# for key injection (browser buttons etc.). On x86_64 that's the prebuilt +# evdev-binary wheel; on aarch64 (no evdev-binary wheel) we install [dev] +# then source-build python-evdev via scripts/install_evdev_source.sh, which +# needs a C compiler (the `gcc` flox package). If that build is skipped the +# uinput backend degrades gracefully (input.py imports evdev lazily, no-ops). setup-linux: + #!/usr/bin/env bash + set -euo pipefail uv venv --python 3.12 --allow-existing - uv pip install -e ".[dev,uinput]" + if [ "$(uname -m)" = x86_64 ]; then + uv pip install -e ".[dev,uinput]" + else + echo "note: $(uname -m) has no evdev-binary wheel; source-building python-evdev." >&2 + uv pip install -e ".[dev]" + PYTHON=".venv/bin/python" bash scripts/install_evdev_source.sh \ + || echo "warn: evdev source build failed; uinput key injection will no-op." >&2 + fi cd client && npm install # macOS dev: [dev] + [macos] (PyObjC Quartz covers scroll, pointer, click, @@ -110,7 +124,10 @@ install-focus-kwin: script_id="deckd-focus" script_path="$HOME/.local/share/kwin/scripts/${script_id}/contents/code/main.js" # 1. Install (or upgrade) the KWin script package into the user dir. - kpackagetool6 --type=KWin/Script -u "$pkg" + # -u fails on a first run ("Plugin deckd-focus is not installed"), + # so install first and fall back to upgrade when it already exists. + kpackagetool6 --type=KWin/Script -i "$pkg" 2>/dev/null \ + || kpackagetool6 --type=KWin/Script -u "$pkg" # 2. Persist enable across relogins (kwinrc [Plugins] deckd-focusEnabled=true). kwriteconfig6 --file kwinrc --group Plugins --key "${script_id}Enabled" true # 3. Apply kwinrc changes so a KWin restart picks the script up automatically. diff --git a/README.md b/README.md index 06ba19f..dab45a1 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ export PATH="$HOME/.local/bin:$PATH" # 2. Create the venv and install deps uv venv --python 3.12 -uv pip install -e ".[dev,uinput]" +uv pip install -e ".[dev,uinput]" # aarch64: source-builds python-evdev — see note below # 3. Install JS client deps (once) cd client && npm install && cd .. @@ -99,6 +99,8 @@ just run-daemon Open `http://127.0.0.1:8765` in any browser. You should see the active layout's buttons filling the main area, an always-on jogstrip pinned to the right edge, and a chrome bottom strip with the app name, a connection status dot, and a `trackpad` button. Drag or flick vertically on the right-side jogstrip to emit `REL_WHEEL_HI_RES` deltas through uinput (log-only when uinput is unavailable). Tap the `trackpad` button to swap the button grid for a full-area trackpad surface — see the [Trackpad mode](#trackpad-mode) section for the gesture list. +> **aarch64 Linux (e.g. Asahi):** `evdev-binary` publishes x86_64 wheels only, so it can't cover aarch64. Instead `just setup-linux` source-builds `python-evdev` via `scripts/install_evdev_source.sh` (the `uinput` extra also declares plain `evdev` on non-x86_64 via a `platform_machine` marker). The source build needs a C compiler and kernel headers — the flox dev env pins `gcc` for exactly this (Nix hides the headers from evdev's `build_ecodes`, so the script locates them via the compiler and passes them explicitly). With that plus `/dev/uinput` write access (see [uinput permissions](#uinput-permissions)), scroll/key/trackpad injection works natively on aarch64, **including KDE Plasma Wayland** — keys are injected at the kernel evdev layer, so the compositor routes them to the focused window. If the build is skipped the sink degrades gracefully to log-only. + ### macOS The daemon runs on macOS via `daemon/deckd/platform_macos.py` — focus + key injection work out of the box, and the jogstrip + trackpad need `pyobjc-framework-Quartz` (pulled in via the `[macos]` extra). The GNOME Shell focus extension is Linux-only. @@ -131,6 +133,49 @@ What works / doesn't on macOS: When the layout doesn't switch as expected, run `python scripts/check_focus_macos.py` for a one-shot diagnostic: it prints what `osascript` reports for the frontmost app, whether the auto-ignore rule would hold, and which layout `resolve_layout` would pick. Saves reading the daemon log for the common cases (TCC denied, stale daemon, wrong app_id). +### KDE Plasma Wayland + +Two KDE-specific pieces on top of the base setup: a **KWin script** for focus-based layout switching, and **`/dev/uinput` access** so button/scroll/trackpad injection actually reaches apps. This walkthrough is distro-neutral; Nix/flox users get the extra CLI tools automatically (see the [Tooling note](#kde-plasma-wayland-sessions) under the focus watcher) and can skip the package-install hints. + +**0. Check the KDE CLI tools are present.** These ship with a standard Plasma 6 desktop; run this to spot any gaps: + +```sh +for t in kpackagetool6 kwriteconfig6 qdbus gdbus; do command -v "$t" >/dev/null || echo "missing: $t"; done +``` + +If something is missing, install it from your distro: `kpackagetool6`/`kwriteconfig6` come with **KDE Frameworks 6** (KPackage / KConfig tools), `qdbus` with **Qt 6 tools**, and `gdbus` with **glib** (Debian/Ubuntu `libglib2.0-bin`, Fedora `glib2`, Arch `glib2`). The daemon shells out to `gdbus` on every focus poll, so it's not optional. + +**1. Install deps and build the client.** + +```sh +just setup # venv + Python/JS deps incl. the uinput sink (auto-picks setup-linux) +just build-client +``` + +On **x86_64** the `uinput` extra installs the prebuilt `evdev-binary` wheel. On **aarch64** (e.g. Asahi) there is no such wheel, so `just setup-linux` source-builds `python-evdev` instead — that needs a C compiler and kernel headers: + +```sh +# Debian/Ubuntu +sudo apt install build-essential linux-libc-dev +# Fedora +sudo dnf install gcc kernel-headers +# Arch +sudo pacman -S base-devel linux-api-headers +``` + +**2. Grant `/dev/uinput` write access.** Without it, keys/scroll/trackpad are silently no-ops (the daemon logs `platform sink unavailable` at startup). Follow [uinput permissions](#uinput-permissions) — the udev rule plus adding yourself to the `input` group, then log out and back in. `just check-uinput` confirms it. + +**3. Run the daemon**, then install the focus KWin script: + +```sh +just run-daemon # owns org.deckd.Focus; serves the client at :8765 +just install-focus-kwin # installs + enables + hot-starts the KWin focus script +``` + +Order matters slightly: the script pushes focus to the *running* daemon, so start the daemon first (or just re-run `install-focus-kwin` afterwards — see [Cold-start ordering](#kde-plasma-wayland-sessions)). Details and verification are in [KDE Plasma Wayland sessions](#kde-plasma-wayland-sessions) under the focus watcher. + +**4. Verify.** Open `http://127.0.0.1:8765`, focus different apps and watch the layout follow (`just watch-focus`), and press a browser button — it should fire the keystroke in the focused window. If buttons do nothing, it's almost always step 2 (`just check-uinput`). + ### Phone/tablet testing The phone must load the web client from a daemon address it can reach. Build the client, run the daemon on all interfaces, then open the desktop's LAN IP from the phone: @@ -243,7 +288,13 @@ sleep 2 && .venv/bin/python -u scripts/send_scroll.py --velocity 1200 ### uinput permissions -For real scroll injection, deckd needs write access to `/dev/uinput`. Current-session ACLs may work temporarily, but the reproducible setup is to install the udev rule and add the daemon user to `input`: +Any real injection — scroll, **keys (browser buttons etc.)**, or trackpad — needs write access to `/dev/uinput`; without it the daemon logs `platform sink unavailable` at startup and every press is a no-op logged as `[key log]`. For a quick one-session test you can grant it with an ACL (reverts on reboot): + +```sh +sudo setfacl -m u:"$USER":rw /dev/uinput +``` + +The reproducible setup is the udev rule plus membership in `input`: ```sh sudo install -m 0644 packaging/udev/70-deckd-uinput.rules /etc/udev/rules.d/ @@ -252,6 +303,8 @@ sudo udevadm trigger --subsystem-match=misc --sysname-match=uinput sudo usermod -aG input "$USER" ``` +On **NixOS**, don't do the above by hand — `packaging/nixos/deckd-spike.nix` already loads the `uinput` module, installs the same udev rule, creates the `input` group, and adds the daemon user to it (see the NixOS block below). Enable it (or lift those lines into your config) and relog. + Log out and back in, then check: ```sh @@ -347,15 +400,17 @@ On X11 there is no `app_id` analogue (no Wayland / Flatpak app id), so `app_id` KDE Plasma Wayland does not export the active window to outside clients over a documented D-Bus interface (the spike in `docs/spike-kde-wayland-focus.md` ruled out every Wayland-protocol and `org.kde.KWin` session-bus path). Instead deckd ships a tiny **KWin script** that runs inside the compositor and `callDBus`-pushes the focused window snapshot into the daemon's own `org.deckd.Focus` cache over the session bus. The wire shape on the consumer side is byte-identical to the GNOME extension, so the daemon's `KdeFocusBackend` reads from the same in-process cache the GNOME backend polls via `gdbus`. -Install + enable + hot-start in one go (Plasma 6, requires `kpackagetool6` / `kwriteconfig6` / `qdbus` on `$PATH` — stock Plasma dev installs): +Install + enable + hot-start in one go: ```sh just install-focus-kwin ``` +**Tooling.** The recipe needs `kpackagetool6`, `kwriteconfig6`, and `qdbus` on `$PATH`, and the daemon itself shells out to `gdbus` (from glib) on every focus poll. On a stock Plasma 6 install these all come with the desktop. The flox dev env additionally pins `glib` (→ `gdbus`) and `kdePackages.kconfig` (→ `kwriteconfig6` / `kreadconfig6`) so `flox activate` covers the two tools NixOS doesn't put in the system profile — see the comments in `.flox/env/manifest.toml`. (`kconfig` is pinned to 6.26.0 there because 6.27+ moves those binaries into a `devtools` output flox's catalog resolver can't select.) + That recipe: -1. Installs the KWin Script package into `~/.local/share/kwin/scripts/deckd-focus/` via `kpackagetool6 -u`. +1. Installs the KWin Script package into `~/.local/share/kwin/scripts/deckd-focus/` via `kpackagetool6 -i` (falling back to `-u` when a copy is already installed). 2. Persists `deckd-focusEnabled=true` in `kwinrc` so the script survives relogin. 3. `qdbus org.kde.KWin /KWin reconfigure` applies the enable flag without a relogin. 4. Hot-starts the script via `org.kde.kwin.Scripting.loadScript`, which fires the script's initial `push(workspace.activeWindow)` against the running daemon's `org.deckd.Focus` cache so the layout switches to the currently focused app immediately instead of waiting for the next alt-tab. @@ -374,7 +429,7 @@ app_id='org.kde.dolphin' wm_class='dolphin' pid=4242 title='Dolphin — Home' app_id=None wm_class='firefox' pid=188566 title='YouTube — Mozilla Firefox' ``` -If the KBin script isn't installed or the daemon couldn't own `org.deckd.Focus`, `watch-focus` and the daemon both print the `install-focus-kwin` hint and keep running on the default layout (the same graceful-failure stance the X11 backend takes when `xdotool` is missing). +If the KWin script isn't installed or the daemon couldn't own `org.deckd.Focus`, `watch-focus` and the daemon both print the `install-focus-kwin` hint and keep running on the default layout (the same graceful-failure stance the X11 backend takes when `xdotool` is missing). **Lifecycle note.** The daemon owns `org.deckd.Focus` only on KDE Plasma Wayland sessions (`XDG_CURRENT_DESKTOP=KDE` + `XDG_SESSION_TYPE=wayland`), so the GNOME extension and the KDE daemon-side cache never fight over the same bus name. KDE-X11 falls back to the `xdotool` path documented above. diff --git a/client/vite.config.ts b/client/vite.config.ts index 46e5bf1..4fcf8a0 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ server: { port: 5173, // Match any tailnet hostname so `..ts.net` isn't rejected. - allowedHosts: ["lute", "jonodot-1", ".ts.net"], + allowedHosts: true, // ["lute", "*", ".ts.net"], ...(https ? { https } : {}), // Same-origin proxy so the client never has to know the daemon's URL: // /ws, /health, /reload all appear at the Vite origin and are forwarded diff --git a/pyproject.toml b/pyproject.toml index 059a6b3..430d685 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,13 @@ dependencies = [ [project.optional-dependencies] uinput = [ - "evdev-binary>=1.9", + # x86_64 gets the prebuilt evdev-binary wheel. Other arches (notably + # aarch64) have no evdev-binary wheel, so fall back to the sdist `evdev`, + # which builds from source -- needs a C compiler + kernel headers. On a + # stock distro `pip install .[uinput]` handles that; on Nix/flox use + # scripts/install_evdev_source.sh (it locates and passes the header paths). + "evdev-binary>=1.9; platform_machine == 'x86_64'", + "evdev>=1.9; platform_machine != 'x86_64'", ] dev = [ "websockets>=13", diff --git a/scripts/install_evdev_source.sh b/scripts/install_evdev_source.sh new file mode 100755 index 0000000..dfda28a --- /dev/null +++ b/scripts/install_evdev_source.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Build + install python-evdev from source, for platforms that have no +# prebuilt wheel — notably aarch64/arm64, where `evdev-binary` ships x86_64 +# wheels only and the plain `evdev` package is sdist-only (needs a compiler). +# +# On a stock Linux distro `pip install evdev` "just works" because the kernel +# headers live in /usr/include and evdev's `build_ecodes` step finds them +# there. Under Nix/flox there is no /usr/include, so build_ecodes (which opens +# linux/input.h, input-event-codes.h and uinput.h *by path*) can't find them. +# We therefore locate the header dir via the C compiler's own include search +# and pass it explicitly with `--evdev-headers`. +# +# Requires a C toolchain (cc/gcc) on PATH. flox users get it from the `gcc` +# package (see .flox/env/manifest.toml). No-op if evdev already imports. +# +# Usage: PYTHON=/path/to/venv/python scripts/install_evdev_source.sh +# (PYTHON defaults to `python` on PATH.) +set -euo pipefail + +PY="${PYTHON:-python}" + +if "$PY" -c 'import evdev' 2>/dev/null; then + echo "evdev already importable under $PY; nothing to do." + exit 0 +fi + +# Fast path: on distros with headers in /usr/include this builds cleanly. +if "$PY" -m pip install -q 'evdev>=1.9' 2>/dev/null && "$PY" -c 'import evdev' 2>/dev/null; then + echo "evdev installed from source via pip (standard header layout)." + exit 0 +fi + +# Slow path (Nix/flox): find the include dir that actually holds the kernel +# headers and hand it to build_ecodes explicitly. +if ! command -v cc >/dev/null 2>&1; then + echo "error: no C compiler (cc) on PATH; cannot build evdev from source." >&2 + echo " flox: 'flox install gcc' distro: install gcc + kernel headers." >&2 + exit 1 +fi + +hdr="" +for d in $(echo | cc -xc -E -v - 2>&1 \ + | sed -n '/#include <...> search starts/,/End of search/p' | grep '^ '); do + if [ -f "$d/linux/uinput.h" ]; then hdr="$d"; break; fi +done +if [ -z "$hdr" ]; then + echo "error: could not locate linux/uinput.h in the compiler include path." >&2 + exit 1 +fi +echo "building evdev against kernel headers in: $hdr" + +"$PY" -m pip install -q setuptools wheel + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +"$PY" -m pip download -q --no-deps --no-binary :all: 'evdev>=1.9' -d "$work" +tar xzf "$work"/evdev-*.tar.gz -C "$work" +src=$(echo "$work"/evdev-*/) +( + cd "$src" + "$PY" setup.py -q \ + build_ecodes --evdev-headers "$hdr/linux/input.h:$hdr/linux/input-event-codes.h:$hdr/linux/uinput.h" \ + build_ext --include-dirs "$hdr" \ + bdist_wheel + "$PY" -m pip install -q --force-reinstall --no-deps dist/evdev-*.whl +) +"$PY" -c 'import evdev; from evdev import UInput; print("evdev built + installed OK")'