Skip to content

deckd v1: milestones 3–7 implementation spec #6

Description

@jonocodes

Problem Statement

The deckd spikes proved the two highest-risk unknowns: uinput scroll injection works smoothly on GNOME/Wayland, and active-window detection via a GNOME Shell extension is reliable. The project now needs to be built into a complete, daily-usable system. Currently there is no production-quality daemon, no polished client with persistent chrome, no app-aware layout switching, no trackpad mode, and no lifecycle management (auth, suspend handling, packaging).

Solution

Build the full deckd system across five milestones:

  • M3 — Daemon skeleton: wire up all action primitives (key, dbus), integrate the focus watcher into layout switching, auto-ignore the deckd client window during local dev, add deckctl layout override.
  • M4 — Client skeleton: persistent chrome (bottom strip + right-side jogstrip), trackpad mode button, PWA manifest, reconnect overlay, portrait/landscape scaling.
  • M5 — Layout switching: per-app YAML loading, focus watcher → layout push, match resolution, default fallback.
  • M6 — Trackpad widget: relative pointer movement, tap gestures, drag lock, two-finger right-click.
  • M7 — Lifecycle polish: screensaver/suspend D-Bus sync, password auth, deckctl CLI commands, NixOS module.

User Stories

Local development

  1. As a developer, I want the layout to stay fixed when I click the browser window running the control surface, so that I can test app layouts without the surface switching away.
  2. As a developer, I want to run deckctl layout <name> to force a specific layout regardless of which app is focused, so I can test any layout without opening that app.
  3. As a developer, I want layout YAML changes to reload live without restarting the daemon, so I can iterate on configs quickly.
  4. As a developer, I want a smoke test suite that exercises the full daemon via a fake WebSocket client, so I can catch regressions without a real device.

Keystroke and action dispatch
5. As a user, I want to tap a button and have a keystroke fired on my desktop (key: "ctrl+t"), so I can trigger keyboard shortcuts from the surface.
6. As a user, I want to tap a button and run a shell command (shell: "..."), so I can launch apps or trigger scripts.
7. As a user, I want to tap a button and trigger a D-Bus method call (dbus: "..."), so I can control desktop services directly.
8. As a user, I want button actions to fire on the currently focused application, not the browser window showing the surface, so shortcuts go to the right place.

Scroll (jogstrip)
9. As a user, I want to drag the right-side scroll strip and have the focused application scroll smoothly, so I can scroll without touching my mouse wheel.
10. As a user, I want to flick the scroll strip and have momentum carry through after I lift my finger, so scrolling feels like a phone.
11. As a user, I want a layout to disable the persistent right-side jogstrip (jogstrip: false), so layouts that define their own scroll widget can do so without conflict.

App-aware layout switching
12. As a user, I want the layout to automatically change when I switch focused applications on my desktop, so I always see controls relevant to what I'm working on.
13. As a user, I want a default layout to show when no app-specific layout is configured, so the surface is always functional.
14. As a user, I want layout files to match on app_id (Wayland-native) or wm_class (XWayland), so both native and XWayland apps are covered.

Chrome and navigation
15. As a user, I want to always see the name of the currently focused app on the surface, so I know which layout is active.
16. As a user, I want a connection indicator always visible, so I can tell immediately if the surface has lost connection.
17. As a user, I want a settings button always visible in the bottom chrome, so I can access settings without navigating through app layouts.
18. As a user, I want a trackpad mode button always visible in the bottom chrome, so I can switch to cursor control at any time regardless of which app is focused.

Trackpad mode
19. As a user, I want to drag my finger on the trackpad widget to move the desktop cursor, so I can control the pointer from my phone.
20. As a user, I want to tap once on the trackpad to left-click, so I can click without a separate button.
21. As a user, I want to two-finger tap to right-click, so I can access context menus.
22. As a user, I want tap-and-a-half (tap then immediately touch-and-drag) to drag, so I can drag items on screen.
23. As a user, I want an exit button on the trackpad view to return to my current app layout, so I can switch back to controls easily.

