Skip to content

macOS platform support - #28

Merged
jonocodes merged 9 commits into
mainfrom
mac-platform
Jul 17, 2026
Merged

jonocodes merged 9 commits into
mainfrom
mac-platform

Conversation

@jonocodes

Copy link
Copy Markdown
Owner

Adds the macOS platform backend per the #24 spike: focus detection + key injection via osascript, scroll + pointer + click via PyObjC Quartz. Introduces the per-platform overlay convention (layouts.macos/) so Mac-specific shortcuts can ship alongside the shared base without forking.

What's in here

daemon/deckd/platform_macos.py (new)

  • MacFocusBackend — osascript + System Events for frontmost app + window title
  • MacKeySink — osascript keystroke for printable chars, key code for non-printables, Quartz CGEvent for pointer / click / held-button drag (tap-and-half)
  • MacScrollSink — Quartz CGEventCreateScrollWheelEvent, 1 line per 120 REL_WHEEL_HI_RES units (matches Linux)

daemon/deckd/layouts.py

  • load_layouts(base, overlay=None) — overlay entries load first, base entries with matching id are dropped. First-match-wins within the combined list, so overlay entries shadow base entries that match the same focused-app identity.

daemon/deckd/__main__.py

  • Auto-discovers layouts.<platform> next to --layouts-dir (sibling layouts.macos/ on Mac, layouts.linux/ on Linux). --no-overlay to skip.
  • Constructs Mac sinks on darwin before uinput fallback.
  • Logs which focus backend is in use at startup (catches stale-daemon debugs).

daemon/deckd/server.py

  • _is_deckd_window now requires exact title match on "deckd". Previously used in title.lower() which false-positived on tabs whose titles merely contained deckd as a substring.

daemon/deckd/input.py

  • New name_from_keycode() reverse lookup for the Mac sink. Prefers single-char aliases ([ over leftbrace for code 26) so AppleScript's keystroke "<char>" works without an HID-code lookup.

pyproject.toml

  • New [macos] extra: pyobjc-framework-Quartz>=10.0. evdev-binary stays in [uinput] (Linux-only).

Justfile

  • setup-macos / setup-linux per-platform recipes. setup auto-dispatches.

layouts.macos/

  • firefox.yaml — super+t / super+w / super+f / super+n / super+r / super+l / super+[ / super+] (Mac-conventional)
  • default.yaml — generic 4×2 fallback (new tab, close, find, new window, open URL, iTerm, Terminal, System Settings)

scripts/check_focus_macos.py (new)

  • One-shot diagnostic: prints what osascript reports, whether the auto-ignore rule would hold, and which layout would resolve. --port / --layouts-dir CLI args. Used during testing to verify TCC + auto-ignore behaviour without reading daemon logs.

Tests (43 new, 116 total)

  • tests/test_input.py — name_from_keycode() regressions
  • tests/test_platform_macos.py — _build_keystroke_script() (20 cases incl. the super+[ regression)
  • tests/test_deckd_window.py — _is_deckd_window() exact-match
  • tests/test_layouts.py — overlay semantics + _overlay_dir_for()

Issue

Closes #24. Supersedes the research-only outcome — this PR also lands the implementation that the spike was groundwork for.

Follow-ups filed

Verification

  • Daemon + client smoke-tested end-to-end on Mac (focus switching, super+[ / super+] / super+t / super+w, trackpad click + drag with tap-and-half, jogstrip scroll)
  • All 116 unit tests pass
  • Code review pass: 4 Required + 5 Optional findings addressed in 3a289aa

Behavior matrix

capability macOS
focus detection yes (osascript + System Events)
key: action (printable + combos) yes (osascript keystroke)
key: action (non-printable) partial (HID map covers common keys — arrows, esc, tab, enter, F-keys, backspace, delete, home/end, pgup/pgdn, capslock)
shell: / terminal: actions yes
dbus: action no (no MPRIS / GNOME services)
trackpad pointer + clicks + drag yes (PyObjC Quartz CGEvent)
jogstrip scroll yes (PyObjC Quartz CGScrollWheel)

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 '<char>'; 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.<platform> 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.
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'.
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.
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.
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.
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.
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.
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.
@jonocodes
jonocodes merged commit b029808 into main Jul 17, 2026
2 checks passed
@jonocodes
jonocodes deleted the mac-platform branch August 4, 2026 03:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Investigation: macOS platform backend (focus, injection, power sync)

1 participant