App-aware touch control surface for your desktop. A Stream Deck-like deck of buttons, sliders, scroll strips, and a manual control mode (a single combined trackpad + keyboard passthrough), rendered in any browser on any touchscreen device, driven by a local daemon that watches the focused application and swaps layouts automatically.
- Control your desktop from your phone, or tablet, or laptop
- Control multiple computers from one surface
- Control slides/presentations
- Get custom controls for each app you are using — including websites: play/pause and skip on YouTube or Netflix, or turn a site into an on-screen piano (see Web-app layouts; title-based, so best-effort today)
- Automatically switch display depending on which app is active
- Expose hotkeys for launching apps, or keyboard shortcuts
- Use phone as a mouse, scrollbar, and keyboard controller
- Voice typing from your phone to desktop
Pre-alpha, but usable day-to-day. Here's what deckd can do today and what's still planned.
Working today
- Automatic per-app layouts — focus a window on the desktop and the phone's browser flips to that app's buttons automatically.
- Web-app layouts — treat a website as an app. A layout can claim a site by matching the browser's window title (e.g. YouTube and Netflix media controls), driving each site's own keyboard shortcuts. See Web-app layouts.
- Buttons that fire keystrokes, shell commands, launch a terminal, or call D-Bus methods.
- Macros — chain multiple actions in a single button press, with delays and optional continue-on-error.
- Button styling — bundled icons (Lucide glyphs + Simple Icons brand logos) and per-button background colours, set in YAML.
- Scroll strip — an always-on right-side jogstrip to scroll the focused window, with release momentum.
- Manual control mode — the phone becomes a trackpad (move, tap, right-click, drag-lock) and a keyboard, so you can type into and point at the focused app for the things layouts don't cover (URL bars, chat boxes, ad-hoc commands).
- App badge — the focused app's name, icon, and accent color show in the bottom bar so you can tell at a glance what you're controlling.
- Chrome media indicator — the media icon sprouts a pulsing green dot whenever an MPRIS player is
Playing(passive playback indicator), independent of the browser view. - MPRIS media browser — browse and control any MPRIS-compatible player (Spotify, Firefox, VLC, etc.) from a dedicated chrome view, with album art, per-row transport controls, and now-playing metadata.
- Running programs list — tap a layout-grid icon in the bottom chrome to open a list of every open window on the host, labeled by the layout the window would match (display-only in v1; raise-on-tap is stage 3).
- VLC media widgets — full VLC control surface with play/pause, seek, volume, album art. Configurable art sources (VLC embedded art + iTunes fallback).
- Live sensor widgets — meter and stats widgets pushed to the client in real time (CPU %, memory %, etc.), bound to daemon-side sensor sources.
- GUI layout editor — build and edit layouts from the browser without hand-editing YAML: a widget palette, a drag-to-reorder reflow canvas with span and overflow controls, a properties panel (labels, icons via a searchable picker, colours, actions and macros), and new-layout creation — saved back to disk over the write API. In development, but usable today. See Layout editor.
- Live layout editing — edit a layout file on the desktop (by hand or via the GUI editor) and every connected phone/tablet re-renders instantly; a bad edit shows an error in place instead of crashing.
- Per-device tuning — a settings panel for scroll speed/direction, trackpad sensitivity, content and text size, bar sizes, and keep-screen-awake, all saved on the device.
- Keep screen awake while the surface is in use.
- Install to home screen (PWA) for a fullscreen, app-like surface.
- Password auth — every client authenticates with a shared password (on by default;
--no-authdisables it for local development). See Client auth. - LAN scope control — bind the daemon to a specific network interface (
--bind iface:wlan0) or literal address; defaults to localhost-only for safety. - Accessibility — keyboard navigation, visible focus ring, screen-reader landmarks and live announcements, larger controls, high contrast, and reduced motion.
- Runs on Linux: GNOME (Wayland), KDE Plasma (Wayland), X11
- Runs on MacOS (barely tested)
Planned
- Screensaver & suspend sync — dim/lock the surface when the desktop sleeps.
- One-step NixOS install — a production module instead of the current spike.
- Multiple simultaneous clients with per-device layouts and resolutions.
- Soundboard — trigger sound clips from the deck.
- 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).
- Windows support
- Packing and deployment
┌──────────┐
│ Phone / │
│ Tablet │
│ Browser │
└────┬─────┘
│
WebSocket (ws://)
│
┌──────────────────┼───────────────────┐
│ deckd daemon │
│ (aiohttp, asyncio) │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Layout │ │ Action │ │
│ │ Loader │ │ Dispatch │ │
│ └──────────┘ └────┬─────┘ │
│ │ │ │
└─────────┼───────────┼────────────────┘
│ │
layouts/*.yaml ┌─────┴─────┬──────────┐
│ │ │
uinput shell D-Bus
(evdev) (subprocess) gdbus
│ │
scroll + keys + pointer ┌───────┴───────┐
│ │ │
/dev/uinput GNOME Shell (xdotool —
Extension any X11 DE)
deckd-focus
@local
┌──────────────────┐
│ Focus watchers │
└──────────────────┘
GNOME Shell ext. (Wayland, GNOME)
KWin script (Wayland, KDE Plasma)
xdotool (any X11 session)
osascript (macOS)
daemon/deckd/ Python daemon: aiohttp server, WebSocket, layout loader, action dispatch
client/ Vite + React + TS web client (the dumb renderer)
layouts/ Per-app YAML layouts (default.yaml + one per app)
scripts/smoke.py End-to-end test that boots the daemon over WS, clicks every button
docs/INCEPTION.md Full design doc — source of truth for *what* and *why*
A layout normally claims a desktop app by putting its app_id/wm_class in the
match: list. A layout can also claim a website with a title: token — a
case-insensitive glob matched against the focused browser's window title:
match:
- "title:*- YouTube*" # any tab whose title ends in "- YouTube"When the focused app is a browser, a title: match outranks a generic
browser layout (so youtube.yaml wins over firefox.yaml), and falls back to
the browser layout when no site matches. The buttons are ordinary actions —
the shipped layouts/youtube.yaml and layouts/netflix.yaml drive each site's
own keyboard shortcuts (key: k, key: s, …), so no special capability is
needed beyond a layout file.
The same idea also turns a site into an on-screen musical keyboard:
layouts/musicca.yaml matches title:*Musicca* and fires the letter/number
keys that Musicca's keyboard instruments listen
for (top letter row = white keys, number row = black keys). Because Musicca's
piano and synthesizer share one mapping, a single layout plays both — and any
other site using the same mapping, once you add its title to match:. It's just
keystrokes (no MIDI/velocity). See #96
for range/sustain/UI follow-ups.
This is a heuristic. Desktop focus backends can only see the browser's window title, never the active tab's URL, so:
- a site is only matchable if its name appears in the
<title>; - sub-pages of the same site can't be told apart (both share a title suffix);
- the match breaks silently if a site changes how it formats its title.
Reliable URL/domain matching is planned via a browser extension (#90).
You need Python 3.11+ and Node 18+. Dependencies are managed with [uv](https://docs.astral.sh/uv/):
# 1. Install uv (one-time)
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
# 2. Create the venv and install deps
uv venv --python 3.12
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 ..
# 4. Build the client so the daemon can serve it
just build-client
# 5. Run the daemon (serves the built client at http://127.0.0.1:8765)
just run-daemonOpen 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, a manual control button, and a settings 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 manual control button to swap the button grid for a combined trackpad + IME surface — see the Manual control mode section.
aarch64 Linux (e.g. Asahi):
evdev-binarypublishes x86_64 wheels only, so it can't cover aarch64. Insteadjust setup-linuxsource-buildspython-evdevviascripts/install_evdev_source.sh(theuinputextra also declares plainevdevon non-x86_64 via aplatform_machinemarker). The source build needs a C compiler and kernel headers — the flox dev env pinsgccfor exactly this (Nix hides the headers from evdev'sbuild_ecodes, so the script locates them via the compiler and passes them explicitly). With that plus/dev/uinputwrite access (see 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.
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.
Setup (no uv/uinput bits):
just setup # auto-picks setup-macos on Mac, setup-linux elsewhere
just build-client
just dev-daemon # listens on http://127.0.0.1:8765, auto-restarts on Python editsjust dev-daemon wraps the daemon in the deckd-dev supervisor so Python edits hot-reload (YAML hot-reloads either way). For a one-shot deckd invocation use just run-daemon.
To force a specific platform's setup (e.g. on a CI box): just setup-linux or just setup-macos.
First time you focus a non-default window, System Events will pop a TCC prompt asking you to allow the controlling terminal/iTerm/whatever wraps Python. Accept it once and the focus watcher runs forever after. The focus backend uses the process name as app_id, so layouts match by process name — firefox, Terminal, kitty, code etc. work as-is. The GNOME-specific per-app YAMLs (org.gnome.Console, foot, konsole…) won't match on macOS unless you rename them to the Mac process name.
What works / doesn't on macOS:
| capability | macOS |
|---|---|
| focus detection | yes (osascript + System Events) |
key: action (printable + combos) |
yes (osascript keystroke) |
key: action (non-printable) |
partial (HID-code map covers the common ones — arrows, esc, tab, enter, F-keys) |
shell: / terminal: actions |
yes |
dbus: action |
no (macOS D-Bus exists but GNOME services don't) |
| trackpad pointer + clicks + drag | yes (PyObjC Quartz CGEventCreateMouseEvent) |
| jogstrip scroll | yes (PyObjC Quartz CGEventCreateScrollWheelEvent — pulled in via the [macos] extra) |
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).
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 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:
for t in kpackagetool6 kwriteconfig6 qdbus gdbus; do command -v "$t" >/dev/null || echo "missing: $t"; doneIf 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.
just setup # venv + Python/JS deps incl. the uinput sink (auto-picks setup-linux)
just build-clientOn 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:
# 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-headers2. 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 — 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:
just run-daemon # owns org.deckd.Focus; serves the client at :8765
just install-focus-kwin # installs + enables + hot-starts the KWin focus scriptOrder 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). Details and verification are in 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).
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:
just build-client
just run-daemon-lan
# In another terminal, find the desktop IP:
hostname -IOpen http://<desktop-lan-ip>:8765 on the phone, for example http://192.168.30.117:8765. The client connects its WebSocket back to the same host automatically, so no separate VITE_DECKD_WS setting is needed for this built-client path.
The client can be viewed and design-iterated without a running daemon:
- Demo mode — append
?demo=<name>to the client URL (firefox,default, orshowcase) to render a fixture layout with the WebSocket disabled. Theshowcasefixture exercises every icon path (Lucide glyphs, Simple Icons brand logos, per-button colour, a no-icon button, and the unknown-icon placeholder). Dev-only; adds no cost when the param is absent. (For forcing a real daemon layout with a live backend, use the per-client?layout=<name>pin — see Layout override.) - Responsive gallery —
cd client && npm run dev, then open/gallery.html. Renders the real client in phone / large-phone / 7" / 10"-tablet iframes at once, with layout, orientation, and key hints selectors — for checking how a layout reads across screen sizes (the key-hints toggle drives each frame's?showKeyHints=1). Dev-only entry, not in the production build. - Ladle (component workbench) —
cd client && npm run ladle. BrowseButtonGrid/Icon/JogStripstories in isolation with width/theme controls, plusSurface → Device sizesstories that render the grid in fixed phone/tablet frames (size + orientation) for a quick per-component resolution check. Stories live insrc/*.stories.tsx(Storybook-compatible CSF). - Lint —
cd client && npm run lint(ESLint flat config;npm run buildstill runstsc --noEmit).
Every push to main builds the client and Ladle and publishes them to GitHub Pages, so the previews above can be browsed with no daemon and no local checkout:
- Live client (demo mode) —
https://jonocodes.github.io/deckd/?demo=showcase(also?demo=firefox,?demo=default). Without?demo=the client loads and shows "disconnected" since there's no daemon behind the Pages site; the param lets the fixture layout run. - Responsive gallery —
https://jonocodes.github.io/deckd/gallery.html. - Ladle stories —
https://jonocodes.github.io/deckd/ladle/.
Source: .github/workflows/deploy-pages.yml. The Vite build uses VITE_BASE_PATH=/deckd/ and the Ladle build uses --base /deckd/ladle/ so the Project-Pages sub-path resolves; local dev keeps base /. Reproduce the deploy bundle locally with just build-pages (output: client/dist/) and serve it with npx serve client/dist.
"Add to Home Screen" on Android Chrome / Edge only prompts over a secure context (localhost or HTTPS). Plain http://<lan-ip>:8765 from a phone doesn't qualify, so the install banner never shows. iOS Safari is looser and accepts HTTP LAN, so it's Chrome/Edge that need help.
If both devices are on a Tailscale tailnet, deckd has a just dev-client-tailscale recipe that:
- Uses
tailscale certto provision (or reuse) a Let's-Encrypt cert for your tailnet hostname. - Runs Vite's dev server on
:5173with HTTPS backed by that cert. - Configures Vite to proxy
/wsand/healthto the local daemon on:8765, so the whole app is same-origin — no extraVITE_DECKD_WSconfig, HMR still works, PWA install still eligible.
# One-time: enable HTTPS certs for your tailnet
# https://login.tailscale.com/admin/dns → "Enable HTTPS"
# Terminal 1 — daemon on localhost (the recipe expects it at 127.0.0.1:8765):
just dev-daemon
# Terminal 2 — Vite HTTPS backed by tailscale cert:
just dev-client-tailscaleOn first run the recipe writes <host>.crt / <host>.key under client/.tls/ (gitignored). Then it prints the URL. Open https://<hostname>.<tailnet>.ts.net:5173/ on the phone — Chrome's install banner should appear, tap "Add to Home Screen", and deckd installs fullscreen.
Cert files last ~3 months (Let's Encrypt); delete client/.tls/* and rerun the recipe to renew.
What Tailscale is (and isn't) doing. This flow uses Tailscale for three lightweight things, and nothing else:
- DNS.
<host>.<tailnet>.ts.netresolves to the desktop's tailnet IP. - Routing. The phone (on the tailnet) can reach that IP.
- The cert.
tailscale certmints a Let's Encrypt cert for the hostname and drops the.crt/.keyfiles where Vite reads them.
Tailscale is not running an application proxy — tailscale serve status will show nothing when this is set up, and that's correct. Vite binds 0.0.0.0:5173 directly with HTTPS, terminates TLS with the cert itself, and proxies /ws internally to the daemon at 127.0.0.1:8765. The phone's connection lands on Vite; the daemon only ever sees localhost traffic.
That's why the URL you see in devtools is wss://<host>.<tailnet>.ts.net:5173/ws (Vite's port), not :8765 (daemon's port).
Troubleshooting — page loads but wss://…:5173/ws won't connect and no password prompt appears. This is almost always the daemon being down, not a cert problem: the page loads over HTTPS (so the cert is fine, and wss:// reuses it), but Vite's /ws proxy has no upstream to forward to, so the socket is dropped mid-handshake. Because the auth exchange never completes, the client never receives the unauthorized frame and the password gate never renders — it just loops on reconnect. Check curl -s http://127.0.0.1:8765/health; if it fails, (re)start the daemon. Note the password lives in per-origin localStorage, so entering it once at localhost:5173 does not carry over to the <host>.ts.net:5173 origin — enter it again there. If the daemon itself won't start, it now fails fast with cannot bind 127.0.0.1:8765 — another process is already listening there (usually a stale deckd); clear it with pkill -f bin/deckd and relaunch.
Contrast: tailscale serve (persistent URL, no dev server). If you want an installable PWA at https://<host>.<tailnet>.ts.net/ (no port, works without any process running on the desktop besides the daemon), that's a different setup — just build-client + just run-daemon + tailscale serve --bg 8765. Tailscale proxies :443 → 127.0.0.1:8765, the daemon serves the built client/dist/. You lose HMR but gain a URL that survives closing your dev terminals. Not covered by any just recipe yet — file an issue if you want one.
Every layout renders inside a persistent chrome shell that the daemon does not know about:
- Bottom strip (always visible): the current app badge (from
LayoutMessage.app— optionally a branded icon +display_name+themecolour from the layout's YAML, see Chrome app badge), a connection dot (live / reconnecting / disconnected), amanual controlbutton that swaps the main area for the combined trackpad + IME surface (see Manual control mode), amedia browserbutton (when enabled — see MPRIS media browser; ADR-0008 records the chrome-view carve-out that lets the client pin a specific layout) that asks the daemon for the global MPRIS browser view, and asettingsbutton (see Client tuning). - Right-side jogstrip (always visible): a full-height scroll strip that works the same as the in-grid
jogstripwidget. A layout can suppress it withjogstrip: falseat the YAML top level — the daemon forwards this asjogstrip_enabledon everyLayoutMessage.
Widgets in a layout's widgets: list are an ordered list that reflows against the viewport width (ADR-0010). There are no grid coordinates. The client packs widgets left-to-right and wraps down, computing the column count from the available width against a client-side cell-size band. A widget may carry a size: [w, h] span (default [1, 1]) for non-uniform cells; the list order is the only positional input. Portrait just fits fewer columns — no transpose, no orientation conventions.
Tap the manual control button in the bottom chrome and the layout area is replaced by a single combined surface: a trackpad for cursor movement and a keyboard passthrough for typing into the currently-focused desktop app, both live at the same time. No mode switching. The trackpad handles pointing and clicking, and a small strip at the top of the surface hosts the few keys mobile IMEs can't produce (Esc, Tab, arrows) plus a keyboard-icon toggle that raises the phone's soft keyboard when you want to type. When the IME is open you can still drag on the trackpad area to move the cursor — the two coexist.
Manual control covers the long tail layouts don't: URL bars, chat boxes, ad-hoc commands, plus anywhere you'd normally reach for a trackpad. Known per-app shortcuts stay in layouts as buttons.
Trackpad gestures (client-side; daemon receives pad / pad_tap / pad_drag events and maps them to REL_X / REL_Y + BTN_LEFT / BTN_RIGHT on the same uinput device that handles keys and scroll):
| Gesture | Action |
|---|---|
| One-finger drag | Move the desktop cursor (relative motion, like a laptop trackpad) |
| Quick tap (< 250ms, < 10px) | Left click |
| Two-finger tap (both down, both up together) | Right click |
| Tap-and-a-half (tap, then touch again within 400ms and drag) | Left button held during the drag; release on finger lift |
The right-side jogstrip stays available for scrolling while you're pointing.
Keyboard passthrough is opt-in: the IME is closed when you enter manual control. Tap the keyboard-icon button on the strip to raise the phone's soft keyboard; tap it again to dismiss. While the IME is open, the hidden input behind the trackpad captures glyphs and forwards them to the daemon via the type / key wire messages — the same path layout key actions use. The trackpad surface still captures pointer events; you can type and move the cursor in the same session without switching modes.
- The IME does the typing. Letters, symbols, autocorrect, swipe-typing — the client diffs the hidden field's contents on every input event and sends the delta (
typemessage), so whatever the IME commits is what the desktop gets. Enter and Backspace travel as namedkeymessages instead (Android: viabeforeinputinputType inspection; iOS / physical keyboards: viakeydown). - Minimal strip. Mobile keyboards have no Esc / Tab / arrow keys, so the strip at the top of the surface sends those as named combos. There are deliberately no sticky Ctrl/Alt modifiers — combos belong in layouts.
- ASCII only, US layout. Injected text is translated to evdev keycodes char-by-char; capitals and shifted symbols get an implicit Shift per the US keyboard layout. The desktop's own layout reinterprets keycodes, so exact fidelity requires the desktop to use US layout. Anything outside printable ASCII (accented characters, CJK, emoji) is logged and dropped.
- Focus guard. Injected keystrokes land on whatever window has desktop focus. If that's the deckd client itself (you opened the client on the same machine as the daemon), the daemon drops
type/keymessages rather than feed the client's own input back into itself. - Physical keyboards. A Bluetooth keyboard paired to the phone works through the
keydownpath with no extra setup.
⚠️ Security. The keyboard passthrough is a remote text-injection primitive — with a terminal focused it is arbitrary command execution. Every client authenticates with a shared password (see Client auth) unless the daemon is started with--no-auth. The password is a single shared secret over a plaintext WebSocket, not per-user auth or transport encryption — still expose the daemon (--bind 0.0.0.0) only on a network you trust, ideally a Tailscale tailnet, and put TLS in front of it if the link isn't already private. (Auth is enforced from thehellomessage, so it holds up behind a proxy — a TLS terminator or the Vite dev proxy — without anyX-Forwarded-Fortrust.)
Tap the layout editor button in the bottom chrome (next to media browser / settings) to build and edit layouts in the browser — no hand-editing YAML. Like manual control and the media browser, it's a chrome view that swaps in over the button grid.
- Palette — pick a widget kind (button, jogstrip, meter, stats, media, blank, …) to append it to the layout.
- Reflow canvas — widgets render exactly as the live deck does (ADR-0010, ordered-list reflow). Drag to reorder, adjust a widget's
sizespan, and toggle the layout'soverflowmode; the canvas repacks as you go. - Properties panel — edit the selected widget's
label,icon(via a searchable Lucide / Simple Icons picker),color, and itsactionormacro. Fields the editor doesn't model yet are passed through opaquely, so editing a layout never drops hand-authored config. - New layouts — create a layout from scratch, setting its
match:list; the filename is derived from the primary match token on first save.
Saving writes back to disk over the authed write API (PUT/POST /layouts, below), preserving YAML comments and widget identity; watchfiles then hot-reloads every connected client. The editor is in active development — most YAML is round-trippable today, but hand-editing remains the escape hatch for anything it doesn't yet surface.
The bottom strip's app badge carries the focused app's brand identity, sourced from the active layout's YAML — three optional top-level fields the daemon relays opaquely to the client:
display_name: Mozilla Firefox— the human-readable label shown instead of the rawmatchtoken. Without it the badge falls back to the match token.theme: "#ff7139"— any CSS colour string (hex,hsl(...), named); tints the badge border and a thin accent stripe along the top edge of the bottom chrome, so the focused app reads at a glance from across the room.icon: { source: simple-icons, name: firefox }— the same{source, name}dispatch widgets already use (ADR-0006); reuses the bundled Lucide + Simple Icons sets, so badging a new app is a YAML edit with no client/daemon rebuild.
A layout with none of these keeps the chrome unchanged (the badge is just the bold app name). The daemon never resolves icons from .desktop files or the web — presentation stays in user-owned config, exactly like per-widget color. See ADR-0007 for the full rationale.
match:
- firefox
display_name: Firefox
theme: "#ff7139"
icon:
source: simple-icons
name: firefox
widgets:
- ...Tap the settings button in the bottom chrome for a control panel:
- Scroll scale (slider, integer 1–10, default 3) — high-resolution wheel units per CSS pixel.
- Scroll invert (toggle) — flip vertical scroll direction.
- Bar width (slider, 40%–100%, default 100%) — width of the persistent right-side jogstrip (the scroll bar), as a fraction of its responsive base width, so you can slim it down on devices where it reads as too wide.
- Trackpad sensitivity (slider, float 0.5×–3.0×, default 1.0×) — multiplier applied to raw pointer deltas before they're sent to the daemon.
- Content size (slider, float 0.75×–2.5×, default 1.0×) — multiplier for grid content (button icon + label, in-grid jogstrip) on top of the responsive base, so the deck stays readable across phone and tablet screens. The persistent chrome is unaffected.
- Text size (slider, float 0.5×–1.5×, default 1.0×) — multiplier for the button label (the caption under each icon), applied on top of Content size, so the text can be dialled down without shrinking the icon.
- Bottom bar (slider, 40%–100%, default 100%) — size of the persistent bottom chrome bar (app badge, connection indicator, trackpad + settings buttons), so you can shrink it down on devices where it reads as too tall.
- Keep screen awake (toggle, default on) — holds a Screen Wake Lock while the socket is open and the tab is visible, so a phone acting as the surface doesn't sleep mid-use. Released on tab hidden / socket disconnect; re-acquired on visible / reconnect. Unsupported browsers or denied permissions are logged and swallowed.
- Show key hints (toggle, default off) — renders the key combo a button sends (its
action.key, or the firstkeystep of a macro) as a small dimmed caption under the label, e.g.Ctrl+A. Buttons whose action isn't a key combo (shell, url, dbus, …) show no hint.
Values persist per-device to localStorage — closing and reopening the client keeps your tuning. The persistent right-side jogstrip stays live inside the settings view so you can feel scale/invert changes immediately.
URL query params still work as a one-shot dev override (won't touch localStorage):
http://<host>:5173/?scrollScale=2
http://<host>:5173/?scrollScale=4&scrollInvert=1
http://<host>:5173/?padSensitivity=1.5
http://<host>:5173/?contentScale=1.5
http://<host>:5173/?labelScale=0.7
http://<host>:5173/?jogWidth=0.6
http://<host>:5173/?bottomScale=0.7
http://<host>:5173/?wakeLock=0
http://<host>:5173/?showKeyHints=1
Daemon-side flick momentum can be tuned with CLI flags:
.venv/bin/deckd --layouts-dir layouts \
--scroll-momentum-friction 0.90 \
--scroll-momentum-cutoff 20 \
--verboseLower friction decays faster; --scroll-momentum-friction 0 effectively disables momentum after one frame. The helper script can test release momentum without the touch UI:
sleep 2 && .venv/bin/python -u scripts/send_scroll.py --velocity 1200The client is usable end-to-end without a mouse (issues #60 and #62).
Keyboard navigation — Tab walks every interactive element in DOM/logical order: the bottom-chrome buttons (manual control / media browser / settings), the layout's widgets, the in-grid jogstrip, the settings sliders and toggles. Shift+Tab walks back. The focused element has a high-contrast cyan focus ring (a double-box-shadow; meets WCAG 2.1 SC 1.4.11 contrast); the ring is focus-visible-only, so a mouse click doesn't surface it.
Keyboard activation — every button (chrome, grid, media, mediabrowser, settings, jog-strip) responds to Enter and Space. Native <button> elements get this for free when they have an onClick; the project's onPointerDown-only pattern (kept for fast touch response) is paired with a matching onKeyDown so the keyboard path is preserved.
Keyboard alternatives for the pointer surfaces — the right-side jogstrip and the trackpad each expose a keyboard mode so they aren't pointer-only:
| Surface | Keys |
|---|---|
| JogStrip | ↑/↓/←/→ (small step), PageUp/PageDown (large step), Home/End (jump), held = auto-repeat |
| Trackpad | ↑/↓/←/→ (move), Numpad 1/3/7/9 (diagonals), PageUp/PageDown (big step), Space/Enter (left click) |
Global shortcuts — 1 toggles trackpad mode, 2 opens the media browser, 3 opens settings, Escape returns to the focused-app layout. Shortcuts are suppressed while a text input is focused, so typing into the password gate or the trackpad IME isn't hijacked.
Focus restoration — opening a chrome view (settings, trackpad, media browser) pushes focus into the first interactive element of that view; closing it (via Escape or the same button) hands focus back to the chrome button that opened it. The password gate also restores focus to the surface after a successful submit, so a keyboard user can Tab into the layout without clicking anywhere.
OS-level preferences — the theme respects prefers-contrast: more (thicker focus ring, higher-contrast cell borders, white halo on the connection dot) and prefers-reduced-motion: reduce (the connection-state pulse and the media-icon playback dot stop animating; press feedback loses its scale-down but keeps the static brightness shift). Status (connection state, playback state) is conveyed by icon + text + colour so it doesn't depend on colour alone: the connection indicator has a visible "live" / "reconnecting" / "disconnected" / "locked" label, and the media icon carries a screen-reader-only "now playing" / "idle" string alongside the pulsing green dot.
Typography — labels scale with the browser zoom and the existing Text-size slider; cell sizes use clamp(min, vw, max) so they grow with the viewport. The Content-size and Bottom-bar sliders affect icon + chrome sizes without clipping adjacent content.
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):
sudo setfacl -m u:"$USER":rw /dev/uinputThe reproducible setup is the udev rule plus membership in input:
sudo install -m 0644 packaging/udev/70-deckd-uinput.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
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:
ls -l /dev/uinput
id
just check-uinputOn NixOS, import packaging/nixos/deckd-spike.nix and enable the spike service:
{
imports = [ /home/jono/src/deckd/packaging/nixos/deckd-spike.nix ];
services.deckd-spike = {
enable = true;
user = "jono";
projectDir = "/home/jono/src/deckd";
lan = true;
};
}Run just setup and just build-client in the checkout before starting the user service.
Layout YAML is watched by the daemon itself — any edit under layouts/ is picked up automatically and pushed to every connected client. No manual deckctl reload needed. A broken save (bad YAML, schema violation) is trapped: the daemon keeps the last-good layouts live and sends a LayoutMessage with error: "<parse error>" so the client shows a diagnostic in place of the grid until the next successful save.
Python changes need a daemon restart. just dev-daemon runs a supervisor that watches daemon/**/*.py and restarts the child on save:
# Terminal 1
just dev-daemon # daemon under a Python-file-restart supervisor
# Terminal 2 (LAN so a phone can hit it)
just dev-client-lanOpen http://lute:5173 on the phone when hostname resolution is available, or use the Network URL printed by Vite. lute is listed in Vite's server.allowedHosts. dev-client-lan sets VITE_DECKD_WS=ws://lute:8765/ws automatically, so the Vite page still talks to the daemon.
GNOME Shell's built-in org.gnome.Shell.Introspect API returns AccessDenied for window queries. Instead, a tiny GNOME Shell extension (deckd-focus@local) publishes the focused window as JSON over session D-Bus (org.deckd.Focus). The daemon polls this at 100ms via GnomeShellFocusBackend.
Install and enable (relogin required on Wayland if the extension is not yet listed):
just install-focus-extension
# If it says "Installed but not enabled", log out/in then:
gnome-extensions enable deckd-focus@localVerify:
just watch-focus # polls and prints focus changes
just watch-focus-once # single snapshotExpected output:
app_id='org.gnome.Console' wm_class='org.gnome.Console' pid=1234 title='Terminal'
app_id=None wm_class='firefox' pid=188566 title='YouTube — Mozilla Firefox'
On any X11 session — XFCE, MATE, Cinnamon, Budgie, LXQt, KDE-X11, GNOME-X11, standalone i3/openbox, etc. — the focus watcher uses [xdotool](https://manpages.ubuntu.com/manpages/xdotool) directly. No GNOME extension is needed; no D-Bus service is required. The daemon picks X11FocusBackend automatically when XDG_SESSION_TYPE=x11.
Make sure xdotool is on $PATH:
# Debian / Ubuntu
sudo apt install xdotool
# Fedora
sudo dnf install xdotool
# Arch
sudo pacman -S xdotoolThen verify the same way:
just watch-focus # app_id=None, wm_class=<class>, title=<title>
just watch-focus-onceOn X11 there is no app_id analogue (no Wayland / Flatpak app id), so app_id is always None and layouts match on wm_class only. If xdotool is missing or cannot reach the display, watch-focus and the daemon both print an install hint instead of crashing.
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:
just install-focus-kwinTooling. 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:
- Installs the KWin Script package into
~/.local/share/kwin/scripts/deckd-focus/viakpackagetool6 -i(falling back to-uwhen a copy is already installed). - Persists
deckd-focusEnabled=trueinkwinrcso the script survives relogin. qdbus org.kde.KWin /KWin reconfigureapplies the enable flag without a relogin.- Hot-starts the script via
org.kde.kwin.Scripting.loadScript, which fires the script's initialpush(workspace.activeWindow)against the running daemon'sorg.deckd.Focuscache so the layout switches to the currently focused app immediately instead of waiting for the next alt-tab.
Verify:
just watch-focus # polls and prints focus changes
just watch-focus-once # single snapshotExpected output:
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 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.
Cold-start ordering. Because KWin scripts can only callDBus outbound (they can't own a D-Bus name — see spike), the script's initial push(workspace.activeWindow) lands nowhere if the daemon isn't yet running. The cache stays empty until the next window activation, or until you re-run just install-focus-kwin (which hot-reloads the script and re-fires the initial push against the now-running daemon). Long-running sessions with the script enabled in kwinrc automatically re-fire the initial push on the next KWin restart, so day-to-day use doesn't require re-running the recipe.
Two conveniences for local development without a separate device:
Auto-ignore. When the focus watcher reports the deckd client browser window gaining focus (matched by the daemon's own port appearing in the window title, or the deckd page title "deckd" in the title), the daemon holds the current layout instead of switching away. So clicking the browser tab that's rendering the control surface doesn't flip the layout to the browser's own (e.g. Firefox) layout while you're testing something else.
Layout override. deckctl layout <name> force-switches every connected client to a named layout regardless of focus, so you can test a specific app's layout without opening that app:
deckctl layout firefox # force the firefox layout on all clients
deckctl layout default # back to the default layout
deckctl layout nonexistent # error: unknown layout (exit 1)This hits POST /layout/<name> on the daemon. The override is global (every connected client) and not sticky: the next genuine (non-deckd-window) focus change clears it and normal focus-driven switching resumes.
Layout save/create (write API). The in-development layout editor writes layouts back to disk over two authed HTTP endpoints (plural /layouts, distinct from the runtime-override POST /layout/<id>): PUT /layouts/<id> performs an idempotent full-snapshot save of an existing layout (URL <id> must equal match[0]; a match[0] change is a 409 rename, use create instead), and POST /layouts creates a new file on first save, deriving the filename from slugified match[0] (Slack → slack.yaml) with a 409 on id collision. Both return sanitized structured 400s (loc/msg/type only — no action payloads) and 200 echoing the canonical re-read; writes are atomic (temp + os.replace) so watchfiles hot-reloads the live deck. See docs/REFERENCE.md for the full endpoint table.
Per-client pin (?layout=<name>). For a demo device you want to park on one view, append ?layout=<name> to the client URL (e.g. ?layout=tilix). The client sends the name in its hello frame and the daemon pins just that session to the named layout, ignoring host focus and unaffected by other clients — so a window switch on the host won't move it. The name is matched case-insensitively against each layout's id, display_name, or any match token, so ?layout=tilix finds the layout even though its id is the reverse-DNS token com.gexperts.Tilix. The pin lives in the URL (survives reload) and re-resolves from disk on deckctl reload; an unknown name is ignored and the client follows focus as normal. This is the backend-driven counterpart to the backend-free ?demo=<name> fixtures (see Design tooling) — ?layout= serves the real daemon layouts, so it never drifts.
scripts/smoke.py boots the daemon in-process, connects a WS client, fires every action primitive, and asserts the right things happen. Useful as a quick "did I just break the wire?" check:
uv pip install -e ".[dev]" # installs the websockets test dep
.venv/bin/python -u scripts/smoke.pyThe canonical reference for all flags, commands, environment variables, and diagnostic workflows is docs/REFERENCE.md. Quick-start:
deckctl status # hit /health (open — no password needed)
deckctl reload # POST /reload — re-read layout YAML and push
deckctl layout firefox # force all clients to the firefox layout (dev)
deckctl layout default # force the default layout
# /reload and /layout are gated when auth is on — pass the password:
deckctl --password "$PW" reload
DECKD_PASSWORD="$PW" deckctl --host desktop.tailnet.ts.net layout firefoxWhen the daemon runs with auth on, the control endpoints (/reload, /layout) require the password — supply it with --password or the DECKD_PASSWORD env var. deckctl deliberately does not read the daemon's password file itself (it may be pointed at a remote daemon whose file it can't see). deckctl status hits /health, which is left open, so it always works. For frictionless local work, run the daemon with --no-auth.
The commands above are for a foreground / development run. To have deckd start with your desktop and stay running, install it as a per-user session service.
deckd is a per-user desktop-session daemon, not a detachable backend. One process both runs the logic and serves the built web client (there is no separate frontend server in production — --client-dist client/dist is served at :8765). Because it watches the focused window through a compositor plugin, injects input, and calls your session D-Bus bus, it must run inside your logged-in graphical session. That makes a systemd user service (Linux) or a launchd LaunchAgent (macOS) the right vehicle — not a system daemon (no session bus/display) and not Docker (it would need host /dev/uinput, the host session-bus socket, and the host display, and still couldn't host the compositor plugin — so containerising buys no isolation).
1. Build the client the daemon serves (one-time; re-run after upgrading):
just setup && just build-client2. Set up input + the focus watcher for your platform:
-
Linux — grant
/dev/uinputwrite access (udev rule +inputgroup, per uinput permissions; without it injection is a silent no-op), then install the focus watcher for your desktop:just install-focus-extension # GNOME Shell just install-focus-kwin # KDE Plasma Wayland (see the KDE section for prerequisites)
-
macOS — no udev/uinput; the first focus change pops a one-time TCC prompt for System Events (accept it once). See the macOS section for the focus/injection capability matrix.
3. Install the service — OS-aware, like just setup: the systemd user unit on Linux, the launchd agent on macOS:
just install-serviceOn Linux this installs packaging/systemd/deckd.service (with your checkout path substituted for @PROJECT_DIR@) to ~/.config/systemd/user/, then systemctl --user enable --now deckd — WantedBy=graphical-session.target, so it starts on login and restarts on failure. On macOS it installs packaging/launchd/com.deckd.daemon.plist to ~/Library/LaunchAgents/ and launchctl loads it (RunAtLoad + KeepAlive).
Auth is on by default — the shared password is read from (or generated at) ~/.config/deckd/password on first start; the bind is localhost-only unless you add --bind 0.0.0.0 (or --bind iface:wlan0) to the unit's ExecStart / the plist's ProgramArguments.
# Linux
systemctl --user status deckd # check it's running
journalctl --user -u deckd -f # follow logs
systemctl --user restart deckd # only after a code/unit change — layout YAML hot-reloads
sudo loginctl enable-linger $USER # optional: keep running while logged out (headless deck host)
# macOS
launchctl list | grep deckd # confirm it's loaded
tail -f deckd.log # follow logs (written in the checkout)Prefer not to use just? The recipes are thin wrappers you can run by hand — install-service is a path-substituting sed into ~/.config/systemd/user/ (or ~/Library/LaunchAgents/) followed by the systemctl --user enable --now / launchctl load above; install-focus-extension is gnome-extensions pack/install/enable on packaging/gnome-shell/deckd-focus@local. See the Justfile for the exact commands.
NixOS users can skip all of the above — import the module at packaging/nixos/deckd-spike.nix, which declares the same user service plus the uinput udev rule and input group. See its header for options (bind, port, …).
A directory of YAML files in layouts/ — one per app, plus a default.yaml fallback. Shipped layouts today: default, firefox, terminals (org.gnome.Console, foot, kitty, gnome-terminal, konsole, alacritty), com.gexperts.Tilix. Each widget has an id, kind (button or jogstrip — the trackpad is a chrome mode, not a widget kind), an optional size: [w, h] span (default [1, 1]; for non-square widgets like wide meters), an optional label, an optional icon: (a {source, name} pair — source names a client-side icon set, e.g. lucide or simple-icons, and name is the glyph within it; the daemon relays it opaquely), an optional color: (any CSS colour string — hex, hsl(...), named — applied as the button background; buttons only, ignored on jogstrips), and an optional action. Widgets pack in list order (ADR-0010); there are no grid coordinates. The special kind: blank skips a cell slot for visual gaps. A layout's top-level match: list says which apps it covers (matched by app_id or wm_class); the layout with match: [default] is the fallback. A layout may set jogstrip: false at the top level to suppress the client's persistent right-side chrome jogstrip (defaults to true); the daemon echoes this to the client as jogstrip_enabled on every LayoutMessage. A layout may also set three optional top-level chrome-identity fields the daemon relays verbatim — display_name (human-readable app name shown in the bottom badge), theme (a CSS colour the badge + chrome accent is tinted with), and icon (a {source, name} pair rendered next to the app name) — see the Chrome app badge section and ADR-0007. Action primitives:
shell: "..."— launch a command, fire-and-forget. The child is detached (its own session) and runs independently; stdin/stdout/stderr are discarded and the daemon does not wait for it or observe its exit code. This is the way to launch a program (shell: firefox,shell: code,shell: "xdg-open https://…"), including a specific terminal (shell: tilix).terminal: true— open the auto-detected terminal emulator, resolved via$TERMINALthen a candidate list (foot,kitty,gnome-terminal,konsole,alacritty). This is the only accepted form:terminaltakes no command string — for a specific program (terminal or otherwise) useshell:. A string value is rejected at layout-load time with a message pointing you atshell:.key: "ctrl+t"— fire the keystroke through uinput as a single combo.dbus: "service:path org.Interface.Method arg1 arg2"— call a D-Bus method viadbus-fast. The bus is inferred from the interface name (org.freedesktop.login1.*,systemd1.*,timedate1.*,locale1.*, etc. → system bus; everything else → session bus). Errors are logged, not surfaced to the client. With theservice:pathprefix omitted, the daemon derives them from the first two / three segments of the interface name.url: "https://…"— open a URL in the user's default browser (xdg-openon Linux,openon macOS). Acceptshttp:,https:, andfile:schemes; other schemes are rejected at load time with guidance to useshell:instead. The URL is passed directly to the opener binary (no shell quoting), so query strings, fragments, and percent-encoded paths survive unchanged.text: "hello world"— inject a string into the focused window. Two modes: simulate (default) emits each character as a synthetic key event through the existing keyboard injection path; paste (text_mode: paste) writes the string to the clipboard, emitsctrl+v, and restores the previous clipboard contents after one second (setrestore_clipboard: falseto skip restoration). When a string contains characters not mappable to keycodes (multi-byte emoji, control chars) and the mode isn't explicitly forced tosimulate, it automatically falls back to paste with a warning.
Mark a button (or a macro widget) as dangerous by adding confirm: true. On press the daemon withholds the action, mints a short token, and pushes a confirmation prompt to the client; the action runs only when the client confirms. The client also carries a persistent red border + ⚠ badge on the widget in its resting state so danger reads at a glance before any press.
- id: rm-all
kind: button
label: Remove all
confirm: true # require a confirmation before running
action:
shell: "rm -rf ~/Downloads/tmp"
# A macro is gated as a whole — one confirm covers every step.
- id: full-reset
kind: button
label: Full reset
confirm: true
macro:
steps:
- type: key
value: "ctrl+alt+Delete"
- type: shell
value: "systemctl --user restart deckd"Rules:
confirmis a plain boolean (defaultfalse). Opt-in only — the daemon never auto-classifies an action as dangerous.- Valid only on a widget that has an
actionor amacro. Rejected at load onblank,meter,stats,media, andmediabrowser(media sub-actions are intentionally ungated; nothing dangerous runs there). - Gates the main press only. The same widget's transport / sub-actions still fire without a prompt.
- The confirmation round-trip is daemon-authoritative: silence = no dangerous action. The daemon never runs the action until the client confirms. A ~30 s backstop discards the pending action if the client doesn't reply (silently, no-op) and the client modal auto-dismisses in lockstep so a visible prompt is always a live one.
- Outcomes are recorded in the diagnostic surfaces: a
confirmdiagnostic event carries the lifecycle (requested/confirmed/cancelled/expired), andcancelled/expiredland as distinct recent-action ring records.confirmedis the normal execution record (no double-counting).
The keyboard contract for the modal is Enter = Confirm, Esc = Cancel.
Every client authenticates with a single shared password. There is no source-address exemption — a same-machine browser is treated exactly like a phone on the LAN. The check only looks at the password carried in the WebSocket hello frame (or the X-Deckd-Password header for the HTTP control endpoints), never at the peer IP, so it stays correct behind a proxy (a TLS terminator, or the Vite dev proxy) without any forwarded-header trust. --no-auth turns it off entirely — the right choice for frictionless local development.
- Where it lives.
~/.config/deckd/password($XDG_CONFIG_HOME/deckd/passwordif set), plaintext, mode0640. Override the location with--password-file <path>, or disable auth entirely with--no-auth. - First start. If the file is absent, the daemon generates a random 32-char password, writes it (mode
0640), and logs it once at WARN with aSAVE THIS — it won't be shown againheader. It's never logged again. - Pre-existing file. Respected verbatim. Set your own before first start with
pwgen 32 | tee ~/.config/deckd/password && chmod 640 ~/.config/deckd/password. The daemon refuses to start if the file exists but is unreadable or more permissive than0640, logging the path and reason. - On the client. A client that connects without (or with the wrong) password lands on a password screen; entering the password connects and the browser remembers it (localStorage). There's no QR, token file, or URL query param.
/healthstays open even with auth on — it's a read-only diagnostic the Settings panel fetches, anddeckctl statusrelies on it.- Rotation is out of scope: edit the file and restart the daemon.
The password is a shared secret over a plaintext WebSocket — it gates access, it does not encrypt the link. Keep the daemon on a trusted network (see the security note above).
By default the daemon binds to localhost only (127.0.0.1 + ::1) — a fresh install is reachable from the host machine but invisible on the LAN even before the password gate is configured. To expose it to a phone, a tailnet, or another host, repeat --bind with the addresses you want it to listen on:
# LAN opt-in: bind to every interface on the IPv4 stack. The password
# gate still has to be passed by every non-localhost client.
deckd --bind 0.0.0.0
# Tailnet only: bind to a single Tailscale IP, not the whole LAN.
deckd --bind 100.64.0.1
# Bind to every IP on a specific interface (handles DHCP
# re-assignments without editing the command). The name must exist.
deckd --bind iface:wlan0
# Mixed: localhost + a tailnet address, repeated --bind.
deckd --bind 127.0.0.1 --bind ::1 --bind 100.64.0.1Each spec is either a literal IPv4/IPv6 address or iface:<name> (every usable IP on that interface). The CLI rejects typos and unknown interfaces at startup — no silent fallback. All bound sockets share one port (--port 8765 by default; 0 asks the kernel for an ephemeral one).
The active bind surface is exposed for tooling:
GET /healthreturnsbind,addresses, andurl(the preferred pairing URL — IPv4 wins, IPv6 only when nothing else is bound).GET /diagmirrors the same fields for AI-assisted debugging.deckctl statusprints the pairing URL above the JSON.
The NixOS spike module (services.deckd-spike) takes a list-shaped bind option (default [ "127.0.0.1" "::1" ]) and translates each entry into a --bind flag.
The daemon also loads a sibling directory next to --layouts-dir whose name is suffixed with the current platform: layouts.macos/ on macOS, layouts.linux/ on Linux. A missing overlay is fine (the most common case). Overlay entries load first and replace any base entry with the same id — so layouts.macos/firefox.yaml overrides layouts/firefox.yaml on Mac without you touching the shared base. The watcher also watches the overlay dir, so edits reload live. Pass --no-overlay to skip the overlay even when it exists (debugging, cross-platform checkout debugging, etc.).
This is how layouts.macos/firefox.yaml carries the super+t / super+[ / super+] shortcuts without forking the rest of firefox.yaml for every Linux user who pulls the repo.
A layout can include widgets that display values pushed by the daemon in real time. Today the only kind is meter (a numeric readout with a horizontal bar). It looks like a button in the grid, doesn't react to taps, and renders the value the daemon keeps pushing on the bound sensor source.
- id: cpu_percent
kind: meter
label: CPU
icon:
source: lucide
name: cpu
source: cpu_percent # daemon-side sensor name
min: 0 # bar's left edge (default 0)
max: 100 # bar's right edge (default 100)The daemon polls the bound sensor on a timer and pushes a widget_update WebSocket frame every time the value changes (or the source flips stale). The bar fills proportionally between min and max and is color-graded cool→hot so a glance tells you whether the number is OK before you read it.
Built-in sensors (all psutil-backed — see "Why no CPU temperature?" below for the rationale):
| Source | What it shows | How | Poll |
|---|---|---|---|
cpu_percent |
Whole-system CPU utilisation | psutil.cpu_percent(interval=None) — delta since the last call. The first reading is 0.0 (no baseline); subsequent readings land in [0, 100]. |
1s |
mem_percent |
Memory used / total | psutil.virtual_memory().percent |
1s |
| (more TBD) | CPU frequency, battery, swap | psutil.cpu_freq(), psutil.sensors_battery(), psutil.swap_memory() — all the same API. Open to contributions. |
varies |
We deliberately don't ship a cpu_temp source. The short version is that Apple Silicon doesn't expose a stable, unprivileged CPU temperature API:
psutil.sensors_temperatures()has no macOS backend at all (verified against the upstream source — there's no IOKit call, no SMC probe, no entitlement handling).osx-cpu-temp(Homebrew, ~100 lines of C) andistats(theiStatsRuby gem) both read classic Intel SMC keys (TC0P,TC0D,TC0E). Apple Silicon uses a completely different sensor namespace that Apple doesn't document and that changes per SoC generation; the brew arm64 bottle exists because the binary compiles and runs, not because it returns valid temperature data.- The only reliable M-series source is Apple's own
sudo powermetrics, which requires root, an undocumented/unstable output format, and either a privileged helper or interactive sudo prompts.
Net result: a cross-platform cpu_temp source would either silently fail on most Apple Silicon Macs (deceptive) or require a deployment story heavier than the rest of deckd put together (overkill). cpu_percent and mem_percent cover the same "is the box healthy" use case and work on every Linux + every macOS without any per-OS install step. Users who really want CPU temp on Linux specifically can keep a custom layout pointing at /sys/class/thermal (we removed the in-tree reader because nothing on macOS could share the code path; bringing it back is a small PR).
The meter rendering is a regular cell in the grid (it picks up --content-scale and the user's Button-size preference like every other widget). When the daemon hasn't pushed a value yet, the cell renders "—" with the bar at 0% and a dashed border so you can see at a glance it's waiting.
Try it without sensors: ?demo=meter loads a backend-free demo with seeded CPU%/MEM% values so you can see the meter without a running daemon.
The pieces behind the features above, for anyone reading the code:
- Wire protocol in both directions:
LayoutMessage(withjogstrip_enabled+ optionalerror) andhello(with optionalpassword) /press/jog/jog_end/pad/pad_tap/pad_drag/type/keyevents. A remote client that fails auth gets{"type": "error", "reason": "unauthorized"}and the socket is closed. - YAML config → Pydantic →
Widgetgraph → action dispatch forshell,terminal,key,dbusprimitives. - Jogstrip scroll plumbing from browser pointer movement to daemon-side uinput, including release momentum.
- Manual control mode: combined trackpad (
REL_X/REL_Ymotion plusBTN_LEFT/BTN_RIGHT/BTN_MIDDLEon the same uinput device, with client-side gesture recognition: tap / two-finger tap / tap-and-a-half drag lock) and IME passthrough (type/keywire messages, ASCII+Shift→evdev translation, daemon-side focus guard against self-injection). Both live in one view; the strip's keyboard-icon toggle raises the soft keyboard. - Active-window detection via GNOME Shell extension + session D-Bus (
app_id,wm_class,title,pid). - Persistent client chrome — bottom strip (branded app badge + connection dot + manual-control button + media icon + settings) and right-side jogstrip — layered above every layout with zero daemon involvement. The app badge optionally carries an icon, a theme colour, and a human-readable name the layout YAML declares (ADR-0007). The one carve-out is the
mediabrowserchrome view: a client can pin its session to a specific layout viaselect_view, with the daemon pushing aview-taggedLayoutMessageso the client knows which mode to render. ADR-0008 records the carve-out and the general mechanism. - Layout hot-reload — the daemon watches
layouts/*.yamland re-pushes on any edit; bad YAML surfaces as a diagnostic on the client without crashing the daemon. - Reconnecting client (
useDeckdSocketexponential backoff). - Build output is plain static files —
client/dist/— served by the daemon.
The daemon is normal Python — pip install -e . is the contract. We keep the Nix-based packaging (udev rules, input group, systemd.user.service) in the lifecycle milestone #5 for when a clean-machine install story matters; the per-day edit/run loop should not need a sandbox.
The media kind is a single responsive composite widget. It uses configured keyboard actions by default, so basic play/pause remains available without extra VLC configuration. Add media_http to receive live playback state, timestamps, volume, and text metadata from VLC's local HTTP interface:
- id: vlc-media
kind: media
size: [4, 2]
controls: [play, volume, position]
action: {key: space}
volume_down_action: {key: volumedown}
volume_up_action: {key: volumeup}
media_http:
host: 127.0.0.1
port: 8080
password_ref: VLC_HTTP_PASSWORDIn VLC, open Tools → Preferences → Interface and select Web under Main interfaces. Under Interface → Main interfaces → Lua, set the Lua HTTP password, save, and restart VLC. VLC's HTTP interface listens on port 8080 by default.
For a Flatpak VLC installation, starting it explicitly is useful for testing:
flatpak run org.videolan.VLC \
--extraintf=http \
--http-host=127.0.0.1 \
--http-port=8080 \
--http-password=dummyKeep the HTTP interface bound to localhost unless remote access is specifically required. Export the same password before starting deckd:
export VLC_HTTP_PASSWORD=dummyVerify VLC independently before debugging deckd:
curl -u ':$VLC_HTTP_PASSWORD' \
http://127.0.0.1:8080/requests/status.jsonA successful response is JSON with fields such as state, time, length, volume, and information.meta. A 401 Unauthorized response means VLC is running but the supplied password does not match the active Lua HTTP password. If the endpoint cannot connect, enable the Web interface, confirm VLC was restarted, and check that port 8080 is listening.
The daemon polls this endpoint once per second and forwards changed values to the client over its WebSocket. Volume and seek commands use the same HTTP interface; play/pause remains the configured keyboard action. Without media_http, a media widget renders keyboard-only −/+ controls backed by volume_down_action and volume_up_action; with HTTP configured, it preserves the live volume slider. If VLC HTTP becomes unavailable, live values are explicitly shown as unavailable. The password is never stored directly in layout YAML, and password_ref must name a non-empty environment variable.
The media cell shows cover art in its centre, falling back to the VLC logo when none is available. Because the phone can't read the daemon host's local art cache or hold VLC's password, the daemon proxies the image: the client requests /media/<widget-id>/art (unauthenticated — album art is low-value and an <img> tag can't carry the password header) and the daemon streams back the current item's art. The URL is cache-busted per track, so the browser fetches each cover only once.
Art sources are chosen with art_source (default [vlc]):
art_source: [vlc, itunes]vlc— VLC's own art (embedded tags or its art cache; enable VLC's album-art download policy if you want VLC itself to fetch online art).itunes— when VLC has no art, the daemon looks the cover up via Apple's public iTunes Search API using the track's artist/album/title. This is opt-in because it sends that metadata to a third party; dropitunesto keep all metadata local. Results (including misses) are cached in memory, so a track is looked up at most once.
On some setups (e.g. NixOS) Python's TLS can't find a CA bundle, which makes the HTTPS lookup fail silently (art just falls back to the logo). If that happens, set
SSL_CERT_FILE/NIX_SSL_CERT_FILEfor the daemon.
The MPRIS media browser is a global media-control surface that works
independently of the focused app: it lists every MPRIS player the
system exposes over the session D-Bus (VLC, mpv, Spotify, Firefox
audio, …) and gives each row a prev / play-pause / next transport.
It's a chrome view — a full-bleed panel that replaces the layout
area — reached from the bottom chrome. It's deliberately separate
from the VLC media widget (see VLC media widgets):
the VLC widget is per-VLC, the browser is per-host. A user with both
sees the VLC widget in the VLC layout and the browser in the chrome
view, side by side and not interfering.
Drop a layout that declares the mediabrowser widget kind into your
layouts/ directory. The shipped mpris.yaml is exactly this:
match: [mpris]
display_name: MPRIS
widgets:
- id: browser
kind: mediabrowser
size: [4, 2]The match: [mpris] token is a synthetic view name — no real
application reports app_id == "mpris" to the focus watcher. It
exists so the server can address the chrome view by name; you don't
need a focus match for any real app.
Once the layout is on disk, restart the daemon (or just wait — YAML
changes are hot-reloaded). The bottom chrome gains a music-note
icon between the manual-control and settings buttons — the chrome
media icon, the entry point to the browser. Tapping it pins this
client to the MPRIS chrome view (sends select_view: "mpris" over
the WebSocket). Tapping it again reverts to the focused-app layout
(clear_view). The pin is per-client: a phone parked on the browser
doesn't lock a second phone out of its own focus-driven layout.
The icon is opt-in: a daemon that has no mediabrowser layout
never shows the music-note button, never opens the session D-Bus, and
never pays the bus-connect cost. Users who don't enable the feature
see the bottom chrome exactly as before.
The media icon doubles as a glance affordance for the host's
playback state (issue #47): a small green dot pulses in its
top-right corner whenever at least one MPRIS player is Playing,
and disappears otherwise. The dot reads as the same "live signal"
affordance chat apps use for recording indicators, and crucially
doesn't compete with the cyan accent the icon takes on when the
view is open — the two states stack cleanly when both are true.
The icon stays useful whether or not the browser view is open —
the indicator reflects global reality, so a phone on the desk
reads "something is playing" at a glance without the user having
to tap the icon and pin the view.
The daemon pushes a chrome_media frame over the WebSocket on the
two event types that change the indicator's meaning:
- Registration transitions — every
org.mpris.MediaPlayer2.<suffix>appearing, disappearing, or being handed off fires one frame.availableflips in step with the owned-names set. PlaybackStatusboundary crossings — a transition into or out ofPlayingfires one frame.playingflips accordingly.
Position and Metadata updates are filtered out at the backend, so a 1Hz position poll (or a track skip) doesn't flood the icon with redundant frames. The wire is debounce-by-event-type, not time-window — the indicator fires precisely when the meaning changes, no sooner, no later.
A fresh session receives a snapshot frame on connect (right after
the layout + per-row media_state frames), so a phone that joins
while a track is already playing tints immediately rather than
waiting for the next boundary transition. On platforms without an
MprisBackend (macOS today) no frames are produced and the icon
stays in the default outlined state — the same
graceful-degradation stance the rest of the MPRIS surface takes.
The chrome view is the same mpris.yaml layout, rendered with the
layout area replaced by the mediabrowser widget. One row per
discovered player. Each row is topped by an app-name header — the
player's human-readable name from the MPRIS root interface's Identity
(e.g. "Firefox", "VLC media player"), matching GNOME's media control —
and omitted entirely when the player publishes no Identity. Below the
header are three slots:
- Art slot (left) — the row's cover art when the daemon has
mpris:artUrlto point at; the daemon proxies the image atGET /mpris/<row-suffix>/art(unauthenticated, same rationale as the VLC media widget's/media/<id>/art), so the phone never reads the host's cache or carries upstream credentials. Afile:///http(s):///data:URL is resolved server-side (other shapes / no URL fall back through theDesktopEntrybrand icon to the generic LucideDiscglyph). The cover is cache-busted per track, so the browser fetches each new cover exactly once. - Title / subtitle (centre) —
xesam:titleandxesam:artistfrom MPRISMetadata. Unknown fields render as an em-dash. - Transport (right) — previous / play-pause / next buttons. The
play-pause icon follows
PlaybackStatus; previous and next are present but become non-reactive when the underlying player reportsCanGoPrevious == false/CanGoNext == false. Play-pause is always reactive.
Tapping a transport button sends a typed media_command over the
existing WebSocket — play-pause / next / previous keyed by
mpris.<row-suffix>. The daemon routes the message to the right
MPRIS bus name (org.mpris.MediaPlayer2.<suffix>) and the right
Player-interface method (PlayPause / Next / Previous). Volume
and seek are deferred follow-ups.
The view persists across focus changes until cleared — a user who
tapped the icon wants the browser to stay put even if they alt-tab
to a different app. clear_view (the second tap on the chrome
icon, or the session ending) is the only way out.
The mediabrowser widget has one optional knob:
- id: browser
kind: mediabrowser
size: [4, 2]
empty_state: show # or "hide"empty_state: show(default) — when no players exist, render a single "No media players detected" row so the chrome icon is still reachable.hidecollapses the cell so a layout that depends on the browser can drop the cell entirely.
Row order is the order the session bus's org.freedesktop.DBus.ListNames
reply reports the players — the same order GNOME Shell's quick-settings
media widget surfaces, so the two surfaces line up on the same desktop
session. There is no per-widget ordering knob (issue #58).
The daemon enumerates every bus name matching
org.mpris.MediaPlayer2.* on the session D-Bus at startup, gated on
the layout actually containing a mediabrowser widget — users who
don't enable the feature don't pay the bus-connect cost. Two
exclusions:
org.mpris.MediaPlayer2.playerctld— the MPRIS multiplexer forwards commands to other players but exposes itself on the bus too. Including it would create a duplicate row the user has no way to remove. It's filtered out by suffix.- Malformed suffixes (empty, non-ASCII, control characters) are silently dropped at the bus-name parser so a misbehaving player doesn't poison the row set.
The bus is monitored live: NameOwnerChanged signals add / remove
rows as players come and go (a bus-name handoff is treated as
remove-then-add so the new owner's metadata is rebuilt cleanly),
and PropertiesChanged signals update each row's cached state
without a fresh Properties.GetAll round-trip. The browser reflects
the bus, not a snapshot.
The forwarded state subset is the documented one: PlaybackStatus,
xesam:title, xesam:artist, mpris:artUrl (hashed into a stable
art_token the client stamps on the cover-art proxy, see Album
art), DesktopEntry, CanGoNext, CanGoPrevious from
the Player interface, plus Identity (the app_name header) from
the root org.mpris.MediaPlayer2 interface — a separate GetAll
fetched once per player and cached, since the name is stable for a
bus name. Other Player-interface properties the daemon sees are
ignored so a future contributor adding new state slots knows the
subset is intentional.
a{sv} bodies (both the GetAll reply and the live PropertiesChanged
changed dict) arrive with every value boxed in a dbus_fast Variant
— and Metadata is a Variant wrapping a nested a{sv} — so the
backend unwraps them recursively before the property mappers run.
Skipping this silently drops every field (the isinstance checks fail)
and crashes the signal path on the unhashable Variant.
The media pump broadcasts a row only when its state changes, against a
single last cache shared by all sessions. That's fine for the VLC
media widget — its position ticks every second, so every poll is a
change and late-joining clients catch up within a second — but MPRIS
state is static while a track plays the same, so a session that connects
after the last change would never receive the existing players. The
daemon closes that gap by replaying a per-session snapshot of the
current MPRIS rows on connect and on select_view (see
Server.push_media_snapshot), so a reload or a second client shows the
players immediately instead of "no players detected".
The browser shows a real cover in the row's art slot when the
player's Metadata.mpris:artUrl is set, and falls back to the
DesktopEntry-mapped brand icon or the Disc glyph otherwise.
Because the phone can't reach the host's local art cache and has
no way to carry upstream credentials, the daemon proxies the image
at GET /mpris/<row-suffix>/art — unauthenticated, same rationale
as the VLC media widget's /media/<id>/art (art is low-value, an
<img> tag can't carry the password header, and the URL the proxy
serves is always the exact one the row's current metadata reported,
so the endpoint can't be redirected to an arbitrary path). The URL
is cache-busted per track (?token=<art_token>), so the browser
fetches each cover exactly once.
The proxy supports the three mpris:artUrl shapes real players
publish:
file://…— a local cache file (Firefox, Chromium, Spotify write cover art to~/.cacheor/tmp; the daemon reads it).http://…/https://…— a remote cover URL (some players point at a CDN); the daemon fetches it server-side so the phone needs no outbound network or credentials.data:image/…;base64,…— an inline cover (rare, but it sidesteps the cache-file race); the daemon decodes the base64 payload.
Anything else (a non-ASCII scheme like smb://, a malformed data:
URL, no artUrl at all) leaves art_token null and the row falls
back to the brand icon / Disc glyph. Downscaling / thumbnailing
is out of scope for v1; the daemon streams the image as-is.
The two are independent features. The VLC media widget is per-VLC:
it lives in the VLC layout, polls VLC's local HTTP interface for
playback state, and routes commands through VLC's HTTP API. The
MPRIS browser is per-host: it lives in the chrome view, watches the
session D-Bus for every MPRIS player, and routes commands through
the standard MPRIS Player-interface methods. The shared wire message
is the media_command you already saw in the previous section; the
daemon's dispatch routes mpris.* ids to the MPRIS backend and
everything else to the VLC handler. Adding one feature doesn't
affect the other.
A user who has both sees the VLC media widget in the VLC layout
(with VLC's keyboard or HTTP-based transport) and the MPRIS browser
in the chrome view (with per-player MPRIS transport), side by side
and not interfering.
- Volume, seek, scrubber — the v1 browser exposes the three
transport buttons only. Volume and seek controls (and the
capability-gated
CanSeekhonouring) are deferred. - Per-row select / raise — the GNOME 50 media widget calls
MPRIS
Raiseto bring the player to the foreground when the card is tapped. Out of scope for v1.
See ADR-0008 for the chrome-view carve-out (the select_view /
clear_view mechanism, the view field on LayoutMessage, and how
the new general mechanism positions future chrome-shaped views).
A second chrome view lists the host's currently-open windows. Tap
the new layout-grid icon in the bottom chrome strip and the focused
app's layout is replaced with a list of every window the platform
backend can enumerate — labeled by the layout the window would match
against (firefox.yaml → row reads "Firefox", with the Simple Icons
firefox glyph), and falling back to the raw wm_class (or
gtk_application_id, then title last resort) on a default-fallback
row. Default-fallback rows render with a placeholder glyph rather
than a brand icon: a generic "terminal" Lucide glyph on every xterm
would imply every xterm is the same xterm, and the list is
per-window precisely so they're not.
Stage 2 ships display-only — tapping a row is wired but ignored;
stage 3 (a follow-up ticket) raises the window. The list reflects
global reality regardless of which view a session has pinned, so
switching into the view is instant (no spinner, no
select_view round-trip).
The shipped layouts/windows.yaml is the layout the chrome view
pins to:
match: [windows]
display_name: Windows
jogstrip: false
widgets: []The platform backend advertises watch_windows in its
capabilities(); today's GNOME Shell extension ships a
ListWindows() method on the org.deckd.Focus interface that the
daemon polls at ~100ms. Backends that can't enumerate (X11,
macOS, headless) don't advertise the capability — the chrome icon
stays rendered (the affordance is discoverable for users on a
platform that ships it later) but tapping it shows the
"running programs: unsupported on this platform" empty state,
mirroring the media browser's "no players detected" placeholder.
- No
running_windowsframe yet: the unsupported empty state. - Empty snapshot: "no running programs" — distinguishes "the platform can enumerate but the desktop is idle" from "the platform can't enumerate".
- Non-empty snapshot: one row per window. The label is the
matched layout's
display_name(or the layout id whendisplay_nameis absent). The icon rides from the matched layout when present and isnullon the default-fallback path.
The icon_for_window helper re-derives on every push (no cache), so
a layout reload (POST /reload) takes effect on the next snapshot
with no invalidation logic.
deckd is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the
Free Software Foundation, either version 3 of the License, or (at your
option) any later version. See LICENSE for the full text.



