Status: built, local-only proof (no macOS CI runner yet) · Scope: full-screen + per-window pixel capture and pointer/keyboard input synthesis behind the same [
Executor] trait the Linux/X11 backend implements. The structured/AX-tree observation engine is out of v1 scope (see Seams). Built-vs-designed map: status.md.
Live proof: the agent typed this text over CGEvent and captured its own focused window
(screenshot(scope="active_window"), CoreGraphics, Retina) through the same ACI the Linux engine serves.
The macOS backend lives in shinkend/src/executor_macos.rs, gated by
cfg(target_os = "macos") + the macos-native cargo feature (default-on; it compiles
to nothing on non-macOS targets, so the Linux build is untouched). It reuses the entire
existing pipeline above the capture/input boundary: raw RGB8 feeds the shared
downscale/PNG/JPEG encoders, the dirty-tile delta screencast works via the poll-diff
fallback (damage_cursor() returns None on macOS — there is no XDamage analogue in
v1), and the wire contract is unchanged.
shinkend --backend macos # explicit (also: SHINKEND_EXECUTOR=macos)
shinkend # auto: on macOS with no $DISPLAY → native backendauto keeps X11 priority when $DISPLAY is set (XQuartz), then falls back to the
native backend, then to virtual. The --backend flag overrides $SHINKEND_EXECUTOR.
v1 uses the CoreGraphics C API via the maintained core-graphics crate:
- Full screen:
CGDisplayCreateImage(CGDisplay::image()). - Per-window (
scope: window:<id>andactive_window):CGWindowListCreateImagewithkCGWindowImageBoundsIgnoreFraming | kCGWindowImageBestResolution; theactive_windowscope resolves the frontmost layer-0 window fromCGWindowListCopyWindowInfo(front-to-back order) — a permissionless heuristic that matches EWMH_NET_ACTIVE_WINDOWsemantics closely enough for capture. - Captured
CGImages are normalized by one blit into an owned RGBX bitmap context (robust to whatever pixel format/stride the OS returns), then stripped to RGB8 for the shared encode pipeline.
Both capture calls are deprecated since macOS 14 in favor of ScreenCaptureKit but remain functional (verified live on macOS 26/arm64). The upgrade path, when the deprecation bites or when screencast capture should stop paying per-frame blit costs:
SCShareableContent/SCContentFilter/SCScreenshotManagerfor one-shot capture,SCStreamfor the screencast loop (delta-friendly: SCK delivers dirty rects, which would replace the poll-diff path the same way XDamage did on Linux),- via the
objc2+objc2-screen-capture-kitcrates (block-based completion handlers bridged withblock2) — judged a deeper binding fight than v1 warranted (async completion plumbing for a synchronousExecutorcall, for zero contract change); theExecutorseam is unchanged by the swap.
Input synthesis is not deprecated and stays: CGEventCreateMouseEvent /
CGEventCreateKeyboardEvent / CGEventCreateScrollWheelEvent, posted at the HID tap
(CGEventTapLocation::HID), with an 8 ms settle after each event.
The ACI speaks in capture pixels (what the agent sees in the screenshot), exactly
like Linux. On Retina displays CGEvent coordinates are global display points
(typically half the pixels), so the backend reads the live display mode each call and
scales pixel→point at the posting boundary. Verified live: move(x=600, y=400) on a
2× display lands the real cursor at point (300.0, 200.0). screen_size reports capture
pixels (e.g. 3456×2234 on a 2× 1728×1117 display) so screenshot dims == click space.
macOS gates both halves of the engine behind user-granted permissions, attached to the
responsible app — the terminal (or other host process) that launched shinkend:
| Grant | Gates | Failure mode without it |
|---|---|---|
| Screen Recording (System Settings → Privacy & Security → Screen & System Audio Recording) | capture | CGDisplayCreateImage "succeeds" but returns a wallpaper-only frame — useless and misleading |
| Accessibility (System Settings → Privacy & Security → Accessibility) | input | posted CGEvents are silently dropped |
The backend is honest about both, instead of crashing or lying:
readiness()(thereadyquery) reportsdisplay_up(the cross-platform alias ofx11_up: a display is online) andpermissions_pending(true while either grant is missing);readyis true only when a display exists and Screen Recording is granted.root_nonblackisnullon macOS (nothing sampled).- A capture attempted without Screen Recording, or an input action without
Accessibility, fails with a typed
permission_pending: …error naming the grant and the settings pane — it never returns a wallpaper-only frame and never posts events into the void. - At startup with grants missing,
shinkendprints one clear guidance block and callsCGRequestScreenCaptureAccess()once, which registers the responsible app in the Screen Recording pane and triggers the system prompt.
Grant flow: launch shinkend once → grant Screen Recording and Accessibility to
your terminal in System Settings → restart the terminal (macOS applies Screen Recording
on relaunch of the responsible app) → rerun. scripts/macos_smoke.py exits 2 with the
same guidance while grants are pending.
Permission state is re-probed per call, so the ready query flips as soon as macOS
honors the grant — clients poll ready exactly like they do during Linux desktop boot
(display_up plays the role of x11_up).
v1 posts input globally (CGEventPost to the HID tap): clicks move the real cursor
and keystrokes go to whatever is frontmost. That is exclusive-desktop semantics — correct
when the Mac is the agent's machine for the duration, wrong for co-use (a human keeps
working while the agent operates one app). The co-use reference implementation in the field
(open-codex-computer-use) does three
things v1 does not:
- per-app background input —
CGEvent.postToPid(pid)delivers clicks/keys into the target app's event queue without moving the shared cursor or stealing focus (InputSimulation.swift), with an AX-action fallback (kAXRaiseActionetc.); - a software cursor for the human — a click-through overlay panel
(
SoftwareCursorOverlay.swift:ignoresMouseEvents, joins all Spaces, level tracks the target window) animated by a motion model, so the user sees the agent act; - cursor-free observations — its SCK capture sets
showsCursor = false(AccessibilitySnapshot.swift:416); the model grounds on the element-indexed AX tree, the human on the overlay.
Shinken's captures are already cursor-free on both engines (CoreGraphics and X11
XGetImage never composite the hardware cursor — load-bearing for fork-fleet frame-hash
dedup and idle suppression). The missing co-use pieces are per-pid posting and the overlay —
the D14 co-use tier, designed-only. Until it lands, the co-use answer on macOS is the
mcp-computer operation-layer backend (D15): a codex-style AX server drives apps in the
background under the same ACI.
All shared coordinate-tier verbs are implemented: move, click, double_click
(proper kCGMouseEventClickState=2 on the second pair), right_click, scroll
(pixel-denominated dx/dy, sign-mapped to CG wheel axes), type_text, key,
plus the schema gesture verbs — drag (left-down at target, 8 interpolated
drag moves, up at the to target; duration_ms accepted, fixed per-event pacing in
v1; CG has only a LeftMouseDragged event type, so a non-left button is an honest
nack) and the decomposed mouse_down/mouse_up halves (left/right; an
omitted target acts at the live cursor position, resolved at post time). The
structured-observation family (observe, invoke_action, set_value) is
Linux-only in v1: on macOS no tree source is attached, so those verbs answer the
typed structured_observation_unavailable error (the AXUIElement tier is the D14
follow-up).
Keyboard model, mirroring the X11 backend's xdotool-style names:
type_textis layout-independent: the text travels as a unicode string on the keyboard event (CGEventKeyboardSetUnicodeString), chunked at 20 UTF-16 units (surrogate-safe) — any character types correctly regardless of keyboard layout.keycombos resolve against a static US-ANSI table (kVK_ANSI_*): all printable ASCII (both shift levels),enter/return,tab,escape,space,backspace,delete(forward delete, matching X11'sXK_Delete), arrows,home/end,pageup/pagedown,capslock,f1–f12. Modifiers:ctrl,shift,alt/option, andsuper/meta/cmd/command/win→ Command (⌘). Modifier key events are posted around the key with cumulativeCGEventFlags, and a key whose US-ANSI level needs Shift gets an implicit Shift press (soctrl+A≡ ctrl+shift+a), like X11. Names with no mac equivalent (insert,numlock,printscreen,menu) fail honestly instead of pressing a wrong key.- Known limitation: combo keycodes assume the US physical layout; a non-US layout
types combos against the wrong physical keys. The follow-up is layout-aware mapping
via
UCKeyTranslate/TISCopyCurrentKeyboardInputSource.
# terminal 1
export SHINKEND_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
echo "$SHINKEND_TOKEN" # copy this value
cargo run --manifest-path shinkend/Cargo.toml -- --backend macos
# terminal 2
export SHK_TOKEN="<copied token>"
python scripts/macos_smoke.py # non-destructive: ready/screen_size/screenshot/hover
python scripts/macos_smoke.py --unsafe # adds click + type_text + key on the LIVE desktopThe smoke is deliberately non-destructive by default (it runs against your real
desktop): it asserts the platform, the ready payload, plausible screen_size, a PNG
screenshot whose dimensions equal screen_size, and a pointer hover at screen center.
Exit 2 = runtime healthy but TCC grants pending (with the typed refusal asserted).
14 cfg-gated unit tests (cargo test on a mac host; Linux CI never compiles them):
keycode/modifier mapping, verb dispatch via the pure planner (plan_action — the mock
seam: actions are planned as typed event sequences and asserted without posting),
text chunking, target validation parity with X11, the capture blit's color/row order
(synthesized CGImage, TCC-free), plus live-host probes for geometry and readiness
honesty. The Linux build is cross-checked (cargo check --target aarch64-unknown-linux-musl) to stay untouched.
- AX tree (
AXUIElement) observation: out of scope. The Linux a11y engine (#2/D3) defines the structured-observation contract; the macOS implementation follows it.element_reftargets bail on macOS native v1; Linux/AT-SPI is the built reference implementation for guest-side ref resolution. - ScreenCaptureKit: the capture upgrade path above (also unlocks dirty-rect screencasts and cursor-change events).
- Event-driven capture:
damage_cursor()isNone→ screencast loops poll-capture and the delta path diffs frames (the same correctness, more guest CPU than XDamage on Linux). - CI: local-only. A macOS runner job (unit tests + the non-destructive smoke under a pre-granted runner image, or unit tests only) is the follow-up.