Orientation and layout
24. As a user, I want the surface to work in both portrait and landscape, so I can hold my phone either way.
25. As a user, I want the layout grid to scale proportionally to fill available screen space after chrome, so buttons use all the available area.

Connection lifecycle
26. As a user, I want the surface to show a "reconnecting…" overlay when the daemon disconnects, so I know it's trying to reconnect rather than silently stale.
27. As a user, I want the surface to reconnect automatically with exponential backoff, so I don't need to manually refresh after a daemon restart.
28. As a user, I want the surface to show a locked state when the desktop screensaver activates, so I know input is suppressed while the desktop is locked.
29. As a user, I want the surface to blank on desktop suspend and restore on wake, so the surface stays in sync with the desktop state.

Auth
30. As a user, I want the daemon to reject non-localhost connections without a valid password, so my control surface can't be used by others on the network.
31. As a user, I want the password to be generated automatically on first run and stored in ~/.config/deckd/password, so setup requires no manual key management.
32. As a user, I want to enter the password once on my phone and have it remembered, so reconnects don't ask again.

CLI
33. As a user, I want deckctl status to show daemon health, so I can verify it's running.
34. As a user, I want deckctl reload to reload layout files without restarting the daemon, so I can apply config changes live.
35. As a developer, I want deckctl layout <name> to force-switch all clients to a named layout, so I can test during development.

Note: the original spec proposed QR pairing (deckctl qr + a /qr endpoint). That was never implemented; password auth replaces it. See the Auth decision below and the README.

Packaging
37. As a NixOS user, I want a NixOS module that declaratively configures the udev rule, input group membership, and a systemd.user.service, so I can deploy deckd reproducibly.
38. As a user, I want the built client static files served by the daemon on the same port as the WebSocket, so I only run one process.

Implementation Decisions

Input injection

  • One combined UInput device handles all input: REL_X, REL_Y, REL_WHEEL, REL_WHEEL_HI_RES for pointer/scroll, plus EV_KEY keycodes for keystroke injection. No second device.
  • key: strings in YAML (e.g. "ctrl+t") map to physical evdev keycodes (KEY_LEFTCTRL + KEY_T). No keymap translation — see ADR-0001 for the Colemak/non-QWERTY revisit path.
  • Scroll momentum is daemon-side: client sends jog_end with velocity, daemon runs the decay loop — see ADR-0002.

Chrome (client-only)

  • The daemon has no knowledge of chrome. Bottom strip (app name, connection indicator, trackpad mode button, settings button) and right-side jogstrip are rendered unconditionally by the client.
  • The daemon includes a jogstrip_enabled: bool field (default true) in the LayoutMessage so layouts can suppress the persistent strip via jogstrip: false in YAML.
  • Grid coordinates are relative to the chrome-excluded area. The client computes cell sizes from available screen space after subtracting chrome.
  • See ADR-0003.

Orientation

  • The PWA manifest does not lock orientation. The grid scales proportionally in both portrait and landscape — same [x, y, w, h] coordinates, cells resize. Orientation-specific layout variants are a future extension — see ADR-0004.

