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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,50 @@
default:
@just --list

# Create the venv and install Python + JS deps. Idempotent.
# Run once. flox activate handles PATH; this sets up the venv itself.
setup:
# Per-platform setup recipes. `setup` auto-picks the right one; use the
# explicit recipe when you want to override (e.g. cross-checking on a CI box).

# Linux/GNOME dev: [dev,uinput] (evdev-binary is Linux-only).
setup-linux:
uv venv --python 3.12 --allow-existing
uv pip install -e ".[dev,uinput]"
cd client && npm install

# macOS dev: [dev] + [macos] (PyObjC Quartz covers scroll, pointer, click,
# and held-button drag for the trackpad).
setup-macos:
uv venv --python 3.12 --allow-existing
uv pip install -e ".[dev,macos]"
cd client && npm install

# Dispatch: picks setup-linux on Linux, setup-macos on macOS. flox users
# don't need this -- flox activate handles its own venv.
setup:
@if [ "$(uname)" = Darwin ]; then \
just setup-macos; \
else \
just setup-linux; \
fi

# Run the daemon against the layouts directory, serving the built client.
run-daemon:
.venv/bin/deckd --layouts-dir layouts --client-dist client/dist --verbose
deckd --layouts-dir layouts --client-dist client/dist --verbose

# Run the daemon on the LAN without a built client (use dev-client-lan for HMR).
run-daemon-lan:
.venv/bin/deckd --host 0.0.0.0 --layouts-dir layouts --verbose
deckd --host 0.0.0.0 --layouts-dir layouts --verbose

# Run the daemon under a supervisor that restarts it when daemon/**/*.py
# changes. Layout YAML hot-reload is built into the daemon itself; this is
# only useful when editing Python.
dev-daemon:
.venv/bin/deckd-dev --verbose
deckd-dev --verbose

# Same, but bind the daemon to all interfaces so a phone on the LAN
# (or Tailscale) can reach it. deckd-dev forwards unknown args to the
# child, so --host and --verbose end up on the deckd process.
dev-daemon-lan:
.venv/bin/deckd-dev --host 0.0.0.0 --verbose
deckd-dev --host 0.0.0.0 --verbose

# Vite dev server on the LAN. Vite proxies /ws and /health to the local
# daemon (see vite.config.ts), so the client is same-origin at :5173.
Expand Down Expand Up @@ -75,12 +93,12 @@ install-focus-extension:

# Print active app/window changes for Spike #2.
watch-focus:
.venv/bin/python -u scripts/watch_focus.py
python -u scripts/watch_focus.py

# Single snapshot of the active app/window.
watch-focus-once:
.venv/bin/python -u scripts/watch_focus.py --once
python -u scripts/watch_focus.py --once

# Hit /health.
status:
.venv/bin/deckctl status
deckctl status
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,38 @@ just run-daemon

Open `http://127.0.0.1:8765` in any browser. You should see the active layout's buttons filling the main area, an always-on jogstrip pinned to the right edge, and a chrome bottom strip with the app name, a connection status dot, and a `trackpad` button. Drag or flick vertically on the right-side jogstrip to emit `REL_WHEEL_HI_RES` deltas through uinput (log-only when uinput is unavailable). Tap the `trackpad` button to swap the button grid for a full-area trackpad surface — see the [Trackpad mode](#trackpad-mode) section for the gesture list.

### macOS

The daemon runs on macOS via `daemon/deckd/platform_macos.py` — focus + key injection work out of the box, and the jogstrip + trackpad need `pyobjc-framework-Quartz` (pulled in via the `[macos]` extra). The GNOME Shell focus extension is Linux-only.

Setup (no `uv`/uinput bits):

```sh
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 edits
```

`just 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).

### Phone/tablet testing

The phone must load the web client from a daemon address it can reach. Build the client, run the daemon on all interfaces, then open the desktop's LAN IP from the phone:
Expand Down Expand Up @@ -324,6 +356,12 @@ A directory of YAML files in `layouts/` — one per app, plus a `default.yaml` f
- `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 via `dbus-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 the `service:path` prefix omitted, the daemon derives them from the first two / three segments of the interface name.

### Per-platform overlay

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.

## What works today

- **Wire protocol** in both directions: `LayoutMessage` (with `jogstrip_enabled` + optional `error`) and `press` / `jog` / `jog_end` / `pad` / `pad_tap` / `pad_drag` events.
Expand Down
2 changes: 1 addition & 1 deletion client/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default defineConfig({
server: {
port: 5173,
// Match any tailnet hostname so `<host>.<tailnet>.ts.net` isn't rejected.
allowedHosts: ["lute", ".ts.net"],
allowedHosts: ["lute", "jonodot-1", ".ts.net"],
...(https ? { https } : {}),
// Same-origin proxy so the client never has to know the daemon's URL:
// /ws, /health, /reload all appear at the Vite origin and are forwarded
Expand Down
46 changes: 41 additions & 5 deletions daemon/deckd/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import asyncio
import logging
import signal
import sys
from pathlib import Path

from .input import LoggingKeySink, LoggingScrollSink, ScrollController, UinputSink
Expand All @@ -27,6 +28,17 @@ async def _run(server: Server) -> None:
await server.stop()


def _overlay_dir_for(layouts_dir: Path) -> Path:
"""Pick a per-platform overlay dir next to ``layouts_dir``.

Convention: ``<name>.linux`` or ``<name>.macos``. The path is
always returned; whether it actually exists is the caller's
concern (``Server`` treats a missing overlay as a no-op).
"""
suffix = {"darwin": "macos"}.get(sys.platform, "linux")
return layouts_dir.parent / f"{layouts_dir.name}.{suffix}"


def main() -> None:
parser = argparse.ArgumentParser(prog="deckd")
parser.add_argument("--host", default="127.0.0.1")
Expand All @@ -37,6 +49,11 @@ def main() -> None:
default=Path("layouts"),
help="Directory of per-app YAML layouts (one file per app + default.yaml)",
)
parser.add_argument(
"--no-overlay",
action="store_true",
help="Skip the per-platform overlay (layouts.<platform>) even if present",
)
parser.add_argument(
"--no-focus",
action="store_true",
Expand Down Expand Up @@ -72,17 +89,35 @@ def main() -> None:
)

try:
sink = UinputSink()
if sys.platform == "darwin":
from .platform_macos import MacKeySink, MacScrollSink

sink = MacKeySink()
scroll_sink = MacScrollSink()
key_sink = sink
else:
sink = UinputSink()
scroll_sink = sink
key_sink = sink
except Exception as exc:
logging.getLogger("deckd").warning(
"uinput unavailable; falling back to logging only: %s", exc
"platform sink unavailable; falling back to logging only: %s", exc
)
sink = None

scroll_sink = sink if sink is not None else LoggingScrollSink()
key_sink = sink if sink is not None else LoggingKeySink()
scroll_sink = LoggingScrollSink()
key_sink = LoggingKeySink()

focus_backend = None if args.no_focus else default_backend()
if focus_backend is not None:
logging.getLogger("deckd").info(
"focus backend: %s (sys.platform=%s)",
type(focus_backend).__name__,
sys.platform,
)

overlay_dir = None if args.no_overlay else _overlay_dir_for(args.layouts_dir)
if overlay_dir is not None and overlay_dir.is_dir():
logging.getLogger("deckd").info("loading layouts overlay from %s", overlay_dir)

def dbus_bus_factory(bus_type):
from dbus_fast.aio import MessageBus
Expand All @@ -101,6 +136,7 @@ def dbus_bus_factory(bus_type):
key_sink=key_sink,
dbus_bus_factory=dbus_bus_factory,
focus_backend=focus_backend,
overlay_dir=overlay_dir,
)

if args.client_dist is not None:
Expand Down
34 changes: 34 additions & 0 deletions daemon/deckd/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,40 @@ def parse_key_combo(key_string: str) -> list[int]:
return keycodes


def name_from_keycode(keycode: int) -> str | None:
"""Reverse of :data:`MODIFIER_MAP` + :data:`_SINGLE_KEY_MAP`.

Returns the canonical (lowercase) name for an evdev keycode, or ``None``
if the code isn't in either table. Used by backends (e.g. macOS) that
receive a keycode list and need to translate to their own event names.

When a code has multiple names (e.g. ``[`` and ``leftbrace`` both map to
26), the single-character form is preferred — macOS's ``keystroke``
translates printable literals directly, so ``"["`` round-trips through
AppleScript without needing an HID-code lookup.
"""
candidates = _NAMES_BY_KEYCODE.get(keycode)
if not candidates:
return None
for name in candidates:
if len(name) == 1:
return name
return candidates[0]


# Multiple names can share a keycode (``[`` and ``leftbrace`` both map
# to 26, ``super`` and ``meta`` to 125). Preserve them all in insertion
# order so callers can see every alias; ``name_from_keycode`` then
# prefers the single-char form because AppleScript's ``keystroke "<x>"``
# takes printable literals directly without an HID-code lookup.
_NAMES_BY_KEYCODE: dict[int, list[str]] = {}
for _name, _kc in (
*MODIFIER_MAP.items(),
*_SINGLE_KEY_MAP.items(),
):
_NAMES_BY_KEYCODE.setdefault(_kc, []).append(_name)


# ---------------------------------------------------------------------------
# Fallback / logging sinks
# ---------------------------------------------------------------------------
Expand Down
40 changes: 35 additions & 5 deletions daemon/deckd/layouts.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from __future__ import annotations

import logging
from pathlib import Path

import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError

from .platform import AppInfo

log = logging.getLogger("deckd.layouts")


class Widget(BaseModel):
model_config = ConfigDict(extra="forbid")
Expand Down Expand Up @@ -120,25 +123,52 @@ def resolve_layout(store: LayoutStore, app: AppInfo) -> Layout:
return store.default()


def load_layouts(layouts_dir: Path) -> LayoutStore:
"""Load every ``*.yaml`` / ``*.yml`` file in ``layouts_dir``.
def load_layouts(
layouts_dir: Path, overlay_dir: Path | None = None
) -> LayoutStore:
"""Load every ``*.yaml`` / ``*.yml`` file in ``layouts_dir`` plus an
optional platform overlay.

The overlay is loaded first; same-id base entries are then dropped.
Effect: if the overlay defines a layout with the same id as a base
layout (typically because both name their file ``<id>.yaml``), the
overlay wins. The overlay can also add layouts for apps the base
doesn't cover. A missing ``layouts_dir`` is fatal; a missing overlay
is fine (no overlay is the most common case).

A missing directory is a fatal startup error (matches the existing
single-file behavior: bad config = ``SystemExit``). Non-yaml files in
the directory are ignored.
Resolution semantics stay first-match-wins within the combined list,
so loading the overlay first means its entries shadow base entries
that match the same focused-app identity -- which is the intuitive
"platform overrides shared" semantic.
"""
if not layouts_dir.is_dir():
raise SystemExit(f"layouts directory not found: {layouts_dir}")

layouts: list[Layout] = []

if overlay_dir is not None and overlay_dir.is_dir():
for path in sorted(overlay_dir.glob("*.y*ml")):
if path.suffix not in {".yaml", ".yml"}:
continue
try:
layout = load_layout(path)
except SystemExit as exc:
raise SystemExit(f"{exc}") from None
layouts.append(layout)

overlay_ids = {l.id for l in layouts if l.id}
for path in sorted(layouts_dir.glob("*.y*ml")):
if path.suffix not in {".yaml", ".yml"}:
continue
try:
layout = load_layout(path)
except SystemExit as exc:
raise SystemExit(f"{exc}") from None
if layout.id and layout.id in overlay_ids:
log.info("layout %r overridden by overlay %s", layout.id, path)
continue
layouts.append(layout)

return LayoutStore(layouts)


Expand Down
5 changes: 5 additions & 0 deletions daemon/deckd/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import ast
import json
import os
import sys
from collections.abc import AsyncIterator
from dataclasses import dataclass

Expand Down Expand Up @@ -70,6 +71,10 @@ async def get_active_app(self) -> AppInfo:


def default_backend() -> PlatformBackend:
if sys.platform == "darwin":
from .platform_macos import MacFocusBackend

return MacFocusBackend()
if os.environ.get("XDG_SESSION_TYPE") == "x11":
return X11FocusBackend()
return GnomeShellFocusBackend()
Expand Down
Loading
Loading