From 232db944c0390f3cee8475db8b3d173cba5cb715 Mon Sep 17 00:00:00 2001 From: Jono Date: Fri, 17 Jul 2026 13:42:14 -0700 Subject: [PATCH 1/9] macOS platform support Run deckd on macOS via a new platform_macos backend (osascript + cliclick + PyObjC Quartz). Linux uinput path is untouched. daemon/deckd/platform_macos.py: * MacFocusBackend - frontmost app + window title via System Events. app_id is the process name (firefox, Safari, Terminal, iTerm2, ...). * MacKeySink - osascript keystroke / key code. Printable chars round- trip as keystroke ''; non-printable use the HID map. Modifiers translate ctrl->control, alt->option, super/meta->command. * MacScrollSink - Quartz.CGEventCreateScrollWheelEvent at one detent per 120 REL_WHEEL_HI_RES (matches the UinputSink ratio). Requires pyobjc-framework-Quartz; falls back to log-only if absent. * Pointer + click via cliclick when on PATH (brew install cliclick). daemon/deckd/{__main__,platform,server,layouts}.py: * default_backend() returns MacFocusBackend on darwin. * __main__.py builds MacKeySink/MacScrollSink on darwin before the logging-only fallback; logs which backend is in use. * LayoutStore gains an optional overlay_dir: load_layouts(base, overlay) loads overlay first and drops base entries with matching ids, so layouts.macos/ can shadow layouts/ per-id without forking the base. __main__.py auto-discovers layouts. next to --layouts-dir (--no-overlay to skip). The layouts watcher watches both dirs. * Server._is_deckd_window requires exact title match on 'deckd' to avoid auto-ignore firing on tabs whose titles merely contain 'deckd' as a substring. * input.name_from_keycode() prefers the single-char alias when a code has multiple names (leftbrace/[ both map to 26; now returns '['). pyproject.toml: * New [macos] extra pulling pyobjc-framework-Quartz>=10.0. * New name_from_keycode() helper exported from the daemon. Justfile: * setup-macos: brew install cliclick + uv pip install -e '.[dev,macos]'. * setup: platform-branch to setup-macos on darwin, setup-linux else. layouts.macos/: * firefox.yaml - super+t / super+w / super+f / super+n / super+r / super+l / super+[ / super+] (Mac-conventional shortcuts). * default.yaml - generic 4x2 fallback (new tab, close, find, new window, open URL, iTerm, Terminal, System Settings). scripts/check_focus_macos.py: * One-shot diagnostic: osascript focus query, auto-ignore verdict, and which layout would resolve. Use it to debug 'layout stuck on X' reports - tells you whether the daemon, TCC, or the client is at fault. README: * macOS section: setup steps, capability matrix, layout overlay convention, TCC heads-up. * Configuration: per-platform overlay paragraph. --- Justfile | 46 +++-- README.md | 35 ++++ daemon/deckd/__main__.py | 46 ++++- daemon/deckd/input.py | 28 ++++ daemon/deckd/layouts.py | 40 ++++- daemon/deckd/platform.py | 5 + daemon/deckd/platform_macos.py | 297 +++++++++++++++++++++++++++++++++ daemon/deckd/server.py | 31 ++-- layouts.macos/default.yaml | 66 ++++++++ layouts.macos/firefox.yaml | 65 ++++++++ pyproject.toml | 5 + scripts/check_focus_macos.py | 70 ++++++++ tests/test_layouts.py | 110 ++++++++++++ 13 files changed, 814 insertions(+), 30 deletions(-) create mode 100644 daemon/deckd/platform_macos.py create mode 100644 layouts.macos/default.yaml create mode 100644 layouts.macos/firefox.yaml create mode 100644 scripts/check_focus_macos.py diff --git a/Justfile b/Justfile index 22956fd..a8ed62f 100644 --- a/Justfile +++ b/Justfile @@ -4,32 +4,58 @@ 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] only + cliclick via Homebrew for the trackpad + +# PyObjC Quartz for synthetic wheel events on the jogstrip. +setup-macos: + uv venv --python 3.12 --allow-existing + uv pip install -e ".[dev,macos]" + cd client && npm install + @if command -v cliclick >/dev/null 2>&1; then \ + echo "cliclick already installed: $(command -v cliclick)"; \ + elif command -v brew >/dev/null 2>&1; then \ + echo "Installing cliclick via Homebrew..."; \ + brew install cliclick; \ + else \ + echo "Homebrew not found; install cliclick manually from https://github.com/BlueM/cliclick" >&2; exit 1; \ + fi + +# 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. @@ -75,12 +101,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 diff --git a/README.md b/README.md index f5ed207..1a75d2f 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,35 @@ 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, mouse clicks + relative pointer motion need `cliclick`, and the jogstrip needs `pyobjc-framework-Quartz`. 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 + # (setup-macos also runs `brew install cliclick`) +just build-client +just run-daemon # listens on http://127.0.0.1:8765 +``` + +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 | only with `cliclick` (`brew install cliclick`) | +| jogstrip scroll | yes (PyObjC Quartz `CGEventCreateScrollWheelEvent` — pulled in via the `[macos]` extra) | + ### 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: @@ -324,6 +353,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. diff --git a/daemon/deckd/__main__.py b/daemon/deckd/__main__.py index f04d942..30bc790 100644 --- a/daemon/deckd/__main__.py +++ b/daemon/deckd/__main__.py @@ -4,6 +4,7 @@ import asyncio import logging import signal +import sys from pathlib import Path from .input import LoggingKeySink, LoggingScrollSink, ScrollController, UinputSink @@ -27,6 +28,17 @@ async def _run(server: Server) -> None: await server.stop() +def _overlay_dir_for(layouts_dir: Path) -> Path | None: + """Pick a per-platform overlay dir next to ``layouts_dir``. + + Convention: ``.linux`` or ``.macos``. Missing directories + are fine — the most common case (Linux dev, no overlay yet) just + returns None and the daemon loads only the base. + """ + 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") @@ -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.) even if present", + ) parser.add_argument( "--no-focus", action="store_true", @@ -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 @@ -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: diff --git a/daemon/deckd/input.py b/daemon/deckd/input.py index a7656ca..b1ea2ec 100644 --- a/daemon/deckd/input.py +++ b/daemon/deckd/input.py @@ -127,6 +127,34 @@ 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] + + +_NAMES_BY_KEYCODE: dict[int, list[str]] = {} +for _k, _v in MODIFIER_MAP.items(): + _NAMES_BY_KEYCODE.setdefault(_v, []).append(_k) +for _k, _v in _SINGLE_KEY_MAP.items(): + _NAMES_BY_KEYCODE.setdefault(_v, []).append(_k) + + # --------------------------------------------------------------------------- # Fallback / logging sinks # --------------------------------------------------------------------------- diff --git a/daemon/deckd/layouts.py b/daemon/deckd/layouts.py index 1e7e996..9414ce4 100644 --- a/daemon/deckd/layouts.py +++ b/daemon/deckd/layouts.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path import yaml @@ -7,6 +8,8 @@ from .platform import AppInfo +log = logging.getLogger("deckd.layouts") + class Widget(BaseModel): model_config = ConfigDict(extra="forbid") @@ -120,17 +123,40 @@ 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 ``.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 @@ -138,7 +164,11 @@ def load_layouts(layouts_dir: Path) -> LayoutStore: 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) diff --git a/daemon/deckd/platform.py b/daemon/deckd/platform.py index 508f39c..bdce0cf 100644 --- a/daemon/deckd/platform.py +++ b/daemon/deckd/platform.py @@ -4,6 +4,7 @@ import ast import json import os +import sys from collections.abc import AsyncIterator from dataclasses import dataclass @@ -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() diff --git a/daemon/deckd/platform_macos.py b/daemon/deckd/platform_macos.py new file mode 100644 index 0000000..bf8577f --- /dev/null +++ b/daemon/deckd/platform_macos.py @@ -0,0 +1,297 @@ +"""macOS backend: focus detection + key/pointer/scroll sinks. + +Cheap shim over AppleScript (``osascript``) and, when present, ``cliclick``. +No new native deps, no kernel extensions. TCC will prompt the first time +osascript asks System Events to do something on the daemon's behalf. + +Capability matrix (sketch): + + +-----------------------------+--------+------------------------------+ + | capability | works? | how | + +-----------------------------+--------+------------------------------+ + | focus detection | yes | osascript + System Events | + | key injection (printable) | yes | osascript ``keystroke`` | + | key injection (non-print) | partial| osascript ``key code`` (map) | + | combo modifiers | yes | ``using {command down}`` | + | mouse click (left/right) | yes | cliclick ``c:``/``rc:`` | + | mouse relative motion | yes | cliclick ``dx:``/``dy:`` | + | high-res wheel scroll | yes | PyObjC ``Quartz.CGEvent- | + | | | CreateScrollWheelEvent`` | + +-----------------------------+--------+------------------------------+ + +The Linux ``UinputSink`` covers the same wire protocol but emits Linux evdev +events. This module emits nothing locally -- it shells out to ``osascript`` +and ``cliclick`` so the events reach real apps. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +import sys +from collections.abc import Sequence + +from .input import MODIFIER_MAP, KeySink, ScrollSink, name_from_keycode +from .platform import AppInfo, PlatformBackend, _run + +log = logging.getLogger("deckd.platform_macos") + +# --------------------------------------------------------------------------- +# Focus +# --------------------------------------------------------------------------- + +# AppleScript returns three pipe-delimited fields. Pipe is rare enough in +# process names / window titles that this is fine as a sketch. +_FOCUS_SCRIPT = """ +tell application "System Events" + set frontProc to first process whose frontmost is true + set procName to name of frontProc + try + set winTitle to name of front window of frontProc + on error + set winTitle to "" + end try + return procName & "|||" & winTitle +end tell +""" + + +class MacFocusBackend(PlatformBackend): + """Read the frontmost app's process name + front window title via + System Events. ``app_id`` is the process name; ``wm_class`` is left + None (the Mac concept doesn't map 1:1). + """ + + async def get_active_app(self) -> AppInfo: + out = await _run("osascript", "-e", _FOCUS_SCRIPT) + proc_name, _, title = out.partition("|||") + return AppInfo( + app_id=proc_name.strip() or None, + wm_class=None, + title=title.strip() or None, + ) + + +# --------------------------------------------------------------------------- +# Key sink (osascript keystroke / key code) +# --------------------------------------------------------------------------- + +# AppleScript modifier clauses. ``super``/``meta`` map to Command (the Mac +# "super" key); ``alt`` maps to Option; ``ctrl`` maps to Control. +_MOD_CLAUSE: dict[str, str] = { + "ctrl": "control", + "shift": "shift", + "alt": "option", + "super": "command", + "meta": "command", +} + +# AppleScript ``key code`` numbers (HID usage IDs) for the non-printable +# keys we care about. Letters and digits are sent as ``keystroke "x"`` +# instead -- easier and locale-correct. +_MAC_KEY_CODE: dict[str, int] = { + "esc": 53, "escape": 53, + "tab": 48, + "enter": 36, "return": 36, + "space": 49, + "backspace": 51, + "delete": 117, "del": 117, + "home": 115, + "end": 119, + "pageup": 116, "pgup": 116, + "pagedown": 121, "pgdn": 121, + "up": 126, + "down": 125, + "left": 123, + "right": 124, + "f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97, + "f7": 98, "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111, + "capslock": 57, +} + + +def _build_keystroke_script(keycodes: Sequence[int]) -> str | None: + """Translate an evdev keycode list into a single AppleScript line. + + Returns ``None`` when the translation can't be expressed (e.g. an + unknown keycode); the caller should log and skip. + """ + if not keycodes: + return None + + mods: list[str] = [] + main_name: str | None = None + for kc in keycodes: + name = name_from_keycode(kc) + if name is None: + log.warning("[mac key] unknown evdev keycode %s", kc) + return None + if name in MODIFIER_MAP: + clause = _MOD_CLAUSE.get(name) + if clause is None: + return None + mods.append(clause) + else: + # ``parse_key_combo`` always puts the main key last, so the last + # non-modifier keycode wins. + main_name = name + + if main_name is None: + log.warning("[mac key] no main key in combo %s", keycodes) + return None + + mod_clause = "" + if mods: + mod_clause = " using {" + ", ".join(f"{m} down" for m in mods) + "}" + + # Printable single-char -> ``keystroke "x"``; everything else -> ``key code N``. + if len(main_name) == 1 and main_name.isprintable() and main_name not in _MAC_KEY_CODE: + return f'tell application "System Events" to keystroke "{main_name}"{mod_clause}' + + mac_code = _MAC_KEY_CODE.get(main_name) + if mac_code is None: + log.warning("[mac key] no macOS mapping for key %r", main_name) + return None + return f'tell application "System Events" to key code {mac_code}{mod_clause}' + + +class MacKeySink(KeySink): + """Emit keys via osascript. Pointer + clicks delegate to ``cliclick`` + when it's on PATH, log a warning otherwise. + """ + + def __init__(self) -> None: + self._cliclick = shutil.which("cliclick") + if self._cliclick is None: + log.warning( + "[mac key] cliclick not found on PATH; " + "trackpad pointer + clicks will log only " + "(install with: brew install cliclick)" + ) + self._warned_no_cliclick = False + + # -- key ----------------------------------------------------------------- + + def emit_key(self, keycodes: list[int]) -> None: + script = _build_keystroke_script(keycodes) + if script is None: + return + # Fire and forget: osascript is too slow to block the event loop on + # the first call (TCC prompt can take seconds). The key reaches the + # focused app a few ms later; the wire protocol doesn't await. + subprocess.Popen( + ["osascript", "-e", script], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + log.debug("[mac key] %s", script) + + # -- pointer / click (cliclick) ----------------------------------------- + + def emit_pointer(self, dx: int, dy: int) -> None: + if self._cliclick is None: + if not self._warned_no_cliclick: + log.warning("[mac pointer] skipping dx=%s dy=%s (no cliclick)", dx, dy) + self._warned_no_cliclick = True + return + subprocess.Popen( + [self._cliclick, f"dx:{dx}", f"dy:{dy}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + log.debug("[mac pointer] dx=%s dy=%s", dx, dy) + + def emit_click(self, button: str, pressed: bool) -> None: + # Only emit on press -- the matching release would just re-click. + if not pressed: + return + if self._cliclick is None: + if not self._warned_no_cliclick: + log.warning("[mac click] skipping button=%s (no cliclick)", button) + self._warned_no_cliclick = True + return + verb = "rc:." if button == "right" else "c:." + subprocess.Popen( + [self._cliclick, verb], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + log.debug("[mac click] button=%s", button) + + def close(self) -> None: + pass + + +# --------------------------------------------------------------------------- +# Scroll sink +# --------------------------------------------------------------------------- + + +class MacScrollSink(ScrollSink): + """Synthetic-wheel sink via PyObjC + Quartz. + + macOS doesn't expose wheel injection through AppleScript or any + open-source tool I trust to keep working (cliclick, Hammerspoon + aside). Quartz's ``CGEventCreateScrollWheelEvent`` is the only path + that reaches the focused window's scroll view the same way a real + trackpad or mouse wheel does. Requires ``pyobjc-framework-Quartz``; + the sink falls back to log-only if it's not importable. + + The wire protocol emits ``REL_WHEEL_HI_RES`` deltas (1/120 of a + wheel detent). We accumulate them and emit one ``kCGScrollEventUnitLine`` + event per detent, matching the Linux ``UinputSink`` behaviour. + """ + + # 1 detent (line) == 120 REL_WHEEL_HI_RES units, the same ratio Linux uses. + DETENT = 120 + + def __init__(self) -> None: + try: + import Quartz # type: ignore + + self._Quartz = Quartz + self._available = True + log.info("[mac scroll] Quartz loaded; wheel events will be injected") + except ImportError: + self._Quartz = None + self._available = False + log.warning( + "[mac scroll] PyObjC Quartz not available; " + "jogstrip will log only " + "(install with: pip install pyobjc-framework-Quartz)" + ) + self._wheel_remainder = 0 + + def emit_scroll(self, delta: int) -> None: + if delta == 0: + return + if not self._available: + log.info("[mac scroll log] delta=%s (no Quartz)", delta) + return + self._wheel_remainder += delta + detents = self._wheel_remainder // self.DETENT + if detents == 0: + return + self._wheel_remainder -= int(detents) * self.DETENT + Q = self._Quartz + event = Q.CGEventCreateScrollWheelEvent( + None, + Q.kCGScrollEventUnitLine, + 1, # one wheel axis (vertical) + int(detents), + ) + Q.CGEventPost(Q.kCGHIDEventTap, event) + log.debug("[mac scroll] line events=%s (delta=%s)", int(detents), delta) + + def close(self) -> None: + pass + + +# --------------------------------------------------------------------------- +# Re-export so __main__.py can ask ``platform_macos.is_macos()`` cleanly. +# --------------------------------------------------------------------------- + + +def is_macos() -> bool: + return sys.platform == "darwin" diff --git a/daemon/deckd/server.py b/daemon/deckd/server.py index 6762b66..22855ce 100644 --- a/daemon/deckd/server.py +++ b/daemon/deckd/server.py @@ -110,14 +110,16 @@ def __init__( key_sink: "KeySink | None" = None, dbus_bus_factory: "Callable[[BusTypeT], MessageBus] | None" = None, focus_backend: "PlatformBackend | None" = None, + overlay_dir: Path | None = None, ) -> None: self.layouts_dir = layouts_dir + self.overlay_dir = overlay_dir self.host = host self.port = port self.app = web.Application() self._setup_routes() self._sessions: set[Session] = set() - self.layouts: LayoutStore = load_layouts(layouts_dir) + self.layouts: LayoutStore = load_layouts(layouts_dir, overlay_dir) self._current_app_id: str = DEFAULT_APP_ID self._current_layout: Layout = self.layouts.default() self.scroll = scroll if scroll is not None else ScrollController() @@ -143,7 +145,7 @@ def current_error(self) -> str | None: return self._current_error def reload_layouts(self) -> None: - """Re-read every layout YAML in ``layouts_dir``. + """Re-read every layout YAML in ``layouts_dir`` (and overlay_dir). On success: rebuild the store, keep the current app_id if it still resolves, else fall back to default, and clear any prior error. @@ -154,7 +156,7 @@ def reload_layouts(self) -> None: the grid. Callers should not have to catch anything. """ try: - new_store = load_layouts(self.layouts_dir) + new_store = load_layouts(self.layouts_dir, self.overlay_dir) except SystemExit as exc: self._current_error = str(exc) log.error("layout reload failed (keeping last-good): %s", exc) @@ -167,7 +169,11 @@ def reload_layouts(self) -> None: new_layout = self.layouts.default() self._current_layout = new_layout self._current_error = None - log.info("reloaded layouts from %s", self.layouts_dir) + log.info( + "reloaded layouts from %s%s", + self.layouts_dir, + f" + {self.overlay_dir}" if self.overlay_dir else "", + ) async def _push_to_all(self) -> None: """Push the current layout to every live session. @@ -193,14 +199,16 @@ def _is_deckd_window(self, app: "AppInfo") -> bool: The defining signal is the daemon's own port appearing in the focused window's title (the deckd client is served at that port, so browsers that surface the URL in the title reveal it). The - page-title fallback ("deckd") covers browsers whose window title - is only the page's ````. + page-title fallback is an exact match on the client's ``<title>`` + (``"deckd"``), which avoids false positives on any tab whose + title merely contains "deckd" as a substring (e.g. a GitHub tab + for the deckd repo). """ - title = app.title or "" + title = (app.title or "").strip() port = self.port if port and port > 0 and str(port) in title: return True - return "deckd" in title.lower() + return title.lower() == "deckd" async def _on_focus(self, app: "AppInfo") -> None: if self._is_deckd_window(app): @@ -251,7 +259,7 @@ def start_focus_watcher(self) -> asyncio.Task[None] | None: async def run_layouts_watcher(self) -> None: """Long-running task: reload layouts when a YAML file in the layouts - directory is created, edited, or removed. + directory (or its platform overlay) is created, edited, or removed. Layouts are user configuration, not just a dev-only artifact — watching them is on by default so a user can iterate on their YAML @@ -264,8 +272,11 @@ async def run_layouts_watcher(self) -> None: except ImportError: log.warning("watchfiles not installed; layouts hot-reload disabled") return + watch_paths = [self.layouts_dir] + if self.overlay_dir is not None and self.overlay_dir.is_dir(): + watch_paths.append(self.overlay_dir) yaml_suffixes = {".yaml", ".yml"} - async for changes in awatch(self.layouts_dir): + async for changes in awatch(*watch_paths): if not any(Path(p).suffix in yaml_suffixes for _, p in changes): continue log.info("layouts dir changed -> reload") diff --git a/layouts.macos/default.yaml b/layouts.macos/default.yaml new file mode 100644 index 0000000..46817ce --- /dev/null +++ b/layouts.macos/default.yaml @@ -0,0 +1,66 @@ +# Mac fallback layout. Used when no per-app layout matches. Same `id` as +# `layouts/default.yaml` so the overlay-replaces-base loader picks this +# one on Mac without touching the shared base. +# +# Designed for "works for max apps": the buttons below are useful in a +# long tail of Mac apps (browsers, terminals, editors, chat clients, etc.) +# and the launchers work regardless of which app is focused. +match: + - default +widgets: + - id: new-tab + kind: button + label: New tab + icon: plus + grid: [0, 0, 1, 1] + action: + key: "super+t" + - id: close-tab + kind: button + label: Close + icon: close + grid: [1, 0, 1, 1] + action: + key: "super+w" + - id: find + kind: button + label: Find + icon: search + grid: [2, 0, 1, 1] + action: + key: "super+f" + - id: new-window + kind: button + label: New window + icon: window + grid: [3, 0, 1, 1] + action: + key: "super+n" + - id: open-url + kind: button + label: Open example.com + icon: globe + grid: [0, 1, 1, 1] + action: + shell: "open https://example.com" + - id: launch-iterm + kind: button + label: iTerm + icon: terminal + grid: [1, 1, 1, 1] + action: + shell: "open -a iTerm" + - id: launch-terminal + kind: button + label: Terminal + icon: terminal + grid: [2, 1, 1, 1] + action: + shell: "open -a Terminal" + - id: system-settings + kind: button + label: Settings + icon: cog + grid: [3, 1, 1, 1] + action: + shell: "open -a \"System Settings\"" diff --git a/layouts.macos/firefox.yaml b/layouts.macos/firefox.yaml new file mode 100644 index 0000000..862f63c --- /dev/null +++ b/layouts.macos/firefox.yaml @@ -0,0 +1,65 @@ +# Firefox on macOS. Same match token as the Linux firefox.yaml (the Mac +# process is reported as `firefox`), so this file sorts first (the `-` is +# ASCII 0x2D, before `.`) and wins on the Mac. On a Linux checkout delete +# this file or restructure into per-platform layouts dirs. +match: + - firefox +widgets: + - id: new-tab + kind: button + label: New tab + icon: plus + grid: [0, 0, 1, 1] + action: + key: "super+t" + - id: new-window + kind: button + label: New window + icon: window + grid: [1, 0, 1, 1] + action: + key: "super+n" + - id: back + kind: button + label: Back + icon: arrow-left + color: "#1e3a8a" + grid: [2, 0, 1, 1] + action: + key: "super+[" + - id: forward + kind: button + label: Forward + icon: arrow-right + color: "#1e3a8a" + grid: [3, 0, 1, 1] + action: + key: "super+]" + - id: reload + kind: button + label: Reload + icon: refresh + grid: [0, 1, 1, 1] + action: + key: "super+r" + - id: focus-url + kind: button + label: URL bar + icon: link + grid: [1, 1, 1, 1] + action: + key: "super+l" + - id: find + kind: button + label: Find + icon: search + grid: [2, 1, 1, 1] + action: + key: "super+f" + - id: close-tab + kind: button + label: Close tab + icon: close + grid: [3, 1, 1, 1] + action: + key: "super+w" diff --git a/pyproject.toml b/pyproject.toml index 3e01929..059a6b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,11 @@ dev = [ "pytest>=8", "pytest-asyncio>=0.23", ] +macos = [ + # PyObjC Quartz for synthetic wheel-event injection via CGEventCreateScrollWheelEvent. + # Needed on macOS for the jogstrip to actually scroll the focused window. + "pyobjc-framework-Quartz>=10.0", +] [project.scripts] deckd = "deckd.__main__:main" diff --git a/scripts/check_focus_macos.py b/scripts/check_focus_macos.py new file mode 100644 index 0000000..38be162 --- /dev/null +++ b/scripts/check_focus_macos.py @@ -0,0 +1,70 @@ +"""One-shot diagnostic for the macOS focus watcher. + +Prints: + - what osascript reports for the frontmost process (and its window title) + - whether the auto-ignore rule says "this is the deckd client" + - which layout ``resolve_layout`` would pick for that focused app + +Useful when the daemon's layout doesn't switch as expected: it answers +"is osascript failing?", "is auto-ignore wrongly holding?", and +"is there no matching layout?" without having to read daemon logs. + +Requires the flox environment (or any venv with ``pip install -e .``). +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + + +async def _probe() -> None: + from deckd.layouts import LayoutStore, load_layouts, resolve_layout + from deckd.platform import AppInfo + from deckd.platform_macos import MacFocusBackend + + base = Path("layouts") + overlay = Path("layouts.macos") if Path("layouts.macos").is_dir() else None + + backend = MacFocusBackend() + try: + app = await backend.get_active_app() + except Exception as exc: + print(f"ERROR: focus query failed ({exc.__class__.__name__}): {exc}") + print("On macOS this is almost always a TCC permission issue --") + print("grant Automation permission to the parent shell (Terminal / iTerm / cmux)") + print("under System Settings -> Privacy & Security -> Automation, then re-run.") + sys.exit(2) + + print(f"osascript sees: app_id={app.app_id!r} title={app.title!r}") + + store: LayoutStore = load_layouts(base, overlay) + resolved = resolve_layout(store, app) + + # Reproduce the auto-ignore check exactly as Server._is_deckd_window does. + title = (app.title or "").strip() + port = 8765 + port_match = bool(port) and str(port) in title + exact_match = title.lower() == "deckd" + auto_ignore = port_match or exact_match + print(f"auto-ignore: port_in_title={port_match} exact_title_match={exact_match} -> holds={auto_ignore}") + print(f"would resolve to layout: {resolved.id!r}") + + if resolved.id in {l.id for l in store.layouts if l.id == "default"}: + # Add a hint if the user's app_id is something we'd expect to see + # a layout for but don't. + if app.app_id and app.app_id.lower() in {"firefox", "safari", "chrome"}: + print(f"hint: {app.app_id!r} has no specific layout in this checkout,") + print(" so the default fallback is correct.") + + +def main() -> None: + try: + asyncio.run(_probe()) + except KeyboardInterrupt: + sys.exit(130) + + +if __name__ == "__main__": + main() diff --git a/tests/test_layouts.py b/tests/test_layouts.py index 561f44e..e894fa2 100644 --- a/tests/test_layouts.py +++ b/tests/test_layouts.py @@ -297,3 +297,113 @@ def test_layout_with_jogstrip_false_parses(tmp_path: Path) -> None: _write(tmp_path, "default.yaml", body) store = load_layouts(tmp_path) assert store["default"].jogstrip is False + + +# --------------------------------------------------------------------------- +# Platform overlay +# +# The daemon accepts an optional ``overlay_dir`` next to the base layouts +# dir. Overlay entries load first so they shadow base entries with the +# same id; this is the "platform overrides shared" semantic. +# --------------------------------------------------------------------------- + + +def test_overlay_dir_is_optional(tmp_path: Path) -> None: + _write(tmp_path, "default.yaml", DEFAULT_LAYOUT) + # No overlay_dir arg at all -> unchanged single-dir behavior. + store = load_layouts(tmp_path) + assert "default" in store + + +def test_missing_overlay_dir_is_fine(tmp_path: Path) -> None: + _write(tmp_path, "default.yaml", DEFAULT_LAYOUT) + overlay = tmp_path / "does-not-exist" + store = load_layouts(tmp_path, overlay) + assert "default" in store + + +def test_overlay_replaces_same_id_base_entry(tmp_path: Path) -> None: + base = tmp_path / "base" + overlay = tmp_path / "overlay" + base.mkdir() + overlay.mkdir() + _write(base, "firefox.yaml", FIREFOX_LAYOUT) # id=firefox, key=alt+Left + _write( + overlay, + "firefox.yaml", + """ +match: + - firefox +widgets: + - id: new-tab + kind: button + label: New tab + grid: [0, 0, 1, 1] + action: + key: "super+t" +""", + ) + _write(base, "default.yaml", DEFAULT_LAYOUT) + + store = load_layouts(base, overlay) + firefox_layouts = [l for l in store.layouts if l.id == "firefox"] + # Overlay replaces base entirely -- no duplicate id left in the store. + assert len(firefox_layouts) == 1 + # And it's the overlay's action that survives. + assert firefox_layouts[0].widgets[0].action.key == "super+t" + + +def test_overlay_wins_on_match_conflict_with_different_filename(tmp_path: Path) -> None: + """When an overlay entry matches the same app as a base entry but has + a different filename (so different ``id``), first-match-wins within + the combined list resolves it: overlay entries load first, so the + overlay wins.""" + base = tmp_path / "base" + overlay = tmp_path / "overlay" + base.mkdir() + overlay.mkdir() + _write(base, "firefox.yaml", FIREFOX_LAYOUT) # id=firefox, match=[firefox] + _write( + overlay, + "macos-firefox.yaml", + """ +match: + - firefox +widgets: + - id: new-tab + kind: button + label: New tab + grid: [0, 0, 1, 1] + action: + key: "super+t" +""", + ) + + store = load_layouts(base, overlay) + app = AppInfo(app_id="firefox", wm_class="firefox") + layout = resolve_layout(store, app) + assert layout.widgets[0].action.key == "super+t" + + +def test_overlay_can_add_new_layouts(tmp_path: Path) -> None: + """An overlay file for an app the base doesn't cover is additive.""" + base = tmp_path / "base" + overlay = tmp_path / "overlay" + base.mkdir() + overlay.mkdir() + _write(base, "default.yaml", DEFAULT_LAYOUT) + _write(overlay, "safari.yaml", """ +match: + - Safari +widgets: + - id: new-tab + kind: button + label: New tab + grid: [0, 0, 1, 1] + action: + key: "super+t" +""") + + store = load_layouts(base, overlay) + assert "Safari" in store + assert "default" in store From f2b79bc8871baa0a7fb70d8d6ef117dd5e348281 Mon Sep 17 00:00:00 2001 From: Jono <jono@foodnotblogs.com> Date: Fri, 17 Jul 2026 13:44:51 -0700 Subject: [PATCH 2/9] add mac to hosts --- client/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/vite.config.ts b/client/vite.config.ts index 0ffdc34..46e5bf1 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ server: { port: 5173, // Match any tailnet hostname so `<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 From b867dd1879486107b0eaf4038d2be45c786c7451 Mon Sep 17 00:00:00 2001 From: Jono <jono@foodnotblogs.com> Date: Fri, 17 Jul 2026 13:49:57 -0700 Subject: [PATCH 3/9] macOS trackpad: use Quartz for pointer / click / drag The trackpad's tap-and-a-half gesture needs a held-button drag -- cliclick only knows 'click' (down+up in one shot) and 'move', which can't model 'press, move with button held, then release'. Switched the pointer + click half of MacKeySink to PyObjC Quartz CGEvent and dropped cliclick as a runtime dependency. daemon/deckd/platform_macos.py (MacKeySink): * Load Quartz lazily (same dep the scroll sink already uses). * emit_pointer emits CGEventMouseMoved when no button is held, CGEventLeftMouseDragged while a drag is in progress, with deltaX / deltaY fields set from the wire's dx / dy. * emit_click emits Left/Right MouseDown / MouseUp. The server's pad_tap sequence (True then False) is a clean click; pad_drag start fires MouseDown alone, pad_drag end fires MouseUp alone. Mid-drag pointer events therefore carry the held-button flag through the Quartz event stream, which is what makes native selections / window drags feel right. * _dragging_left tracks the held state across the async event loop. * Removed shutil.which('cliclick') check + cliclick subprocess paths. * Updated docstring capability matrix. Justfile: * setup-macos: dropped brew install cliclick -- Quartz covers everything the trackpad and jogstrip need. README: * macOS section: cliclick is no longer a runtime dependency. * Capability matrix: trackpad pointer / clicks row reads 'yes (PyObjC Quartz)' instead of 'only with cliclick'. --- Justfile | 12 +--- README.md | 5 +- daemon/deckd/platform_macos.py | 100 +++++++++++++++++++++------------ 3 files changed, 68 insertions(+), 49 deletions(-) diff --git a/Justfile b/Justfile index a8ed62f..dff9449 100644 --- a/Justfile +++ b/Justfile @@ -13,20 +13,12 @@ setup-linux: uv pip install -e ".[dev,uinput]" cd client && npm install -# macOS dev: [dev] only + cliclick via Homebrew for the trackpad + -# PyObjC Quartz for synthetic wheel events on the jogstrip. +# 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 - @if command -v cliclick >/dev/null 2>&1; then \ - echo "cliclick already installed: $(command -v cliclick)"; \ - elif command -v brew >/dev/null 2>&1; then \ - echo "Installing cliclick via Homebrew..."; \ - brew install cliclick; \ - else \ - echo "Homebrew not found; install cliclick manually from https://github.com/BlueM/cliclick" >&2; exit 1; \ - fi # Dispatch: picks setup-linux on Linux, setup-macos on macOS. flox users # don't need this -- flox activate handles its own venv. diff --git a/README.md b/README.md index 1a75d2f..296aa11 100644 --- a/README.md +++ b/README.md @@ -93,13 +93,12 @@ Open `http://127.0.0.1:8765` in any browser. You should see the active layout's ### macOS -The daemon runs on macOS via `daemon/deckd/platform_macos.py` — focus + key injection work out of the box, mouse clicks + relative pointer motion need `cliclick`, and the jogstrip needs `pyobjc-framework-Quartz`. The GNOME Shell focus extension is Linux-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): ```sh just setup # auto-picks setup-macos on Mac, setup-linux elsewhere - # (setup-macos also runs `brew install cliclick`) just build-client just run-daemon # listens on http://127.0.0.1:8765 ``` @@ -117,7 +116,7 @@ What works / doesn't on macOS: | `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 | only with `cliclick` (`brew install cliclick`) | +| trackpad pointer + clicks + drag | yes (PyObjC Quartz `CGEventCreateMouseEvent`) | | jogstrip scroll | yes (PyObjC Quartz `CGEventCreateScrollWheelEvent` — pulled in via the `[macos]` extra) | ### Phone/tablet testing diff --git a/daemon/deckd/platform_macos.py b/daemon/deckd/platform_macos.py index bf8577f..e632cf7 100644 --- a/daemon/deckd/platform_macos.py +++ b/daemon/deckd/platform_macos.py @@ -13,21 +13,23 @@ | key injection (printable) | yes | osascript ``keystroke`` | | key injection (non-print) | partial| osascript ``key code`` (map) | | combo modifiers | yes | ``using {command down}`` | - | mouse click (left/right) | yes | cliclick ``c:``/``rc:`` | - | mouse relative motion | yes | cliclick ``dx:``/``dy:`` | + | mouse click (left/right) | yes | PyObjC Quartz CG mouse event | + | mouse relative motion | yes | PyObjC Quartz CG mouse event | + | mouse drag (held-button) | yes | PyObjC Quartz LeftMouseDragged | | high-res wheel scroll | yes | PyObjC ``Quartz.CGEvent- | | | | CreateScrollWheelEvent`` | +-----------------------------+--------+------------------------------+ The Linux ``UinputSink`` covers the same wire protocol but emits Linux evdev -events. This module emits nothing locally -- it shells out to ``osascript`` -and ``cliclick`` so the events reach real apps. +events. This module shells out to ``osascript`` for keys + focus and uses +PyObjC ``Quartz`` for scroll + pointer + click. cliclick was the earlier +choice for pointer / click but couldn't do held-button drags, which the +trackpad's tap-and-a-half gesture needs. """ from __future__ import annotations import logging -import shutil import subprocess import sys from collections.abc import Sequence @@ -157,19 +159,34 @@ def _build_keystroke_script(keycodes: Sequence[int]) -> str | None: class MacKeySink(KeySink): - """Emit keys via osascript. Pointer + clicks delegate to ``cliclick`` - when it's on PATH, log a warning otherwise. + """Emit keys via osascript. Pointer + click + drag via PyObjC Quartz. + + Quartz is needed (over the simpler cliclick path) because the + trackpad's tap-and-a-half gesture requires a held-button drag -- + cliclick only knows ``click`` (down+up in one shot) and ``move``, + so it can't model the "press, move with button held, then release" + sequence the wire protocol demands. Quartz's CGEvent lets us split + those into ``LeftMouseDown`` / ``LeftMouseDragged`` / ``LeftMouseUp``. """ def __init__(self) -> None: - self._cliclick = shutil.which("cliclick") - if self._cliclick is None: + try: + import Quartz # type: ignore + + self._Q = Quartz + self._has_quartz = True + except ImportError: + self._Q = None + self._has_quartz = False log.warning( - "[mac key] cliclick not found on PATH; " - "trackpad pointer + clicks will log only " - "(install with: brew install cliclick)" + "[mac key] PyObjC Quartz not available; " + "trackpad pointer / clicks will log only " + "(install with: pip install pyobjc-framework-Quartz)" ) - self._warned_no_cliclick = False + # Track the held-button state so emit_pointer emits + # ``LeftMouseDragged`` (not ``MouseMoved``) while the user is + # mid-drag. The server drives this via pad_drag start/end. + self._dragging_left = False # -- key ----------------------------------------------------------------- @@ -187,37 +204,48 @@ def emit_key(self, keycodes: list[int]) -> None: ) log.debug("[mac key] %s", script) - # -- pointer / click (cliclick) ----------------------------------------- + # -- pointer / click (Quartz) ------------------------------------------- def emit_pointer(self, dx: int, dy: int) -> None: - if self._cliclick is None: - if not self._warned_no_cliclick: - log.warning("[mac pointer] skipping dx=%s dy=%s (no cliclick)", dx, dy) - self._warned_no_cliclick = True + if not self._has_quartz: + log.info("[mac pointer log] dx=%s dy=%s (no Quartz)", dx, dy) return - subprocess.Popen( - [self._cliclick, f"dx:{dx}", f"dy:{dy}"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + if dx == 0 and dy == 0: + return + Q = self._Q + # ``LeftMouseDragged`` keeps the left button logically held in the + # event stream; ``MouseMoved`` is plain cursor motion with no + # button. Picking the right one is what makes the tap-and-a-half + # drag-lock feel native (selections / windows actually drag). + event_type = Q.kCGEventLeftMouseDragged if self._dragging_left else Q.kCGEventMouseMoved + event = Q.CGEventCreateMouseEvent( + None, event_type, Q.CGPoint(0, 0), Q.kCGMouseButtonLeft ) - log.debug("[mac pointer] dx=%s dy=%s", dx, dy) + Q.CGEventSetIntegerValueField(event, Q.kCGMouseEventDeltaX, dx) + Q.CGEventSetIntegerValueField(event, Q.kCGMouseEventDeltaY, dy) + Q.CGEventPost(Q.kCGHIDEventTap, event) + log.debug("[mac pointer] dx=%s dy=%s (drag=%s)", dx, dy, self._dragging_left) def emit_click(self, button: str, pressed: bool) -> None: - # Only emit on press -- the matching release would just re-click. - if not pressed: + if not self._has_quartz: + log.info("[mac click log] button=%s pressed=%s (no Quartz)", button, pressed) return - if self._cliclick is None: - if not self._warned_no_cliclick: - log.warning("[mac click] skipping button=%s (no cliclick)", button) - self._warned_no_cliclick = True + Q = self._Q + if button == "left": + down_type, up_type = Q.kCGEventLeftMouseDown, Q.kCGEventLeftMouseUp + button_code = Q.kCGMouseButtonLeft + elif button == "right": + down_type, up_type = Q.kCGEventRightMouseDown, Q.kCGEventRightMouseUp + button_code = Q.kCGMouseButtonRight + else: + log.warning("[mac click] unknown button %r", button) return - verb = "rc:." if button == "right" else "c:." - subprocess.Popen( - [self._cliclick, verb], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - log.debug("[mac click] button=%s", button) + event_type = down_type if pressed else up_type + event = Q.CGEventCreateMouseEvent(None, event_type, Q.CGPoint(0, 0), button_code) + Q.CGEventPost(Q.kCGHIDEventTap, event) + if button == "left": + self._dragging_left = pressed + log.debug("[mac click] button=%s pressed=%s", button, pressed) def close(self) -> None: pass From 77b12faee711215b7975a806cf62ac5d1b56d1f8 Mon Sep 17 00:00:00 2001 From: Jono <jono@foodnotblogs.com> Date: Fri, 17 Jul 2026 13:54:12 -0700 Subject: [PATCH 4/9] macOS trackpad: use absolute cursor position, not delta fields CGEventCreateMouseEvent with a (0, 0) cursor position is read by Quartz as 'warp the cursor to (0, 0)' and the deltaX / deltaY fields are then ignored (or applied on top of the warp), so every trackpad event sent the cursor to a screen corner instead of moving it relative to its current position. Read the current cursor position with CGEventGetLocation and post the new event at current+delta instead. Also invert dy -- the wire is screen-down (CSS convention) while Quartz screen Y goes up. State machine unchanged: _dragging_left still toggles between MouseMoved and LeftMouseDragged, so tap-and-half drag-lock still works. --- daemon/deckd/platform_macos.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/daemon/deckd/platform_macos.py b/daemon/deckd/platform_macos.py index e632cf7..8c294ad 100644 --- a/daemon/deckd/platform_macos.py +++ b/daemon/deckd/platform_macos.py @@ -218,13 +218,23 @@ def emit_pointer(self, dx: int, dy: int) -> None: # button. Picking the right one is what makes the tap-and-a-half # drag-lock feel native (selections / windows actually drag). event_type = Q.kCGEventLeftMouseDragged if self._dragging_left else Q.kCGEventMouseMoved + # Compute the new absolute cursor position rather than setting the + # delta fields. ``CGEventCreateMouseEvent`` with a (0, 0) cursor + # position is read by Quartz as "warp the cursor to (0, 0)"; the + # deltaX / deltaY fields are then ignored (or applied in addition + # to the warp), so the cursor snaps to a screen corner on every + # event instead of moving relative to where it was. Posting at + # ``current + delta`` is the reliable path. + # + # The wire's dy is screen-down (CSS convention). Quartz screen + # coordinates have origin at bottom-left, Y up, so we invert. + current = Q.CGEventGetLocation(Q.CGEventCreate(None)) + new_pos = Q.CGPoint(current.x + dx, current.y - dy) event = Q.CGEventCreateMouseEvent( - None, event_type, Q.CGPoint(0, 0), Q.kCGMouseButtonLeft + None, event_type, new_pos, Q.kCGMouseButtonLeft ) - Q.CGEventSetIntegerValueField(event, Q.kCGMouseEventDeltaX, dx) - Q.CGEventSetIntegerValueField(event, Q.kCGMouseEventDeltaY, dy) Q.CGEventPost(Q.kCGHIDEventTap, event) - log.debug("[mac pointer] dx=%s dy=%s (drag=%s)", dx, dy, self._dragging_left) + log.debug("[mac pointer] dx=%s dy=%s -> (%.1f, %.1f)", dx, dy, new_pos.x, new_pos.y) def emit_click(self, button: str, pressed: bool) -> None: if not self._has_quartz: From d75f43573b90c7793476cb7058dd07f6c639243e Mon Sep 17 00:00:00 2001 From: Jono <jono@foodnotblogs.com> Date: Fri, 17 Jul 2026 13:57:07 -0700 Subject: [PATCH 5/9] macOS trackpad: stop inverting dy Mouse-event coords in Quartz use top-left origin with Y down (same as CSS / iOS / Windows), not bottom-left / Y up. The -dy flip made vertical motion inverted: finger down moved the cursor up. --- daemon/deckd/platform_macos.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/daemon/deckd/platform_macos.py b/daemon/deckd/platform_macos.py index 8c294ad..307be41 100644 --- a/daemon/deckd/platform_macos.py +++ b/daemon/deckd/platform_macos.py @@ -226,10 +226,11 @@ def emit_pointer(self, dx: int, dy: int) -> None: # event instead of moving relative to where it was. Posting at # ``current + delta`` is the reliable path. # - # The wire's dy is screen-down (CSS convention). Quartz screen - # coordinates have origin at bottom-left, Y up, so we invert. + # Quartz mouse-event coordinates use top-left origin with Y down, + # same as CSS / iOS / Windows -- so dy from the wire (screen-down) + # maps directly to positive Y on the cursor. current = Q.CGEventGetLocation(Q.CGEventCreate(None)) - new_pos = Q.CGPoint(current.x + dx, current.y - dy) + new_pos = Q.CGPoint(current.x + dx, current.y + dy) event = Q.CGEventCreateMouseEvent( None, event_type, new_pos, Q.kCGMouseButtonLeft ) From 30c3c02fa25b69a839efd4bd57d13f944b7a5f24 Mon Sep 17 00:00:00 2001 From: Jono <jono@foodnotblogs.com> Date: Fri, 17 Jul 2026 14:00:50 -0700 Subject: [PATCH 6/9] macOS trackpad: clicks no longer warp cursor to (0, 0) emit_click had the same gotcha as emit_pointer: a CGEventCreateMouseEvent with cursor position (0, 0) is read by Quartz as 'warp the cursor to (0, 0) and click there'. So taps (pad_tap -> click+release) and the tap-and-half drag-lock (pad_drag start -> click, drag motion, pad_drag end -> click) all snapped the cursor to the top of the screen. Use the current cursor position from CGEventGetLocation instead -- the caller's last emit_pointer already placed the cursor where the click should land. --- daemon/deckd/platform_macos.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/daemon/deckd/platform_macos.py b/daemon/deckd/platform_macos.py index 307be41..4fdc963 100644 --- a/daemon/deckd/platform_macos.py +++ b/daemon/deckd/platform_macos.py @@ -252,7 +252,13 @@ def emit_click(self, button: str, pressed: bool) -> None: log.warning("[mac click] unknown button %r", button) return event_type = down_type if pressed else up_type - event = Q.CGEventCreateMouseEvent(None, event_type, Q.CGPoint(0, 0), button_code) + # Use the current cursor position, not (0, 0). Same trap as + # emit_pointer: a (0, 0) position warps the cursor to the top-left + # before the down / up registers, so taps feel like the cursor + # snapped away. The user's last emit_pointer already placed the + # cursor where they want the click; reuse that. + current = Q.CGEventGetLocation(Q.CGEventCreate(None)) + event = Q.CGEventCreateMouseEvent(None, event_type, current, button_code) Q.CGEventPost(Q.kCGHIDEventTap, event) if button == "left": self._dragging_left = pressed From e9d08576bfb23127dd986b0289d7c83ebb650a25 Mon Sep 17 00:00:00 2001 From: Jono <jono@foodnotblogs.com> Date: Fri, 17 Jul 2026 14:06:48 -0700 Subject: [PATCH 7/9] README: dev-daemon in macOS setup; mention check_focus_macos.py dev-daemon wraps the supervisor so Python edits hot-reload; bare run-daemon does not. Adding a pointer to the diagnostic script for the 'layout stuck on X' troubleshooting path. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 296aa11..a5ff00a 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,11 @@ Setup (no `uv`/uinput bits): ```sh just setup # auto-picks setup-macos on Mac, setup-linux elsewhere just build-client -just run-daemon # listens on http://127.0.0.1:8765 +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. @@ -119,6 +121,8 @@ What works / doesn't on macOS: | 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: From b326e8a6c1428e5fdfb72840b51739c5b260f453 Mon Sep 17 00:00:00 2001 From: Jono <jono@foodnotblogs.com> Date: Fri, 17 Jul 2026 14:12:42 -0700 Subject: [PATCH 8/9] tests: pin the mac-platform pure functions 38 new tests across four files, all targeting the bug-prone pure helpers we shipped in the mac-platform branch. tests/test_input.py * name_from_keycode() -- reverse lookup the macOS sink uses to translate evdev keycode lists into names for AppleScript. Pins: - single-char alias wins over long evdev name ([ over leftbrace for code 26; fixes the super+[ regression that initially shipped) - modifiers round-trip (ctrl / shift / alt / super / meta) - long-name fallback for non-printables without a single-char alias (arrows, F-keys, etc.) - unknown keycode -> None (sink skips, doesn't crash) tests/test_platform_macos.py * _build_keystroke_script() -- pure translation from keycodes to AppleScript line. 20 tests pin: - printable single-char path: keystroke '<char>' - non-printable HID path: key code <N> - modifier mapping: super->command, ctrl->control, alt->option, meta==super - multi-modifier combinations emit one using { ... } clause - super+[ regression test (would have caught the original bug) - empty / unknown / modifier-only keycode lists -> None tests/test_deckd_window.py * Server._is_deckd_window() -- auto-ignore check. 9 tests pin: - port-in-title branch (with own port + with other port) - exact-title branch (the regression fix): 'deckd' holds, 'deckd on GitHub' / 'jonocodes/deckd' / 'deckded' do not - case insensitive + whitespace stripped - port=0 disables port branch tests/test_layouts.py (added 4) * __main__._overlay_dir_for() -- pure path computation, monkeypatched sys.platform. Covers darwin, linux, unknown platform (defaults to linux suffix), arbitrary base dir name. All 116 tests pass; no daemon / platform code touched. --- tests/test_deckd_window.py | 119 +++++++++++++++++++++++++++ tests/test_input.py | 63 ++++++++++++++ tests/test_layouts.py | 49 +++++++++++ tests/test_platform_macos.py | 155 +++++++++++++++++++++++++++++++++++ 4 files changed, 386 insertions(+) create mode 100644 tests/test_deckd_window.py create mode 100644 tests/test_input.py create mode 100644 tests/test_platform_macos.py diff --git a/tests/test_deckd_window.py b/tests/test_deckd_window.py new file mode 100644 index 0000000..b768097 --- /dev/null +++ b/tests/test_deckd_window.py @@ -0,0 +1,119 @@ +"""Tests for ``Server._is_deckd_window`` exact-match behaviour. + +The function decides whether a focus event represents the deckd client +browser gaining focus -- when true, the server holds the current layout +instead of switching away. Two checks: + 1. the daemon's own port appears in the window title + 2. the title is exactly "deckd" (the client's ``<title>``) + +The second check used to be ``"deckd" in title.lower()`` which +false-positived on any tab whose title merely contained "deckd" as a +substring (e.g. a GitHub tab for the deckd repo). These tests pin the +new exact-match contract. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from deckd.platform import AppInfo + + +@dataclass +class _Stub: + """Minimal stand-in for Server -- _is_deckd_window only reads self.port.""" + + port: int + + +def _app(*, app_id: str | None = "firefox", title: str | None = None) -> AppInfo: + """AppInfo with wm_class=None (matches the macOS backend's output + shape). title / app_id defaults can be overridden per-test.""" + return AppInfo(app_id=app_id, wm_class=None, title=title) + + +def _is_deckd(server: _Stub, app: AppInfo) -> bool: + # Late import so test collection doesn't require a fully wired Server. + from deckd.server import Server + + return Server._is_deckd_window(server, app) + + +# --------------------------------------------------------------------------- +# Port-in-title branch +# --------------------------------------------------------------------------- + + +def test_port_in_title_holds() -> None: + """A title carrying the daemon's own port (some browsers surface the + URL in the title bar) holds -- the user is looking at the deckd + client.""" + assert _is_deckd(_Stub(port=8765), _app(title="127.0.0.1:8765")) + + +def test_other_port_in_title_does_not_hold() -> None: + """A different port in the title is a different app -- no hold.""" + assert not _is_deckd( + _Stub(port=8765), _app(title="localhost:8080") + ) + + +def test_no_title_does_not_hold() -> None: + """No title, no signal -- resolve the focus event normally.""" + assert not _is_deckd(_Stub(port=8765), _app(title=None)) + + +# --------------------------------------------------------------------------- +# Exact-title branch (the regression magnet) +# --------------------------------------------------------------------------- + + +def test_exact_deckd_title_holds() -> None: + """The deckd client's ``<title>deckd`` is an exact match.""" + assert _is_deckd(_Stub(port=8765), _app(title="deckd")) + + +def test_exact_deckd_title_with_whitespace_holds() -> None: + """Some terminals / window managers add surrounding whitespace; + the implementation strips before comparing.""" + assert _is_deckd( + _Stub(port=8765), _app(title=" deckd ") + ) + + +def test_exact_deckd_case_insensitive() -> None: + """Page titles can vary in case; lower() comparison covers that.""" + assert _is_deckd(_Stub(port=8765), _app(title="DECKD")) + + +def test_deckd_substring_does_not_hold() -> None: + """Regression: a tab titled "deckd on GitHub" or "deckd-deckd/issues" + must NOT hold -- the user is reading something else and the + previous substring match falsely froze the layout.""" + assert not _is_deckd( + _Stub(port=8765), _app(title="deckd on GitHub") + ) + assert not _is_deckd( + _Stub(port=8765), _app(title="jonocodes/deckd") + ) + assert not _is_deckd( + _Stub(port=8765), _app(title="deckded") + ) + + +def test_empty_title_does_not_hold() -> None: + """An empty string is not the title 'deckd'.""" + assert not _is_deckd(_Stub(port=8765), _app(title="")) + + +# --------------------------------------------------------------------------- +# Port-zero edge case (server hasn't bound yet / explicit override) +# --------------------------------------------------------------------------- + + +def test_port_zero_disables_port_branch() -> None: + """A non-positive port disables the port-in-title branch (the + fallback in __main__ uses 0 when the server isn't bound yet). Only + the exact-title branch can match.""" + assert not _is_deckd(_Stub(port=0), _app(title="1234")) + # ... but the exact-title branch still works. + assert _is_deckd(_Stub(port=0), _app(title="deckd")) diff --git a/tests/test_input.py b/tests/test_input.py new file mode 100644 index 0000000..48cffb0 --- /dev/null +++ b/tests/test_input.py @@ -0,0 +1,63 @@ +"""Tests for ``deckd.input`` helpers. + +Focused on ``name_from_keycode`` -- the reverse lookup the macOS sink +uses to translate the wire's keycode list back to names for AppleScript. +A regression in this table (e.g. ``leftbrace`` winning over ``[``) +silently breaks ``super+[`` / ``super+]`` shortcuts, so we pin the +canonical form here. +""" +from __future__ import annotations + +from deckd.input import name_from_keycode + + +# --------------------------------------------------------------------------- +# Single-char aliases win over the longer evdev names +# --------------------------------------------------------------------------- + + +def test_name_from_keycode_prefers_single_char_alias() -> None: + """``[`` and ``]`` / ``-`` / ``=`` map to the same code as their + long evdev names; the single-char form must win so the macOS sink + can emit ``keystroke "["`` instead of failing the HID-code lookup. + """ + assert name_from_keycode(26) == "[" + assert name_from_keycode(27) == "]" + assert name_from_keycode(12) == "-" + assert name_from_keycode(13) == "=" + + +def test_name_from_keycode_returns_long_name_when_no_single_char() -> None: + """For keys without a single-char alias (arrows, function keys, etc.), + the long evdev name is what callers get.""" + assert name_from_keycode(103) == "up" + assert name_from_keycode(108) == "down" + assert name_from_keycode(105) == "left" + assert name_from_keycode(106) == "right" + assert name_from_keycode(59) == "f1" + assert name_from_keycode(88) == "f12" + + +def test_name_from_keycode_handles_modifiers() -> None: + """Modifiers are also reverse-lookupable -- the macOS sink translates + them to AppleScript ``command down`` / ``control down`` / etc.""" + assert name_from_keycode(29) == "ctrl" + assert name_from_keycode(42) == "shift" + assert name_from_keycode(56) == "alt" + assert name_from_keycode(125) in {"super", "meta"} + + +def test_name_from_keycode_returns_none_for_unknown() -> None: + """Codes outside the table return None -- the sink treats that as + "skip this key" rather than crashing.""" + assert name_from_keycode(999) is None + assert name_from_keycode(-1) is None + + +def test_name_from_keycode_handles_letters_and_digits() -> None: + """The common-printable cases are single chars already; this is just + a sanity check that they round-trip.""" + assert name_from_keycode(20) == "t" + assert name_from_keycode(30) == "a" + assert name_from_keycode(11) == "0" + assert name_from_keycode(2) == "1" diff --git a/tests/test_layouts.py b/tests/test_layouts.py index e894fa2..52cfe6f 100644 --- a/tests/test_layouts.py +++ b/tests/test_layouts.py @@ -407,3 +407,52 @@ def test_overlay_can_add_new_layouts(tmp_path: Path) -> None: store = load_layouts(base, overlay) assert "Safari" in store assert "default" in store + + +# --------------------------------------------------------------------------- +# Overlay directory discovery (__main__._overlay_dir_for) +# +# The daemon auto-discovers ``.`` next to +# the base dir. The suffix is ``macos`` on Darwin, ``linux`` elsewhere. +# Pure path math, tested by monkeypatching sys.platform. +# --------------------------------------------------------------------------- + + +def test_overlay_dir_for_darwin(monkeypatch, tmp_path: Path) -> None: + """On macOS the overlay path is ``.macos``.""" + monkeypatch.setattr("deckd.__main__.sys.platform", "darwin") + from deckd.__main__ import _overlay_dir_for + + base = tmp_path / "layouts" + assert _overlay_dir_for(base) == tmp_path / "layouts.macos" + + +def test_overlay_dir_for_linux(monkeypatch, tmp_path: Path) -> None: + """On Linux (or any non-darwin sys.platform) the suffix is ``linux``.""" + monkeypatch.setattr("deckd.__main__.sys.platform", "linux") + from deckd.__main__ import _overlay_dir_for + + base = tmp_path / "layouts" + assert _overlay_dir_for(base) == tmp_path / "layouts.linux" + + +def test_overlay_dir_for_unknown_platform_defaults_to_linux(monkeypatch, tmp_path: Path) -> None: + """An unmapped sys.platform (e.g. ``freebsd``) falls back to ``linux`` + suffix -- unknown platforms behave like Linux for overlay purposes.""" + monkeypatch.setattr("deckd.__main__.sys.platform", "freebsd") + from deckd.__main__ import _overlay_dir_for + + base = tmp_path / "layouts" + assert _overlay_dir_for(base) == tmp_path / "layouts.linux" + + +def test_overlay_dir_preserves_arbitrary_base_name(monkeypatch, tmp_path: Path) -> None: + """The base dir can be named anything; only ``.`` is + computed. Useful for users who keep their layouts in + ``~/.config/deckd/layouts`` or similar.""" + monkeypatch.setattr("deckd.__main__.sys.platform", "darwin") + from deckd.__main__ import _overlay_dir_for + + base = tmp_path / "my-configs" / "deckd-layouts" + base.parent.mkdir() + assert _overlay_dir_for(base) == base.parent / "deckd-layouts.macos" diff --git a/tests/test_platform_macos.py b/tests/test_platform_macos.py new file mode 100644 index 0000000..3980fe1 --- /dev/null +++ b/tests/test_platform_macos.py @@ -0,0 +1,155 @@ +"""Tests for ``deckd.platform_macos._build_keystroke_script``. + +This is the pure-Python translation that turns an evdev keycode list +(modifiers + main key) into a one-line AppleScript. The actual +``osascript`` call is fire-and-forget so we can't integration-test it +cleanly; the string output is what we assert on. + +Runs on any platform -- the function doesn't import Quartz / osascript. +""" +from __future__ import annotations + +import pytest + +from deckd.input import parse_key_combo +from deckd.platform_macos import _build_keystroke_script + + +# --------------------------------------------------------------------------- +# Printables: keystroke "" +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "combo,expected", + [ + ("t", 'tell application "System Events" to keystroke "t"'), + ("a", 'tell application "System Events" to keystroke "a"'), + ("0", 'tell application "System Events" to keystroke "0"'), + ("=", 'tell application "System Events" to keystroke "="'), + ("-", 'tell application "System Events" to keystroke "-"'), + ("[", 'tell application "System Events" to keystroke "["'), + ("]", 'tell application "System Events" to keystroke "]"'), + ], +) +def test_printable_char_emits_keystroke_literal(combo: str, expected: str) -> None: + """Single-character printables go out as ``keystroke ""`` -- + locale-correct, no HID-code lookup needed. This is the path that + fixed the ``super+[`` shortcut regression.""" + assert _build_keystroke_script(parse_key_combo(combo)) == expected + + +# --------------------------------------------------------------------------- +# Combos: using { down} +# --------------------------------------------------------------------------- + + +def test_super_modifier_maps_to_command() -> None: + """``super`` is the Mac "Command" key -- the modifier clause uses + ``command down``.""" + script = _build_keystroke_script(parse_key_combo("super+t")) + assert script == ( + 'tell application "System Events" to keystroke "t" ' + "using {command down}" + ) + + +def test_ctrl_maps_to_control() -> None: + script = _build_keystroke_script(parse_key_combo("ctrl+a")) + assert 'using {control down}' in script + assert 'keystroke "a"' in script + + +def test_alt_maps_to_option() -> None: + script = _build_keystroke_script(parse_key_combo("alt+Tab")) + assert 'using {option down}' in script + assert "key code 48" in script # Tab's HID code + + +def test_meta_and_super_both_become_command() -> None: + """``meta`` is an alias for ``super`` on Linux; both should yield + ``command down`` on macOS.""" + a = _build_keystroke_script(parse_key_combo("super+a")) + b = _build_keystroke_script(parse_key_combo("meta+a")) + assert a == b + + +def test_multiple_modifiers_combine_in_order() -> None: + """Modifiers are emitted in the order they appear in the input -- + the wire protocol always puts them first so the test combo matches + the canonical order, but the clause should still emit all of them.""" + script = _build_keystroke_script(parse_key_combo("ctrl+shift+Right")) + assert script == ( + 'tell application "System Events" to key code 124 ' + "using {control down, shift down}" + ) + + +def test_super_plus_bracket_uses_literal_keystroke() -> None: + """Regression test: ``super+[`` used to log + ``no macOS mapping for key 'leftbrace'`` because the keycode + reverse-lookup returned the long alias. Now ``name_from_keycode`` + prefers the single-char form and the script is a clean + ``keystroke "["``.""" + script = _build_keystroke_script(parse_key_combo("super+[")) + assert script == ( + 'tell application "System Events" to keystroke "[" ' + "using {command down}" + ) + + +# --------------------------------------------------------------------------- +# Non-printables: key code +# --------------------------------------------------------------------------- + + +def test_function_key_uses_hid_code() -> None: + """F1 -> key code 122 (HID usage ID); no modifiers.""" + assert _build_keystroke_script(parse_key_combo("F1")) == ( + 'tell application "System Events" to key code 122' + ) + + +def test_arrow_uses_hid_code() -> None: + assert _build_keystroke_script(parse_key_combo("Up")) == ( + 'tell application "System Events" to key code 126' + ) + + +def test_return_uses_hid_code() -> None: + assert _build_keystroke_script(parse_key_combo("Return")) == ( + 'tell application "System Events" to key code 36' + ) + + +# --------------------------------------------------------------------------- +# Failure modes +# --------------------------------------------------------------------------- + + +def test_empty_keycodes_returns_none() -> None: + """An empty list (e.g. parsed combo with no known keys) yields + None -- the sink logs and skips rather than emitting ``key code`` + with nothing.""" + assert _build_keystroke_script([]) is None + + +def test_unknown_keycode_returns_none() -> None: + """A keycode not in either forward table is untranslatable; the + sink logs and skips rather than crashing.""" + assert _build_keystroke_script([29, 999]) is None + + +def test_unknown_main_key_returns_none() -> None: + """If the main key isn't printable AND isn't in the HID map, we + bail out -- we don't want to emit garbage key codes.""" + # ``foo`` is not in MODIFIER_MAP or _SINGLE_KEY_MAP. + assert _build_keystroke_script([20, 9999]) is None + + +def test_only_modifiers_returns_none() -> None: + """A combo that ends up modifier-only (e.g. a parse that lost the + main key) returns None -- the sink shouldn't emit ``keystroke`` + with no character.""" + # Pass a keycode that's only in MODIFIER_MAP, twice. + assert _build_keystroke_script([29, 29]) is None From 3a289aa8daac15957a233efe074782f72ee0bffa Mon Sep 17 00:00:00 2001 From: Jono Date: Fri, 17 Jul 2026 14:18:41 -0700 Subject: [PATCH 9/9] review: address Required + Optional findings from code-review Required -------- * platform_macos.py: drop dead is_macos() -- never imported anywhere; callers use sys.platform directly. * platform_macos.py: extract _load_quartz() helper. MacKeySink and MacScrollSink both had the same try/except Quartz + log import dance; collapsed to a one-liner init + sink-specific warning. * input.py: collapse _NAMES_BY_KEYCODE build to a single iteration over (MODIFIER_MAP.items() ++ _SINGLE_KEY_MAP.items()). Same data structure, same lookup behaviour, ~5 lines vs ~10. * __main__.py: _overlay_dir_for now returns Path, not Path | None -- the type lied. The 'missing overlay is fine' branch lives at the call site (Server treats it as a no-op). Optional -------- * platform_macos.py: extract _cursor_pos(Q) helper. emit_pointer and emit_click both called CGEventGetLocation(CGEventCreate(None)) -- same dance, one helper now. * platform_macos.py: fix stale header docstring ('and, when present, cliclick' -- cliclick is no longer used). Updated to explain why Quartz is chosen over cliclick. * scripts/check_focus_macos.py: --port CLI arg + argparse. Hardcoded 8765 would silently lie if the daemon's default ever changed. * scripts/check_focus_macos.py: drop the no-op set comprehension '{l.id for l in store.layouts if l.id == "default"}' -- just 'resolved.id == "default"'. All 116 tests still pass. --- daemon/deckd/__main__.py | 8 ++-- daemon/deckd/input.py | 14 ++++-- daemon/deckd/platform_macos.py | 84 +++++++++++++++++----------------- scripts/check_focus_macos.py | 54 +++++++++++++++------- 4 files changed, 94 insertions(+), 66 deletions(-) diff --git a/daemon/deckd/__main__.py b/daemon/deckd/__main__.py index 30bc790..fc4b847 100644 --- a/daemon/deckd/__main__.py +++ b/daemon/deckd/__main__.py @@ -28,12 +28,12 @@ async def _run(server: Server) -> None: await server.stop() -def _overlay_dir_for(layouts_dir: Path) -> Path | None: +def _overlay_dir_for(layouts_dir: Path) -> Path: """Pick a per-platform overlay dir next to ``layouts_dir``. - Convention: ``.linux`` or ``.macos``. Missing directories - are fine — the most common case (Linux dev, no overlay yet) just - returns None and the daemon loads only the base. + Convention: ``.linux`` or ``.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}" diff --git a/daemon/deckd/input.py b/daemon/deckd/input.py index b1ea2ec..a6608e0 100644 --- a/daemon/deckd/input.py +++ b/daemon/deckd/input.py @@ -148,11 +148,17 @@ def name_from_keycode(keycode: int) -> str | None: 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 ""`` +# takes printable literals directly without an HID-code lookup. _NAMES_BY_KEYCODE: dict[int, list[str]] = {} -for _k, _v in MODIFIER_MAP.items(): - _NAMES_BY_KEYCODE.setdefault(_v, []).append(_k) -for _k, _v in _SINGLE_KEY_MAP.items(): - _NAMES_BY_KEYCODE.setdefault(_v, []).append(_k) +for _name, _kc in ( + *MODIFIER_MAP.items(), + *_SINGLE_KEY_MAP.items(), +): + _NAMES_BY_KEYCODE.setdefault(_kc, []).append(_name) # --------------------------------------------------------------------------- diff --git a/daemon/deckd/platform_macos.py b/daemon/deckd/platform_macos.py index 4fdc963..7e412fd 100644 --- a/daemon/deckd/platform_macos.py +++ b/daemon/deckd/platform_macos.py @@ -1,8 +1,11 @@ """macOS backend: focus detection + key/pointer/scroll sinks. -Cheap shim over AppleScript (``osascript``) and, when present, ``cliclick``. -No new native deps, no kernel extensions. TCC will prompt the first time -osascript asks System Events to do something on the daemon's behalf. +No new native deps, no kernel extensions. Keys + focus shell out to +``osascript`` (AppleScript); scroll + pointer + click go through +PyObjC ``Quartz`` so we can do held-button drags the trackpad's +tap-and-a-half gesture needs. cliclick was an earlier choice for +pointer / click but couldn't model a held button across multiple +moves -- Quartz's ``LeftMouseDragged`` is. Capability matrix (sketch): @@ -20,25 +23,37 @@ | | | CreateScrollWheelEvent`` | +-----------------------------+--------+------------------------------+ -The Linux ``UinputSink`` covers the same wire protocol but emits Linux evdev -events. This module shells out to ``osascript`` for keys + focus and uses -PyObjC ``Quartz`` for scroll + pointer + click. cliclick was the earlier -choice for pointer / click but couldn't do held-button drags, which the -trackpad's tap-and-a-half gesture needs. +The Linux ``UinputSink`` covers the same wire protocol but emits Linux +evdev events. """ from __future__ import annotations import logging import subprocess -import sys from collections.abc import Sequence +from types import ModuleType from .input import MODIFIER_MAP, KeySink, ScrollSink, name_from_keycode from .platform import AppInfo, PlatformBackend, _run log = logging.getLogger("deckd.platform_macos") + +def _load_quartz() -> tuple[ModuleType | None, bool]: + """Lazy import of ``pyobjc-framework-Quartz``. Returns ``(module, available)``. + + Both sinks (pointer / click and scroll) need the same module; + centralising the try / except avoids the duplicated boilerplate and + gives one place to log the install hint. + """ + try: + import Quartz # type: ignore + + return Quartz, True + except ImportError: + return None, False + # --------------------------------------------------------------------------- # Focus # --------------------------------------------------------------------------- @@ -170,14 +185,8 @@ class MacKeySink(KeySink): """ def __init__(self) -> None: - try: - import Quartz # type: ignore - - self._Q = Quartz - self._has_quartz = True - except ImportError: - self._Q = None - self._has_quartz = False + self._Q, self._has_quartz = _load_quartz() + if not self._has_quartz: log.warning( "[mac key] PyObjC Quartz not available; " "trackpad pointer / clicks will log only " @@ -229,7 +238,7 @@ def emit_pointer(self, dx: int, dy: int) -> None: # Quartz mouse-event coordinates use top-left origin with Y down, # same as CSS / iOS / Windows -- so dy from the wire (screen-down) # maps directly to positive Y on the cursor. - current = Q.CGEventGetLocation(Q.CGEventCreate(None)) + current = _cursor_pos(Q) new_pos = Q.CGPoint(current.x + dx, current.y + dy) event = Q.CGEventCreateMouseEvent( None, event_type, new_pos, Q.kCGMouseButtonLeft @@ -257,7 +266,7 @@ def emit_click(self, button: str, pressed: bool) -> None: # before the down / up registers, so taps feel like the cursor # snapped away. The user's last emit_pointer already placed the # cursor where they want the click; reuse that. - current = Q.CGEventGetLocation(Q.CGEventCreate(None)) + current = _cursor_pos(Q) event = Q.CGEventCreateMouseEvent(None, event_type, current, button_code) Q.CGEventPost(Q.kCGHIDEventTap, event) if button == "left": @@ -268,6 +277,12 @@ def close(self) -> None: pass +def _cursor_pos(Q: ModuleType): + """Read the current cursor position. Wraps the CGEventCreate + CGEventGetLocation + pair so emit_pointer / emit_click don't repeat the dance.""" + return Q.CGEventGetLocation(Q.CGEventCreate(None)) + + # --------------------------------------------------------------------------- # Scroll sink # --------------------------------------------------------------------------- @@ -276,12 +291,11 @@ def close(self) -> None: class MacScrollSink(ScrollSink): """Synthetic-wheel sink via PyObjC + Quartz. - macOS doesn't expose wheel injection through AppleScript or any - open-source tool I trust to keep working (cliclick, Hammerspoon - aside). Quartz's ``CGEventCreateScrollWheelEvent`` is the only path - that reaches the focused window's scroll view the same way a real - trackpad or mouse wheel does. Requires ``pyobjc-framework-Quartz``; - the sink falls back to log-only if it's not importable. + macOS doesn't expose wheel injection through AppleScript. Quartz's + ``CGEventCreateScrollWheelEvent`` is the only path that reaches the + focused window's scroll view the same way a real trackpad or mouse + wheel does. Requires ``pyobjc-framework-Quartz``; the sink falls + back to log-only if it's not importable. The wire protocol emits ``REL_WHEEL_HI_RES`` deltas (1/120 of a wheel detent). We accumulate them and emit one ``kCGScrollEventUnitLine`` @@ -292,15 +306,10 @@ class MacScrollSink(ScrollSink): DETENT = 120 def __init__(self) -> None: - try: - import Quartz # type: ignore - - self._Quartz = Quartz - self._available = True + self._Quartz, self._available = _load_quartz() + if self._available: log.info("[mac scroll] Quartz loaded; wheel events will be injected") - except ImportError: - self._Quartz = None - self._available = False + else: log.warning( "[mac scroll] PyObjC Quartz not available; " "jogstrip will log only " @@ -331,12 +340,3 @@ def emit_scroll(self, delta: int) -> None: def close(self) -> None: pass - - -# --------------------------------------------------------------------------- -# Re-export so __main__.py can ask ``platform_macos.is_macos()`` cleanly. -# --------------------------------------------------------------------------- - - -def is_macos() -> bool: - return sys.platform == "darwin" diff --git a/scripts/check_focus_macos.py b/scripts/check_focus_macos.py index 38be162..63a884a 100644 --- a/scripts/check_focus_macos.py +++ b/scripts/check_focus_macos.py @@ -14,18 +14,20 @@ from __future__ import annotations +import argparse import asyncio import sys from pathlib import Path -async def _probe() -> None: +async def _probe(port: int, layouts_dir: Path) -> int: from deckd.layouts import LayoutStore, load_layouts, resolve_layout from deckd.platform import AppInfo from deckd.platform_macos import MacFocusBackend - base = Path("layouts") - overlay = Path("layouts.macos") if Path("layouts.macos").is_dir() else None + overlay = layouts_dir.parent / f"{layouts_dir.name}.macos" + if not overlay.is_dir(): + overlay = None backend = MacFocusBackend() try: @@ -35,35 +37,55 @@ async def _probe() -> None: print("On macOS this is almost always a TCC permission issue --") print("grant Automation permission to the parent shell (Terminal / iTerm / cmux)") print("under System Settings -> Privacy & Security -> Automation, then re-run.") - sys.exit(2) + return 2 print(f"osascript sees: app_id={app.app_id!r} title={app.title!r}") - store: LayoutStore = load_layouts(base, overlay) + store: LayoutStore = load_layouts(layouts_dir, overlay) resolved = resolve_layout(store, app) # Reproduce the auto-ignore check exactly as Server._is_deckd_window does. title = (app.title or "").strip() - port = 8765 port_match = bool(port) and str(port) in title exact_match = title.lower() == "deckd" - auto_ignore = port_match or exact_match - print(f"auto-ignore: port_in_title={port_match} exact_title_match={exact_match} -> holds={auto_ignore}") + print( + f"auto-ignore: port_in_title={port_match} " + f"exact_title_match={exact_match} -> holds={port_match or exact_match}" + ) print(f"would resolve to layout: {resolved.id!r}") - if resolved.id in {l.id for l in store.layouts if l.id == "default"}: - # Add a hint if the user's app_id is something we'd expect to see - # a layout for but don't. - if app.app_id and app.app_id.lower() in {"firefox", "safari", "chrome"}: - print(f"hint: {app.app_id!r} has no specific layout in this checkout,") - print(" so the default fallback is correct.") + if ( + resolved.id == "default" + and app.app_id + and app.app_id.lower() in {"firefox", "safari", "chrome"} + ): + print(f"hint: {app.app_id!r} has no specific layout in this checkout,") + print(" so the default fallback is correct.") + + return 0 def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--port", + type=int, + default=8765, + help="daemon port (matched against the focused window's title by the auto-ignore check)", + ) + parser.add_argument( + "--layouts-dir", + type=Path, + default=Path("layouts"), + help="base layouts directory (overlay is auto-discovered as .macos next to it)", + ) + args = parser.parse_args() + try: - asyncio.run(_probe()) + rc = asyncio.run(_probe(args.port, args.layouts_dir)) except KeyboardInterrupt: - sys.exit(130) + rc = 130 + sys.exit(rc) if __name__ == "__main__":