Auto-ignore deckd window (dev UX)

  • When the GNOME focus backend reports focus moving to the window serving the deckd client (matched by the daemon's own port appearing in the window title or URL), the daemon holds the current layout without switching.
  • deckctl layout <name> POSTs to a new /layout/<name> endpoint to force-switch all connected clients to a named layout, bypassing focus detection entirely.

D-Bus action format

  • action.dbus: value is a string: "org.Interface.Method arg1 arg2". The daemon parses it, calls the method via dbus-fast. Errors are logged, not surfaced to the client.

Auth (password)

  • Password stored in ~/.config/deckd/password, generated (32 chars from [a-zA-Z0-9]) on first run if absent. Localhost connections (127.0.0.1, ::1) are exempt; non-localhost connections must send the password in the first WS frame (hello) or as the X-Deckd-Password header on HTTP. Rejections use {"type": "error", "reason": "unauthorized"} and a dedicated close code. The CLI accepts --password or $DECKD_PASSWORD. QR pairing was proposed in the original spec but never shipped; password auth is the only flow.

Screensaver / suspend

  • Daemon subscribes to org.gnome.ScreenSaver.ActiveChanged (session bus) and org.freedesktop.login1.Manager.PrepareForSleep (system bus) via dbus-fast.
  • On lock or sleep: push StateMessage(locked=True) to all clients.
  • On unlock or wake: push StateMessage(locked=False), then re-push the current LayoutMessage.

NixOS module

  • Production module at packaging/nixos/deckd.nix (replacing the spike module). Provides: services.udev.extraRules for /dev/uinput, users.users.<name>.extraGroups = ["input"], systemd.user.services.deckd pointing at the installed package.

Testing Decisions

What makes a good test: exercises observable daemon behavior at the WebSocket boundary using a fake WS client and fake platform sinks. Does not test internal module structure. A test should fail if the daemon sends the wrong message or takes the wrong action, but not if internal refactoring changes how it gets there.

Test infrastructure: extend scripts/smoke.py into a proper pytest suite under tests/. Use pytest-asyncio. Fake sinks already exist (FakeScrollSink); add FakePointerSink and FakeFocusBackend (an async iterator of AppInfo values driven by the test).

Modules under test (via WS boundary):

  • Layout push on connect: connect a fake client, assert daemon sends correct LayoutMessage
  • Focus-driven switching: drive FakeFocusBackend to emit an AppInfo, assert daemon pushes matching layout
  • Auto-ignore deckd window: emit AppInfo matching deckd's own port, assert layout does not change
  • Keystroke action: press a button with key: action, assert FakePointerSink records correct evdev keycodes
  • Shell action: press a button with shell: action, assert subprocess was invoked
  • Scroll momentum: send jog_end with velocity, assert FakeScrollSink receives a decaying sequence of deltas
  • Trackpad gestures: send pad_tap (1 finger), assert FakePointerSink records a BTN_LEFT click
  • Auth rejection: connect without password from a non-localhost address, assert connection is closed with error
  • Screensaver sync: simulate ScreenSaver.ActiveChanged(true) from FakeFocusBackend, assert StateMessage(locked=True) is pushed

Not tested: client React components (visual), GNOME Shell extension, NixOS module.

Out of Scope

  • Hardware ESP32 client
  • macOS platform backend
  • Dynamic widget state / MPRIS media control integration (see ADR-0005 for future extension notes)
  • Layout editor UI
  • Per-layout page navigation (deferred as an advanced feature)
  • Multiple simultaneous client sessions
  • Layout lock (ruled out — see grilling session notes)
  • Night Light theming
  • Horizontal jogstrip widget

Further Notes

  • iOS Safari PWA behavior (pointer capture, standalone mode, Add to Home Screen) should be manually verified during M4. Known quirks: pointer capture behavior and fullscreen PWA have historically differed from Chrome on iOS.
  • The key and dbus action primitives are currently stubbed in actions.py (log warnings only). M3 wires them up.
  • The trackpad widget kind exists in the Pydantic schema but has no server-side event handling. M6 adds it.
  • The existing packaging/nixos/deckd-spike.nix should be superseded by the production module in M7, not modified.
  • deckctl currently only has status and reload. M7 adds layout <name>. (QR pairing was dropped.)

Activity

  1. jonocodes commented on Jul 27, 2026

    @jonocodes
    OwnerAuthor

    Closing as completed. All M3–M7 work has shipped and the remaining open items are tracked as separate issues (#15 suspend/screensaver sync, #17 NixOS module). Spec body updated to drop the QR pairing material that never shipped (password auth replaced it) and to reflect the current password flow.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    milestoneMilestone work item (post-spike)ready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions