DeviceTerm Architecture
August 10, 2026 · View on GitHub
This file is the source of truth for how DeviceTerm fits together. Update it in the same PR that changes the architecture; reviewers reject PRs that add daemon RPC methods, framework-boundary changes, or new private-API selectors without a matching update here.
Overview
DeviceTerm is a macOS-native terminal whose window content is a terminal pane,
optionally alongside one or more mirrored device panes: iOS Simulators, or
connected iPhones and iPads, including network-paired ones, driven over
the CoreDevice tunnel.
The mirrors render and accept input as panes alongside the shell that
started them.
The terminal pane spawns its shell in the GUI process via
libghostty's posix_spawn; the daemon is not in the PTY path.
The system splits across three processes:
flowchart TD
subgraph gui["DeviceTerm.app (GUI, NSApp)"]
AD[AppDelegate] --> DC["DaemonClient (lazy spawn + connect)"]
AD --> WC["WindowControllers (one per NSWindow)"]
WC --> TS["TabStripViewController (custom strip + content swap)"]
TS --> TC["TabContentViewController (one per tab)"]
TC --> CS[PaneLayoutViewController]
CS --> TP["TerminalPaneViewController + GhosttyTerminalSurface (one session each)"]
CS --> SP["SimulatorPaneViewController 0..N (MTKView)"]
end
subgraph dmn["deviceterm-daemon (LSUIElement, no Dock icon)"]
XS["XPCServer (mach service)"] --> MR
RS["RPCServer (UDS, length-prefixed JSON)"] --> MR
MR["MethodRegistry (one registry; scope gates validated-GUI methods to XPC)"]
MR --> SM["SessionManager (actor)"]
MR --> DCo["DeviceCoordinator (actor)"]
MR --> PC["PaneCoordinator (actor)"]
MR --> SE["ShimMethods (shim.event handler)"]
DCo --> CB["CoreSimulatorBridge (dlopen'd at startup)"]
PC --> CB
SI["NSStatusItem (menu bar)"] ~~~ IM["IdleMonitor (lifetime predicate)"]
end
subgraph cli["deviceterm-cli (symlinked into per-session bin/)"]
CLI["deviceterm tap / swipe / ax tree / panes list / attach"]
end
DC -->|"XPC (mach service)"| XS
CLI -->|"UDS (same JSON RPC)"| RS
The CLI and shim reach the daemon over the Unix domain socket it vends
alongside the mach service, at
~/Library/Application Support/deviceterm/daemon.sock. Configuration stays
in two domains: Ghostty defaults and files drive the terminal surfaces, and
~/.config/deviceterm/config drives app behavior (see "Configuration
domains").
Two binaries support this from the side:
deviceterm-shim: a wrapper forxcrunandsimctlsymlinked into each tab's per-sessionbin/directory. Spawns the real binary as a subprocess (inheriting stdio, forwarding signals), watches argv forsimctlboot/shutdown anddevicectlinstall/launch patterns, and postsshim.eventnotifications to the daemon when matched.deviceterm-probe: the compatibility probe. dlopens CoreSimulator, enumerates required classes/protocols/selectors, printsOKor a structured failure report, and exits non-zero if any are missing. The required-symbol inventory it checks is compiled into the bridge loader;Sources/CoreSimulatorBridge/as-tested.mdis the human-maintained ledger of the same set. It runs explicitly throughmake probe, as part ofmake verify, and by checklist convention before a release tag. The production app does not run it on first launch.
One binary sits entirely outside this system: the deviceterm-uitest
harness. It is a dev/test instrument, not a DeviceTerm process: it holds the
Screen Recording + Accessibility grants (so DeviceTerm.app never needs them),
runs as its own signed faceless .app launched independently of any tab, and
talks to DeviceTerm only the way any other automation would, through the
window server and the accessibility API, plus the ordinary deviceterm CLI.
It is not an RPC peer, has no place in the trust model, and never ships in a
release. See docs/BUILDING.md.
Process layout (.app bundle)
DeviceTerm.app/
Contents/
Info.plist # main bundle
MacOS/
deviceterm # the GUI executable
Frameworks/
Sparkle.framework
Library/
LaunchAgents/
com.deviceterm.daemon.plist # registered via SMAppService
LoginItems/
deviceterm-daemon.app/ # embedded helper bundle
Contents/
Info.plist # LSUIElement=YES
MacOS/
deviceterm-daemon
Helpers/
deviceterm-cli # symlinked into per-session bin/
deviceterm-shim # symlinked into bin/ as xcrun, simctl
deviceterm-probe # explicit compatibility check
Resources/...
The daemon is a helper bundle (LSUIElement=YES), not a top-level
executable. This lets it host an NSStatusItem without showing up in the Dock
or claiming the main menu. It runs as a user-scope LaunchAgent the GUI
registers through SMAppService.agent(plistName:): launchd demand-launches it
on the first send and it idle-exits once nothing needs it, which means no GUI
or CLI peer connected, no live mirror pane whose owning GUI is still alive, and
no DeviceTerm-owned sim still booted. An owned sim left running therefore keeps
the daemon up after every tab is gone, which is exactly what the status item's
iPhone-glyph count is for.
Daemon lifecycle
The daemon stays in the background. Two transports vend the same
MethodRegistry:
- XPC (mach service): the GUI's path. The GUI registers an embedded
LaunchAgent plist via
SMAppService.agent(plistName:); launchd holds the listener, demand-launches the daemon on the GUI's first send, and applies the plist'sKeepAlive={SuccessfulExit:false}policy to relaunch on abnormal exit only. - UDS (Unix domain socket): the CLI and shim path. Each terminal pane's
shell env carries
DEVICETERM_DAEMON_SOCKpointing at the socket the daemon vends alongside the mach service.
Spawn: demand-launched by launchd. The GUI registers the agent at launch (a no-op when already enabled); its first XPC send then demand-launches the daemon. UDS traffic can never demand-launch: the LaunchAgent declares only the mach service, so a CLI or shim call finds the daemon only because an in-tab caller implies a live GUI that already brought it up. Outside a tab the CLI reports it cannot connect.
Stay alive while: any GUI XPC peer connected OR any CLI UDS peer connected OR a non-terminal pane exists whose owner GUI is still alive (a mirror (sim or physical device) survives a momentary connection lapse, but a pane abandoned by a crashed GUI does not pin the daemon) OR any DeviceTerm-owned sim is booted. The status item keeps the sim clause visible: if the daemon is alive holding a sim, the user sees an iPhone glyph and the count N in the menu bar.
Idle exit: no 30 s poll has observed any condition busy for >60 s →
NSApp.terminate(nil). (Sampling is discrete, so the guarantee is "no
sampled activity for the window," not "continuously idle.") launchd's
KeepAlive is configured to not relaunch
on SuccessfulExit, so the daemon stays gone until the next demand-launch.
State recovery
GUI-restored, never disk-rehydrated. The daemon
persists no session, device, or pane state to disk: a same-uid-writable
file is untrusted input, so nothing authority-bearing may come from it. A
fresh daemon instance starts empty and holds a restoration barrier:
until a validated GUI re-supplies its inventory, an session.authenticate
for an unknown session returns the retryable -32002 (not the terminal
-32001), so an in-tab CLI keeps its bounded retry instead of pruning a
still-valid credential. There are two restart shapes:
- Daemon-only restart (the GUI stayed alive): a crash or idle-exit the
GUI outlived. On reconnect the GUI automatically re-supplies its
complete live session inventory via
session.restoreBatch(below) and re-binds each terminal (session.bindTerminal). Sessions come back because a live, signature-validated GUI asserts them, not because a file did. Only sessions and terminal provenance are restored. Existing mirror panes are not reattached automatically; stale pane IDs must be recreated through normal attach. Physical-device panes come back only through a fresh attach (explicit or shim-contextual). Owned sims are never auto-shut-down on a manifest's say-so. - GUI + daemon cold restart: both gone. No old session or pane is
restored. A fresh GUI creates new tabs through
session.create. Old on-disk simulator files (the GUI'sowned-udids.json) are an untrusted recovery hint that can only feed an explicit, human-confirmed orphan prompt: never automatic attribution, attach, or shutdown.
Pane re-attach always flows through the normal pane.create path: bridge
clients (display/HID/AX/Purple) can't survive a restart, so a fresh record
with live clients is rebuilt rather than resurrected. The sim resurrect
path (detach + re-attach in place, gated on the sim still being booted)
covers a sim that
shut down under a live daemon and later re-booted; after a daemon restart
a stale sim pane fails and is re-created only by explicit action.
Physical-device panes attach through explicit action or the shim's
contextual auto-attach; they are never auto-resurrected.
Crash recovery
XPCDaemonConnection observes XPC_TYPE_ERROR on
invalidation, drops every in-flight request and subscription, and lets the
next send trigger a fresh xpc_connection_create_mach_service → launchd
demand-launch. On that reconnect the GUI re-runs the wire-version handshake
first (a Sparkle update may have swapped in a daemon with a different wire
contract; only a definite version mismatch routes to remediation, while a
transient handshake failure (the daemon still coming up after its respawn)
is retried durably with capped backoff, never mistaken for an incompatible
helper and never abandoned). Remediation for a definite mismatch issues
daemon.shutdown to the incompatible daemon and awaits the ack (so a
Sparkle-replaced bundle can't leave the old helper alive to be reconnected to),
then surfaces the critical quit/reopen alert, the same startup and reconnect
both take. It runs at most once per client lifetime: a latch drops any
second remediation (a superseding reconnect handshake that re-detects the
mismatch), and marking the client incompatible fences the transport so no
new peer, hence no further handshake, can be installed. It can't loop,
double-alert, or restore against the dying daemon. If the shutdown isn't
acknowledged the alert says so honestly rather than claiming the helper
stopped. On a successful (same-version)
reconnect the GUI instead runs the session-restore transaction (durably
retried until the daemon echoes the exact inventory) before its terminals
rebind, so terminals recover. Restoration is not a full transport barrier, so
ordinary calls may race it. After a daemon restart, live panes do not
recover automatically: the pane record is gone and the subscription fails
until the pane is recreated. A connection-only drop recovers by itself
through each pane's resubscribe loop.
IOSurface delivery
Surfaces ride the XPC channel as side-band payloads
paired with the JSON surface.changed evt. The side-band carries the
subscriptionToken; the JSON event rides its subscription's request-envelope
id, which the GUI maps to that token (via the mapping the subscribe ack
installs). Either way each pair resolves on (paneId, sequence, subscriptionToken), so two subscriptions on one pane never cross-deliver. The
daemon wraps each IOSurfaceRef via
IOSurfaceCreateXPCObject and the GUI resolves it with
IOSurfaceLookupFromXPCObject: zero-copy, no kIOSurfaceIsGlobal mirror
surface, no per-process visibility. The pairing threshold is 250 ms,
swept roughly every 100 ms; a JSON-only timeout yields a (_, nil) event
and the GUI holds its last good frame. For a device pane the surface is leased;
see "Surface lifecycle" under Data flows for the ownership contract that
keeps a slot from being overwritten while the GPU still reads it.
RPC protocol
Framing. Over UDS, length-prefixed JSON: [uint32 BE length][JSON bytes]; a newline-delimited alternative does not compose with
binary-adjacent payloads. Over XPC the same JSON envelope rides as the
data field of an xpc dictionary, and libxpc supplies the message
boundary, so no length prefix is used there. Any future binary payload is
encoded as a base64 JSON string. The current method set has no binary
payloads. Sim renders travel as XPC-marshalled IOSurface objects on a
side-band message, never inline bytes (see "IOSurface delivery").
Version identity. DaemonProtocolInfo.wireVersion identifies the RPC
contract shared by the app, daemon, bundled CLI, and shim. The GUI uses it to
detect an incompatible helper during an update and replace that helper
cleanly. It is an internal coordination version, not a separate end-user
compatibility promise. The public CLI and JSON contract follows the release
version and is documented in docs/INTEGRATION.md.
Envelope:
{
"id": <uint32, monotonic per client>, // omitted on a notification
"type": "req" | "res" | "evt",
"method": "<name>", // req or evt only
"params": {…}, // method-specific
"result": {…} | "error": {code, msg} // res only
}
One-way notifications. A request with no id is a fire-and-forget
notification: the dispatcher runs the handler and sends no response
(there is no correlation key to reply on). The device-pane surface-lease
methods ride this shape: pane.surfaceRelease (the cumulative watermark
ack) and pane.surfaceDrain (subscription teardown, not an ack). The
behavior lives in RPCEnvelope (decode maps an absent id to nil; encode
omits it) and both dispatchers (RPCConnection, XPCConnection): a
missing id routes to the one-shot handler with every reply suppressed.
pane.surfaceDrain is additionally intercepted by the XPC connection
before generic dispatch, since its target, the subscription task keyed by
the originating pane.subscribe request id, lives on the connection and
no registry handler can resolve it. Responses and events
always carry the id of the request they answer.
Shared types (single source of truth). Every finite wire value and
client-facing result shape is defined once in the Foundation-only
DaemonProtocol module, shared by the daemon, GUI, CLI, and shim: method
names (RPCMethod), event names (PaneEventName), the PaneLifecycle /
PaneCloseMode / HardwareButton / Orientation / DeviceListScope /
ShimEventType enums, env-var names (DeviceTermEnv), the DeviceFamily
enum (lenient: family stays a String? on the wire so a newer
daemon can introduce a family without breaking an older client), and the
per-method result/event structs (DeviceListEntry, PanesListEntry; one
method's types per file). Daemon-only private-API mappings (e.g.
HardwareButton.bridgeValue → CoreSimulator) stay daemon-side in
extensions; DaemonProtocol never links CoreSimulatorBridge.
Streaming model. Server-streamed events reuse the request's id for
every event frame. The client distinguishes a final res from a streaming
evt by type. The GUI's pane subscription is consumer-pulled: control
events (state.changed / orientation.changed) queue losslessly in order,
while surface.changed is held latest-only (a stalled consumer coalesces to
the newest frame, since that's what would be drawn anyway), so a surface
burst can never evict a control event. Surfaces are bounded latest-only;
control events queue losslessly with no configured bound (the queue drains as
the consumer pulls).
Reconnect / resubscribe. Each pane's SimulatorPaneViewModel drives its
own subscription in a retry loop: when the daemon connection drops mid-stream
(the connection finishes the stream on XPC invalidation), it backs off and
re-subscribes: the XPC transport auto-reconnects (launchd demand-launches the
daemon), and the daemon replays current pane state on a matching paneId. A
resubscribe that fails with a transport error (a drop during the subscribe or
its re-authentication) is itself retried after the same backoff. It stops
retrying only on a deliberate terminal state (shutdown/failed) or a terminal
daemon/protocol error from the resubscribe (a missing pane binding after the
daemon restarted; only a fresh attach recovers it). CLI clients are
short-lived and don't resubscribe.
Method reference. The method names below are the single source of truth
RPCMethod enum (Sources/DaemonProtocol/RPCMethod.swift): the daemon
registry is keyed by its rawValues and clients build requests from its
cases, never raw method-name literals. Two drift guards hold this section to
the code: a DaemonTests guard asserts the registry's keys exactly equal
RPCMethod's cases, and a CLITests guard asserts every case has an entry
heading below. A new method lands with a new entry.
Each entry lists the wire shape and the scope the dispatcher enforces:
- daemon-wide: any connection, authenticated or not.
- session: an authenticated connection (a valid cap joined by a matching provenance arm, re-checked on every request; see "Provenance & trust model").
- validated GUI: the signature-validated GUI peer over XPC; UDS can never reach it.
- orchestrator tab: a session holding a live orchestration grant, re-checked per request. Over UDS any granted, provenance-checked session qualifies; over XPC only the validated GUI peer does.
Results shown as {ok} are the shared ack shape. Optional fields are marked
? and are omitted from the wire when absent unless an entry says
otherwise.
Daemon methods
daemon.ping
- Params:
{} - Result:
{version, pid} - Scope: daemon-wide
Used for the wire-version handshake.
daemon.capabilities
- Params:
{}(body ignored) - Result:
{role, allowedMethods, wireVersion, linkagePolicyVersion} - Scope: daemon-wide
Discovery method advertising the caller's role and the methods they may
invoke. Authority is the provenance-checked connection, not the request
body: no (sessionId, cap) is read from the payload, so a stolen cap can't
surface a victim's role or grant advertising.
role is descriptive metadata only. The orchestrator surface in
allowedMethods follows the session's live orchestration grant and
transport, never its role, so advertising matches exactly what dispatch
enforces: a granted agent session is advertised the orchestrator verbs, and
an ungranted orchestrator session is not.
Works with or without an authenticated connection: an unauthenticated
(out-of-tab) connection gets role: null plus the daemon-wide subset. It
powers the role line deviceterm help prints above its command list (the
list itself is never filtered) and deviceterm doctor's allowedMethods
axis. linkagePolicyVersion is the forward-compatibility slot for
linkage-policy changes; nothing consumes it yet.
daemon.events
- Params:
{} - Result:
{ok}, then a stream ofdaemon.eventframes - Scope: session
Session-scoped event subscription: the caller sees its own session's pane
state changes and session lifecycle, plus device boot/shutdown events,
which go to everyone (a udid leaks nothing device.list doesn't). The
audience is filtered daemon-side by an internal EventAudience, never on
the wire; the validated GUI peer spans sessions and sees every event. An
unauthenticated caller is rejected at the scope gate.
Each frame's method name is daemon.event and its params are one flat
{type, ts, paneId?, udid?, state?, sessionId?, shortId?, name?} object
with absent keys omitted. type is one of pane.stateChanged,
device.booted, device.shutdown, session.created, or
session.closed. Powers deviceterm events.
daemon.shutdown
- Params:
{} - Result:
{ok} - Scope: validated GUI
Flushes the {ok} ack, then exits via NSApp.terminate so
applicationWillTerminate unlinks the socket cleanly; the ack flushes
first so the GUI can tell an accepted shutdown from a transport loss.
Its sole production use is the GUI terminating an incompatible old helper
after a definite update-related wire mismatch. Sparkle can replace
DeviceTerm.app while a daemon holding an owned booted sim stays alive
(quitting the GUI does not necessarily stop it); without this, a
relaunched GUI could repeatedly reconnect to the same incompatible daemon.
The GUI issues it, awaits the ack, then follows the quit/reopen
remediation; the next launch demand-launches the updated helper.
No UDS caller, session credential, orchestration grant, or unvalidated XPC peer can reach it. That closes the unauthenticated confused-deputy surface, though it does not claim to prevent every same-uid, signal-level DoS. Ordinary daemon lifecycle still uses idle exit, not this method.
Sessions
session.create
- Params:
{label?, name?, role?, initialPrivate?} - Result:
{sessionId, capability, shortId, name?, role} - Scope: daemon-wide
The capability is a 32-byte token, returned only here. The caller uses it
to authenticate the connection (session.authenticate), not to prove
provenance; see that entry.
The daemon captures the creating process's kernel identity (the "owner"
provenance arm) server-side from the transport peer, the audit token on
XPC or the LOCAL_PEERTOKEN identity on UDS, so no caller-supplied owner
pid rides on the wire.
shortId is a 6-char Crockford base32 identifier the --tab <ref>
resolver uses. name is stored verbatim from the request and never
renamed afterward; the GUI supplies a worktree-derived branch when it
detects one. role defaults to "agent".
Every XPC session.create, whatever the role, must come from the
validated GUI: XPC is the GUI's transport, and only a validated session is
marked restorable (its close leaves a tombstone; see
session.restoreBatch). An unvalidated XPC peer is refused with a
verdict-stability split: a transient .unavailable verdict (the signature
walk couldn't complete) returns the retryable notReady so a recoverable
blip self-heals, while a stable .rejected signature mismatch (a rogue
peer) is a hard roleViolation.
role: "orchestrator" is additionally refused outright over UDS; only the
human GUI mints it, so the human-only escalation property is enforced by
the daemon, not by convention. UDS agent creates carry no GUI validation
and are unaffected.
initialPrivate (default false) seeds the privacy flag in the same actor
turn the session is inserted. The GUI passes true for a terminal joining
an already-private tab, so the new session is never observable as public
on tabs.list; a follow-up toggle would race the create's own publish.
session.authenticate
- Params:
{sessionId, cap} - Result:
{ok, role} - Scope: daemon-wide
Binds the calling connection to the named session for its lifetime; the
dispatcher then authorizes session-scoped methods from the connection's
auth state rather than per-call creds. A few legacy handlers
(session.close, panes.list, pane.create, device.attach,
shim.event) still carry (sessionId, cap) in their params and
re-validate it, and device.boot optionally carries the pair for
ownership attribution, validated the same way when present. The dispatcher
intercepts this method on both
transports; the registry holds a deliberately unreachable stub so the
drift guards and daemon.capabilities still see it.
A valid cap is necessary but not sufficient: the cap is inherited env,
readable by any same-uid process (ps -E), so possession alone can't
authenticate a session. The daemon additionally checks the peer's kernel
identity against one provenance arm (the validated GUI on XPC, the exact
process that created the session, or the session's bound terminal on UDS
via session.bindTerminal) and installs the principal only if one
matches.
Three authentication outcomes exist once the params parse (a malformed
sessionId or cap is invalidParams before any of them). ok means a
provenance arm matched. error.unauthorized
(-32001) means a stale or wrong cap, or a wrong terminal; it is
terminal, don't retry. A distinct retryable -32002 covers a live session
whose terminal anchor hasn't been bound yet (right after a restart), a
transient XPC validation blip, or an unknown session on a fresh daemon
still awaiting its validated-GUI restore batch (the restoration barrier;
see "State recovery"), so a stale in-tab CLI keeps its bounded retry
instead of pruning a still-valid credential. Once a restore batch, even an
empty one, completes, an unknown session is terminally -32001.
Provenance is re-checked on every scoped request, not just at authenticate, so closing a session or revoking its anchor invalidates an already-authenticated socket. Re-auth on an already-authenticated connection replaces the prior state, last-write-wins.
session.bindTerminal
- Params:
{sessionId, foregroundPid, ttyName} - Result:
{ok} - Scope: validated GUI
The peer's audit token is the authority, so no (sessionId, cap) rides on
the wire. The GUI reads its terminal surface's foreground process pid and
controlling tty from libghostty (ghostty_surface_foreground_pid /
ghostty_surface_tty_name) and sends them here so the daemon can derive
and store a terminal anchor: the POSIX session id, controlling TTY device,
and session-leader start time, re-derived from the kernel. The raw pid is
verified then discarded, never retained.
A later in-tab UDS caller's session.authenticate matches its own kernel
identity against this anchor. That is the "terminal" provenance arm: it
lets a non-owner in-tab process (the CLI, the shim) authenticate as the
session, while an out-of-tab cap thief on a different POSIX session or tty
cannot.
The GUI re-binds after a reconnect or daemon restart; the anchor store is in-memory and lost on restart, and a connection teardown revokes the anchors it issued. Idempotent; binding a different terminal to a live session is refused.
session.close
- Params:
{sessionId, cap, mode?} - Result:
{ok} - Scope: session
Tab close converges here. The daemon currently ignores mode ("detach"
or "shutdown"); the GUI fans out device.shutdown itself when the user
picks "Shut Down".
session.restoreBatch
- Params:
{sessions: [{sessionId, capability, shortId, role, name?, isPrivate}], revision} - Result:
{restoredCount, sessionIds} - Scope: validated GUI
The audit token is the authority, and the issuer plus each restored
session's owner are captured from the validated XPC peer server-side,
identical to session.create, never from the payload. Only revision is
a wire field: the epoch is the XPC connection id and the tier is a
server-side constant, both derived at dispatch.
A validated GUI re-supplies its complete live session inventory, both to bring sessions back to a fresh daemon after a daemon-only restart (the sole path by which sessions come back; nothing is rehydrated from disk) and as ongoing authoritative reconciliation whenever its live session set changes. The daemon re-derives each session's non-recoverable verifier from the supplied bearer cap, so the in-tab cap keeps authenticating.
The batch is authoritative, (epoch, tier, revision)-fenced, and
all-or-none. The connection id is the epoch and the GUI's monotonic
revision orders same-connection retries; a restore carries the lower
restore tier, which sits below the live tier of any session.create
membership stamp or session.setPrivateBatch at the same epoch. A
strictly older restore key is rejected; an equal key may replay
idempotently.
It is otherwise validated in full: a malformed UUID, cap, or short-id, an
in-batch duplicate id or short-id, a verifier conflicting with a live
session, or a short-id colliding with a different live session are all
rejected invalidParams. It then applies as one atomic actor segment.
Absent sessions are inserted. A live session the complete inventory omits
is reconciled away as an abandoned ghost (a lost session.close) when
this batch's key strictly dominates the key that last asserted it, so a
session that a newer connection or a higher-revision same-connection retry
drops is reaped, while a live session.create (higher tier) that merely
raced this restore survives. A live session's privacy is updated under
this batch's key, so a newer restore corrects it while a
session.setPrivateBatch the user made after the restore still wins by
its higher tier.
A session closed since the inventory was captured is not resurrected: a
close tombstone fences it. Session ids are unique, so a closed id is dead
for good; the GUI retains a closing terminal until session.close
returns, so a concurrent retry can still list it. Only a session a GUI
restore could ever list is tombstoned, one the validated GUI minted or
restored: a UDS or unvalidated-peer session (an agent's own, or an
attacker churning create/close) never appears in a GUI inventory, so its
close never tombstones.
Every legitimate GUI session is restorable from creation: an XPC
session.create from an unvalidated peer is refused with the retryable
notReady (the GUI retries rather than minting a non-restorable session),
and a validated inventory that lists an existing session also confirms it
restorable. A transient validation failure (an .unavailable verdict,
carried through the dispatch context) is retryable everywhere, including
the orchestrator mint, so a recoverable blip doesn't fail opening a tab;
only a stable signature mismatch is a hard refusal.
The only sound reclaim for a tombstone is a restore that omits the id. The GUI's restore loop is serial (it awaits each reply before the next), so once the daemon processes a restore omitting an id, every earlier restore has already been processed and every later one was captured after the close, so none can resurrect it, even a restore still parked in XPC validation or scheduling. XPC dispatch is non-FIFO, so a transition-count or wall-clock expiry would be unsound.
To bound the tombstone set between reconnects, restoreBatch is ongoing
authoritative inventory reconciliation, not restart-only: the GUI's
InventorySyncCoordinator, the single restoreBatch caller, re-supplies
the live inventory whenever the workspace session set changes, so a closed
session's tombstone is reclaimed by the next omitting inventory. It uses
bounded coalescing: one batch in flight, a fixed coalescing window, a
synced watermark that advances only on a verified echo, a forced follow-up
after a mutation mid-batch, failures that stay dirty and retry, and
steady-state syncs that fire no terminal rebinding.
Privacy is seeded fail-closed in the same actor turn, so a mid-transition
tab restores private, never briefly public. Entry order defines the
restored set's tabs.list order. Processing any non-stale batch,
including an empty one, releases the restoration barrier (see
session.authenticate and "State recovery"). The GUI re-sends its whole
inventory with a fresh revision on every reconnect. The bearer cap is
never logged or interpolated into an error.
session.setPrivateBatch
- Params:
{sessionIds, isPrivate, revision} - Result:
{applied, revision, isPrivate} - Scope: validated GUI
The peer's audit token is the authority: no (sessionId, cap) handshake
rides on the wire, and a UDS caller is refused with
error.role_violation.
Atomically flips the privacy flag for every session backing one tab. The daemon validates that all ids name live sessions before mutating, then applies the whole set in one actor turn, so a multi-terminal tab can never end up torn, some private and some public.
The daemon is the ordering authority for last-write-wins. It pairs the
client's revision with a server-derived epoch (the monotonic XPC
connection id) into an ordering key and, all-or-none, applies the batch
only when that key strictly dominates every target session's last-applied
key; otherwise it returns applied: false without mutating. This lets the
GUI stop serializing sends: an older write arriving late, even across an
XPC reconnect or a GUI restart replaying low revision numbers (which the
higher epoch defeats), loses.
isPrivate is the desired absolute state, idempotent on retry. A returned
applied: false (stale) is not an ordinary success: the GUI commits
presentation only from an applied: true reply and reconciles ambiguous
outcomes via session.privacySnapshot, never from a request-time
snapshot.
A private session disappears from tabs.list for every caller except the
owner; the daemon's sessions(visibleTo:) filter reads the dispatcher's
originatingSessionId task-local. The GUI is the only legitimate caller:
it resolves a tab to its session set and applies the owner check GUI-side
before building the batch.
session.privacySnapshot
- Params:
{sessionIds, revision} - Result:
{fenced, revision, sessions: [{sessionId, state}]} - Scope: validated GUI
An ordering-fenced authoritative read of tab privacy, under the same
audit-token authority as session.setPrivateBatch. In one actor turn the
daemon snapshots each session's confirmed state ("public", "private",
or "missing") and, iff the request's (epoch, revision) key strictly
dominates every live requested session's key, advances them all to that
key without changing privacy.
That advance is the fence: a delayed older write now fails the dominance
check and returns applied: false, so the snapshot the GUI receives
cannot already be obsolete. fenced: false (a newer authority already
exists on some session) means the states may be about to change, so the
GUI treats the result as unresolved.
Read-only: privacy is never mutated. The GUI reconciles tab presentation
from a fenced, uniform-public result (else the tab stays hidden and
unresolved) after a definite rejection, a stale applied: false, or a
superseded indeterminate send.
session.setDisplayTitle
- Params:
{sessionId, title: String|null} - Result:
{ok} - Scope: validated GUI
The peer's audit token is the authority; no capability rides on the wire,
and a UDS caller is refused with error.role_violation.
Publishes the tab's live label (the shell's OSC 0/2 title, a manual
rename, or whatever else won the GUI's title precedence) in the optional,
bounded, normalized form tabs.list serves in place of the static name
stamped at session.create. It is omitted whenever it would say nothing
name does not already, and readers fall back to name whenever it is
absent.
The GUI is the only writer by construction: it is the only process that
sees OSC sequences. It pushes under the tab's primary terminal's session
(a tab holds N terminals with N sessions; the other sessions carry no
displayTitle), coalesced in fixed 150 ms windows. A fixed window rather
than a resetting debounce means a continuously-retitling shell still
flushes once per window instead of being postponed indefinitely. The GUI
republishes each live tab's title after a reconnect, once the session
inventory has been re-supplied, so the push lands on a session the daemon
holds. That republish is required because the daemon cache is memory-only:
a daemon restart or connection replacement would otherwise leave
tabs.list reporting the session name until the next OSC event, which
may never come.
A refusal that says the transport structurally can't accept the method
stops that tab's publisher rather than earning a refusal for every title
the shell emits; how long it stays stopped follows the cause. Not being
the validated GUI is stable for the life of the process (either the
--smoke UDS fallback, which carries no audit token at all, or an XPC
peer whose signature check came back a stable mismatch; an ephemeral
verdict gets its own retryable code), so that stop is permanent. A daemon
predating the method is a property of that daemon (an idle exit or a
crash replaces it without the GUI restarting), so that stop is scoped to
the connection, and the reconnect republish re-arms it.
title is optional and a null clears it: normalization can reduce a
non-empty title to nothing, so the clear is transmitted as an explicit
JSON null rather than skipped, or the previous label would outlive the
value that replaced it. sessionId must name a live session; otherwise
error.unauthorized with its own message, since this method carries no
capability and the shared "invalid sessionId or cap" would name a factor
the caller never sent. A stale queued push therefore can't accrete titles
for dead sessions, and the entry is dropped when the session closes.
The title is normalized on both sides of the wire: C0/C1 controls,
line/paragraph separators, and every Default_Ignorable_Code_Point
stripped. Using the property rather than an enumerated deny-list covers
Bidi_Control overrides, the invisible-but-not-whitespace scalars (soft
hyphen, ZWSP, word joiner, BOM), and the tag characters used for
invisible-text smuggling, with narrow exceptions for the joiners and
variation selectors that are necessary inside a visible cluster. The
result is NFC-composed and truncated on grapheme boundaries to a 256-byte
budget: client-side to bound the payload, daemon-side as the enforcement
that holds regardless of client.
A title that survives as nothing, or as nothing that renders, normalizes
to absent. That last check extends the guarantee to the blank scalars no
Unicode property identifies, namely U+2800 BRAILLE PATTERN BLANK, which
is handled as a space: trimmed at the edges, never counted as content,
but kept between braille characters where it is the word separator.
A push arriving from a connection strictly older than the one that last wrote that session's title is dropped: handler tasks are not FIFO, so a push admitted on a connection the GUI has already replaced could otherwise resume after its successor's and reinstate a superseded label.
tabs.list
- Params:
{}(body ignored) - Result: bare array
[{sessionId, shortId, name?, displayTitle?, label?}] - Scope: daemon-wide
One entry per live session; sessions are minted per terminal pane, not per
tab. name is the session's worktree branch captured at session.create;
shortId is the 6-char handle used by --tab <ref>. The result is a bare
array, not a wrapper object.
displayTitle is the GUI's live tab label as last pushed via
session.setDisplayTitle, in the optional, bounded, normalized form
described there. It is not an identifier and never resolves a
--tab <ref>, since it changes as often as the shell redraws its prompt;
readers fall back to name whenever it is absent. It is absent when no
GUI has pushed one (including after a daemon restart, until the GUI
republishes), when the tab's label carries nothing beyond name (the
label is the name, or the GUI's generic fallback for a tab with no name,
no title, and no known directory), and for the non-primary terminals of a
split tab. For a non-private session it is public metadata: a program
that emits an OSC title is publishing that string to every observer that
can see the tab.
A fresh daemon starts with no sessions (nothing is rehydrated from disk);
the list is populated by session.create and by the validated GUI's
session.restoreBatch after a daemon-only restart, so it never
accumulates a cross-restart graveyard. Private sessions are visible only
to their owner.
panes.list
- Params:
{sessionId, cap} - Result: bare array
[{paneId, udid, state, family, shortId, name?, capabilities, target}] - Scope: session
The session's panes. The payload (sessionId, cap) is provenance-checked
and must name the connection's own session; the validated GUI peer is the
sole cross-session exception. family is the coarse device
class; capabilities and target are as in pane.create.
Backs deviceterm panes list and the CLI's pane resolution: input
commands resolve their target paneId through this (default is the
session's sole device pane, --pane to disambiguate).
Devices
device.list
- Params:
{scope: "owned"|"all"} - Result: bare array
[{udid, name, state, ownedBySession?, family, deviceType?}] - Scope: daemon-wide
family is the coarse device class (watch/phone/pad/tv/
unknown), classified by the daemon from the device type. deviceType
is the human-readable device type name (for example "iPhone 17 Pro"),
absent only when the bridge couldn't read it. state is the
CoreSimulator state name: "Creating", "Shutdown", "Booting",
"Booted", "ShuttingDown", or "Unknown". The result is a bare
array, not a wrapper object.
device.boot
- Params:
{udid, sessionId?, cap?} - Result:
{ok} - Scope: session
Returns when CoreSimulator accepts the boot intent. When sessionId and
cap are both provided, the boot is recorded as owned by that session,
feeding device.list({scope: "owned"}) and the menu-bar count. Omitting
both skips ownership attribution; the connection must still be an
authenticated session either way, so there is no unauthenticated boot
path. Providing one without the other is invalidParams.
device.shutdown
- Params:
{udid} - Result:
{ok} - Scope: daemon-wide
Carries no credentials: UDS access is user-scoped and the user can
already run xcrun simctl shutdown directly; disowning on shutdown is
bookkeeping.
device.attach
- Params:
{udid, sessionId, cap} - Result: as
pane.create - Scope: session
Transfers ownership of an already-booted udid to (sessionId, cap) and
creates a sim pane in one shot. family, the coarse device class, is the
uniform source the GUI sizes from, since every attach path returns it;
capabilities and target are as in pane.create.
This sim attach keeps its legacy {udid, sessionId, cap} param shape,
the known exception; the newer physicalDevice.attach uses
connection-auth instead. Orphan re-attach and most pane creation use this
rather than pane.create, which only creates the pane and leaves daemon
ownership pointed at any prior owner.
physicalDevice.list
- Params:
{}(body ignored) - Result: bare array
[{deviceId, name?, model?, osVersion?, available, unavailableReason?}] - Scope: daemon-wide
Every physical device devicectl list devices reports, including
network-paired ones whose tunnel is down; empty when devicectl reports
none, and a failed enumeration degrades to an empty roster. The
enumeration is usbmux/lockdown (no Device Hub needed), so deviceId is
the device's real UDID; model and osVersion disambiguate two devices
that share a name.
Not a mirror-capability probe: available is always true today (every
connected device is selectable), and mirror capability is judged at
attach. available/unavailableReason are the forward slot for a future
picker that pre-greys rows via an async per-device probe. Feeds the GUI
"Mirror Physical Device…" picker. Device availability leaks nothing
tab-private, hence daemon-wide.
physicalDevice.attach
- Params:
{deviceId, sessionId?} - Result: as
pane.create - Scope: session
Mounts one physical device as a pane. The originating session is read
from the connection's authenticated context, the connection-auth
convention for new verbs; contrast the legacy sim device.attach. The
optional sessionId is attribution, not a credential: it is honored only
for the signature-validated GUI peer, which spans sessions, and ignored
for UDS callers.
Attached through explicit action or the shim's contextual auto-attach,
never auto-resurrected. A device whose iOS is too old to
mirror (the catalog vends no displayservice) returns invalidParams with
a "needs a newer iOS" reason; an absent or locked device returns a clear
invalidParams/serverError. family reads unknown for device panes;
the GUI sizes them from the surface stream instead.
devices.list
- Params:
{}(body ignored) - Result: bare array
[{id, kind, name?, model?, osVersion?, state?, attached, ownerSessionId?}] - Scope: session
The aggregate live roster: booted owned sims plus the physical devices
devicectl reports, each annotated with whether a pane the caller can
see mirrors it. kind is "sim" or "device". model and osVersion
are physical-device only, from the cheap tunnel-down
devicectl list devices enumeration, and disambiguate two
connected devices that share a name.
Not a simctl list or devicectl list clone: it never enumerates
shutdown or never-booted sims; the value added is the pane and ownership
layer. The ownerSessionId annotation obeys the same private-tab opacity
rule as tabs.list: a device attached only in a private session the
caller doesn't own reads as attached: false. Backs
deviceterm devices list.
Pane lifecycle
pane.create
- Params:
{sessionId, cap, kind: "sim", udid} - Result:
{paneId, scale?, family, shortId, name?, deviceType?, pixelWidth?, pixelHeight?, capabilities, target} - Scope: session
kind is reserved as a discriminator; only "sim" is valid, and udid
is then required. family is the coarse device class
(watch/phone/pad/tv/unknown) so the GUI can size the pane.
capabilities is the per-pane control set
(touch/key/text/button/rotate/crown/accessibility) clients
gate affordances on, plus location, which records backend support and,
like the other flags, gates a client affordance: the Device ▸ Location
submenu, through PaneControlAffordance.location. target discriminates
sim from device, encoded externally tagged: {"sim": {"udid"}} or
{"device": {"deviceId"}}.
Both are skew-tolerant: a client that doesn't know them ignores them, and
a peer that omits the block leaves the client on missingBlockFallback.
The added location flag reads false when absent from a present block;
the original fields remain required.
Orphan re-attach and most pane creation use device.attach instead: this
method only creates the pane and leaves daemon ownership pointed at any
prior owner.
pane.closeById
- Params:
{paneId, mode?} - Result:
{ok} - Scope: session
Daemon-internal primitive used by the GUI Router's fan-out (tab and
window close per-pane shutdown) and by SimulatorPaneViewModel's in-pane
shutdown. The user-facing pane.close (under Workspace verbs) is the
ref-based wire shape that flows through the Intent layer. "detach", the
default when mode is omitted, drops the pane and leaves the sim
running; "shutdown" also shuts down the sim.
Pane input
Every input verb is session-scoped and pane-targeted: the paneId is
authorized per request through PaneCoordinator.authorize, so a session
reaches only its own panes and a foreign paneId is a hard reject
indistinguishable from an unknown one.
Three verbs are live streams of contact events driven by the GUI
(touch, multitouch, edgeTouch). key sends discrete key-down and
key-up events; the remaining verbs are one-shot inputs or daemon-paced
gestures. A physical-device pane supports the subset advertised in its
capabilities block, with one gap today: on a device pane touch covers
the single-finger verbs only, and the two-finger pinch/multitouch are
rejected by the backend (surfacing as error.bridgeFailed). Device panes
report crown: false; every sim pane advertises crown, and restricting
it to watch families is a client affordance concern
(PaneControlAffordance), not a daemon gate. Edge gestures ride the
simulator's edge tags on a sim and enriched system-gesture reports on
physical hardware. Coordinates are normalized display coordinates.
pane.input.tap
- Params:
{paneId, x, y} - Result:
{ok} - Scope: session
A replayed single tap at normalized coordinates.
pane.input.touch
- Params:
{paneId, x, y, phase: "down"|"move"|"up"} - Result:
{ok} - Scope: session
The GUI live-drag path. down and move send continued Indigo contact
(tapDown); up releases contact (tapUp). Coordinates may fall
outside [0, 1] for bezel-origin edge gestures.
pane.input.swipe
- Params:
{paneId, fromX, fromY, toX, toY, durationMs?, holdMs?, startHoldMs?} - Result:
{ok, dispatched?: "tap"|"drag", steps?, durationMs?} - Scope: session
The scripted interpolated gesture path; direct GUI drags use
pane.input.touch. durationMs (default 200) paces the interpolated
motion. holdMs (default 0) adds an active dwell at the end point: the
daemon re-reports contact there at a coarse ~30 Hz cadence, each report
nudged a sub-pixel amount so the synchronous HID send completes, then
lifts, so the OS sees the finger decelerate to a stop while still down,
which a plain swipe-and-lift can't express. startHoldMs (default 0) is
the same active dwell at the start point, before the motion begins.
The ack reports how the daemon dispatched the gesture (tap or drag),
the interpolation step count, and the effective duration. A current
daemon always sends all three; they are optional only for old-daemon
skew.
pane.input.edgeSwipe
- Params:
{paneId, fromX, fromY, toX, toY, edge, durationMs?, holdMs?} - Result:
{ok} - Scope: session
An edge-tagged drag that drives the OS system gestures (home indicator,
App Switcher). Distinct from swipe because each contact carries the
originating screen edge (the raw IndigoHIDEdge value; bottom = 3,
live-confirmed): that tag is what routes the drag to the system
recognizer instead of the foreground app, where a plain swipe is eaten
as a scroll. On a sim it is built through the true 6-arg
IndigoHIDMessageForMouseNSEvent(…, NSSize, IndigoHIDEdge) prototype
with MouseDragged motion samples.
On a physical device the interpolated trajectory doesn't apply: the
backend opens the App Switcher via a scripted system-gesture swipe on
the device's touchscreen, with the relay driving the swipe geometry, and
the coordinator keeps a Home double-press fallback for backends without
that path. The GUI "App Switcher" menu item and
deviceterm app-switcher ride this.
pane.input.edgeTouch
- Params:
{paneId, x, y, phase: "down"|"move"|"up", edge} - Result:
{ok} - Scope: session
The per-event analogue of pane.input.touch carrying the originating
screen edge: a live GUI mouse drag starting in the displayed
bottom-edge band streams these (down → edgeTouchDown,
move → edgeTouchMove, up → edgeTouchUp) so the App Switcher
follows the cursor, where edgeSwipe plays a fixed trajectory. On a
sim, unlike plain touch, which collapses down/move to tapDown,
each phase maps to its own primitive: the per-phase NSEventType (down,
dragged, up) is what the system recognizer needs. On a physical device
each contact is sent as an enriched system-gesture report (the relay
adds the trailer and nanosecond timestamp), so the interactive drag
works there too.
pane.input.longPress
- Params:
{paneId, x, y, durationMs?} - Result:
{ok} - Scope: session
A replayed press-and-hold at normalized coordinates; durationMs
defaults to 500.
pane.input.key
- Params:
{paneId, keyCode, down} - Result:
{ok} - Scope: session
keyCode is a macOS HIToolbox kVK virtual key code (NSEvent.keyCode);
the daemon translates it to the USB HID usage code Indigo expects (see
KeyboardInputMap.kVKToHIDUsage). down selects press or release.
pane.input.button
- Params:
{paneId, button} - Result:
{ok} - Scope: session
button is one of home, lock, side, applePay, siri, or
digitalCrown (the watchOS crown press; rotation is
pane.input.crown).
pane.input.rotate
- Params:
{paneId, orientation} - Result:
{ok} - Scope: session
orientation is portrait, portraitUpsideDown, landscapeLeft, or
landscapeRight. A successful rotate also broadcasts
orientation.changed to every pane.subscribe subscriber.
pane.input.pinch
- Params:
{paneId, fromF1X, fromF1Y, fromF2X, fromF2Y, toF1X, toF1Y, toF2X, toF2Y, durationMs?} - Result:
{ok} - Scope: session
A replayed two-finger gesture interpolated between per-finger start and
end points; durationMs defaults to 300. The interactive counterpart is
pane.input.multitouch.
pane.input.multitouch
- Params:
{paneId, phase: "down"|"move"|"up", points: [{id, x, y}]} - Result:
{ok} - Scope: session
The GUI live two-finger path (Option-drag pinch and rotate). down and
move send continued Indigo two-finger contact (twoFingerDown); up
releases (twoFingerUp); there is no daemon-side interpolation.
points is exactly two contacts (the bridge is two-finger), ordered
finger 1 then finger 2; any other count is invalidParams. id is a
forward-compat per-finger identity. Coordinates may fall outside
[0, 1]; a mirrored finger can land off-screen.
pane.input.text
- Params:
{paneId, text} - Result:
{ok} - Scope: session
Replays the string as keyboard input.
pane.input.crown
- Params:
{paneId, delta, velocity?, durationMs?} - Result:
{ok} - Scope: session
watchOS Digital Crown rotation. delta is signed: the sign is the
direction (positive = forward/down) and the magnitude is the distance in
the bridge's raw crown unit. durationMs (default 0 = single send)
sub-steps a large rotation over the duration at roughly 60 Hz.
velocity is accepted but currently ignored; the SimulatorKit builder
takes only a delta. Backed by the optional, watchOS-only
IndigoHIDMessageForDigitalCrownEvent.
Pane accessibility
pane.ax.tree
- Params:
{paneId} - Result:
{tree} - Scope: session
tree is the recursive element tree; each node is
{role, frame, label?, identifier?, subrole?, value?, children}, and
the daemon may add a root-level note.
On watchOS the recursive walk returns {children: []} even when
elements are present (an AXPMacPlatformElement limitation); the daemon
annotates such responses with
tree.note = AXTreeNote.watchOSEnumerationUnsupported. Agents enumerate
via pane.ax.sweep instead.
pane.ax.point
- Params:
{paneId, x, y} - Result:
{element} - Scope: session
Returns the element under the normalized 0..1 point.
pane.ax.sweep
- Params:
{paneId, step?} - Result:
{tree}with a synthetic root - Scope: session
Grid-walks pane.ax.point over a normalized step (default 0.05; the
daemon clamps to [0.02, 0.5], since finer would monopolize the actor
for minutes and block other pane ops). The result shape mirrors
pane.ax.tree with a synthetic root {role: "AXSweepRoot", frame: {x:0,y:0,w:1,h:1}, children: [unique elements], step, sweepedPoints}. The step field echoes the actually-used post-clamp
value.
Per-cell "no element at this point" is a routine outcome: sparse AX
coverage (a Canvas + GeometryReader composition with a few Text(...)
nodes) returns the actual sweep even if children is empty, and
sweepedPoints confirms the grid ran.
The bridge's objectAtPointNil code conflates "blank pixel here" with
"AX server unreachable," so to preserve the retry signal for the latter
the sweep first probes frontmostTree() once. A bridge throw from that
probe surfaces immediately as error.bridgeFailed (code -32020), while
a torn-down backend surfaces as the usual pane-not-active
invalidParams; a return,
even one with {children: []} on watchOS where the recursive walk is
limited, proves the bridge is alive and per-cell misses are legit. A
systemic error mid-sweep (a non-code-78 throw from elementAtPoint)
also surfaces as error.bridgeFailed, distinct from the catch-all
serverError (-32000), so machine consumers can dispatch without
substring-matching the message. Closes the watchOS enumeration gap.
Pane location
The scope on both methods is what makes "Location simulation is a GUI affordance, enforced by scope" (below) a dispatch fact rather than a convention.
pane.location.set
- Params:
{paneId, location} - Result:
{ok} - Scope: validated GUI
Applies a simulated GPS position. location is a SimulatedLocation,
externally tagged: {"cleared":{}},
{"coordinate":{"latitude","longitude"}}, {"scenario":{"name"}}, or
{"route":{"spec":{"mode","speed","waypoints"}}}. One method rather
than four because these are four values of one device property. A route
is the user's own .gpx and a scenario is one of Apple's built-in
trips; they differ only in who supplies the waypoints, so both are
values here rather than separate verbs. The GUI parses the file and
sends waypoints, never a path, so the daemon needs no GPX parser and
never reads a user file.
RouteSpec is validated in the handler like a coordinate is: at least 2
waypoints, at most RouteSpec.maximumWaypoints (10,000), a positive
finite speed and cadence, and every waypoint on the globe, all reported
as invalidParams. An off-globe waypoint additionally names its index,
since a route is a list and "latitude out of range" alone leaves the
caller to find which of several thousand points is wrong; the arity,
speed, and cadence defects describe the route as a whole and carry no
index. Latitude and longitude are range-checked in the handler
(invalidParams); JSON cannot carry NaN, so non-finite values can't
arrive over the wire.
Scenario names are validated per backend, and both backends answer a
typo the same way (invalidParams, "unknown location scenario"). The
simulator backend pre-validates against its own enumeration, because
CoreSimulator's setLocationScenario: accepts an unknown name, reports
success, and changes nothing. The device path lets devicectl report
its own rejection (exit 1), classified from the structured
--json-output error payload (com.apple.dt.CoreDeviceError 20001)
rather than stderr's English sentence, and translated into backend
vocabulary before it leaves the backend.
Concurrent sets serialize per pane, and the tracked value is committed
under a dedicated location fence that advances only on committed
changes: an ownership-transfer commit, a shutdown, or a failure. It is
deliberately not the pane's general ownership/liveness epoch: that one
is bumped speculatively when a transfer starts and is never restored
when a transfer aborts, so fencing on it would throw away a location
that reached the device while owner and backend stayed put, leaving the
checkmark pointing at somewhere the device has already left. The same
fence lets pane.location.state discard scenarios enumerated from a
backend that was retired mid-read.
Backend and bridge failures reach the wire as error.bridgeFailed
(-32020) rather than the catch-all serverError, including raw
CoreSimulator bridge errors; validation failures stay invalidParams.
The error names the verb the caller invoked: a command that failed while
running reports location.set, while a genuine acquisition failure
reports location.acquire.
Backends: a sim goes through the SimLocation bridge wrapper; a
physical device shells out to xcrun devicectl device simulate location. Each invocation waits for devicectl to exit and retains no
keepalive process: the simulation continues on-device afterwards,
including a multi-minute scenario, and a later clear still finds it
active.
pane.location.state
- Params:
{paneId} - Result:
{location?, scenarios} - Scope: validated GUI
location is what DeviceTerm last set, not a device reading: neither
backend exposes a getter (CoreSimulator vends only setters plus
availableLocationScenarios; devicectl has no read verb), so the
daemon tracks its own writes and nothing else. An out-of-band change
(Simulator.app's Features ▸ Location, a raw simctl location call, an
Xcode scheme's default location) leaves it stale, undetectably.
location is absent when DeviceTerm has no claim, and absent is not
{"cleared":{}}: cleared positively asserts the simulation was
cleared, while absent says only that DeviceTerm does not know what the
device is simulating. Clients render absent by checking nothing.
The claim is dropped to absent at fresh create (nothing has been
written, and an attached device may already be simulating something), at
the ownership-transfer commit (a new owner inherits no claim), and on
shutdown or failure (also the reboot path, since a live reboot runs
shutdown then boot); close needs no reset because it removes the record.
Every reset is bookkeeping only. None sends a clear to the device, which
is exactly why none may report cleared: a transferred pane goes on
simulating whatever the prior owner set.
scenarios is the device's named trips in its own order, and is
legitimately empty for a device that isn't running; a simulator
enumerates none until booted. A failed enumeration degrades to empty
rather than throwing, so the tracked location still reaches the caller,
but not silently: unreadable tool output is a distinct backend error,
logged at error level naming the pane as a schema mismatch rather than
silently appearing as an empty trip list. An unreachable or not-running
device stays routine and logs at debug. Reads answer for a shut-down
pane instead of faulting, because the GUI builds its menu from this.
Pane surface streaming
pane.subscribe
- Params:
{paneId} - Result:
{ok, subscriptionToken?}, then a stream of pane events - Scope: session
The stream carries three event methods, each correlated to the subscription's request-envelope id:
state.changed:{paneId, state}, withstateone ofbooting,rendering,shutdown, orfailed.surface.changed:{paneId, sequence}, paired with a side-band surface payload for the same(paneId, sequence).orientation.changed:{paneId, orientation}, broadcast when a rotate lands.
The initial ack returns a subscriptionToken on every XPC subscription:
the correlation key for the connection's side-band lane and, for a
device pane, the pool lease token. UDS mints none.
pane.surfaceRelease
- Params:
{paneId, subscriptionToken, leaseEpoch, lowestHeld} - Result: none (one-way notification, no
id) - Scope: session
The GUI's cumulative low-water-mark ack for a device pane's leased
surface pool: "I hold no generation below lowestHeld; free the
committed ones below it." lowestHeld is the minimum of the held set,
or one past the highest received when empty.
Authority is the peer connection, not the payload: the daemon reads the source connection id from its dispatch context, and the pool rejects any ack whose connection differs from the token's registering connection. UDS peers register no token, so their acks are a counted no-op.
pane.surfaceDrain
- Params:
{paneId, subscribeRequestId} - Result: none (one-way notification, no
id) - Scope: session
Tears down a surface subscription, keyed by the originating
pane.subscribe request id so it works even before any token or
side-band exists. Transport-intercepted on XPC; a no-op over UDS.
Shim events
shim.event
- Params:
{event, sessionId, cap, udid?, deviceName?, runtime?, invokedAs?, argv?, deviceIdentifier?} - Result:
{ok} - Scope: session
event is "booted", "shutdown", or "deviceAttach".
The shim's connection must first authenticate: a valid cap on a live
session plus its kernel terminal provenance matching the session's bound
terminal (the shim runs inside the tab, so it does). The payload
(sessionId, cap) is additionally required to name the connection's own
session, so a stolen cap can't forge attribution for a victim. A cap
mismatch, a wrong terminal, or a foreign payload session are all hard
rejects. The method doesn't appear in unauthenticated callers'
daemon.capabilities response.
booted and shutdown carry the sim udid and mutate ownership;
invokedAs and argv describe the intercepted command. The shim treats
the reply as best-effort and never blocks the intercepted command on it.
deviceAttach is the physical-device contextual auto-attach: the shim
saw xcrun devicectl device install|process launch --device <id>
succeed and sends deviceIdentifier (that --device spec); the daemon
resolves it to a connected device and publishes the same pane.attach
back-channel command the GUI picker and deviceterm device attach use,
attributed to the calling session, one-mirror-latest-wins. Best-effort:
an unresolvable spec or an absent GUI still acks and never affects the
user's devicectl command.
App back-channel
The transport pair behind the workspace verbs; the full flow is under "GUI command back-channel".
app.commands
- Params:
{} - Result:
{ok}, then a stream ofapp.commandframes - Scope: validated GUI
The daemon-to-GUI back-channel for verbs that mutate GUI tab, pane, or window state. Reachable only over XPC from a signature-validated peer, so no UDS client can subscribe. It needs no authenticated session: the GUI subscribes at startup before any tab exists.
Each frame's method name is app.command and its params are one
AppCommand: {commandId, kind, originatingSessionId?, params}, where
kind is the workspace verb's wire name and params carries the verb's
JSON payload as a base64 string. The GUI replies via
app.commandResult.
Single-subscriber, pinned to the subscribing connection id: the GUI subscribes once, a relaunching GUI re-subscribes and takes over last-wins, and a stale teardown from the evicted connection can't unsubscribe the live GUI.
app.commandResult
- Params:
{commandId, status: "ok"|"data"|"error", data?, error?} - Result:
{ok} - Scope: validated GUI
The GUI's per-command reply on the back-channel; data is the verb's
JSON payload as a base64 string, error is {code, message}. Accepted
only from the current subscriber connection: a result from any other
connection is refused with error.role_violation, so a second local
process can't forge replies. The daemon's AppCommandCoordinator keys
pending continuations by commandId and resumes the matching awaiting
CLI handler. A 5 s timeout surfaces a wedged or absent GUI as
intent.guiUnavailable.
Workspace verbs
CLI workspace verbs flow through the back-channel above: the daemon
validates authentication, the dispatcher binds
SessionDispatchContext.originatingSessionId (a task-local) for the
publish-verb handler, and a typed AppCommand is published and awaited;
the GUI's reply becomes the caller's result. The CLI never threads
credentials in params; the wire shapes stay clean.
Refs on the wire are typed {type, value?} objects: a tab ref's type
is one of current, sessionId, shortId, or name; a pane ref's is
one of current, paneId, udid, or shortId; a window ref's is one
of current, index (the value is the stringified index), or keyed.
current resolves origin-aware: an external caller's .current is its
own tab or window, not the human's key window, and .index counts the
caller-visible window projection (see "GUI command back-channel").
Every verb here is session-scoped except windows.list, which stays
daemon-wide.
tab.open
- Params:
{window?, role, cwd?, cmd?} - Result:
{ok} - Scope: session
Opens a tab, optionally in a given window and working directory, running
an optional command. role rides the wire, but the GUI translator
forces agent regardless of the value; an orchestrator tab is opened
only from the GUI menu.
tab.close
- Params:
{tab, mode} - Result:
{ok} - Scope: session
Closes the resolved tab. mode is "detach" (keep sims running) or
"shutdown" (close panes and shut down sims).
tab.rename
- Params:
{tab, name?} - Result:
{ok} - Scope: session
Renames the resolved tab. For actions that don't fit the Route shape,
rename included, the GUI's IntentDispatcher calls an injected
IntentActionDelegate.
tab.select
- Params:
{tab} - Result:
{ok} - Scope: session
Selects the resolved tab.
tab.info
- Params:
{tab} - Result:
{sessionId, shortId?, name?, role, cwd?, label?, isCurrent, simPanes}; eachsimPaneselement is{paneId, udid, shortId?, displayName, family} - Scope: session
Read-only: the GUI answers inline from the workspace rather than dispatching a Route.
tab.move
- Params:
{tab, toIndex?, toWindow?} - Result:
{ok} - Scope: session
Reorders a tab within its window (toWindow nil, toIndex required) or
relocates it to a different window (toWindow set; a nil toIndex
appends). A toWindow that resolves to the tab's own window is treated
as a same-window reorder and still requires toIndex. Cross-window
relocation moves the tab's live view controller,
so the GUI performs it in the AppDelegate transfer coordinator rather
than the Router.
tab.setPrivate
- Params:
{tab, isPrivate} - Result:
{isPrivate, committed}or an error - Scope: session
Owner-only on the GUI side: the IntentDispatcher handler for
RouteIntent.setTabPrivate rejects when the resolved tab's terminals
don't include the caller's session id, judged by origin, so the human
menu always passes and an external caller must own a terminal in the
tab. The rejection is intent.notFound rather than anything that leaks
the tab's existence.
The GUI drives an awaited, fail-closed transition: a public-to-private
request hides the tab immediately, before any round-trip, while a
private-to-public one stays hidden until the daemon acks. The GUI
converges the daemon via session.setPrivateBatch (with a post-ack
membership recheck) and returns the real outcome.
committed: true means the daemon applied it. committed: false means
the requested state remains unconfirmed: a deadline, an indeterminate
transport loss, a same-state supersession, or tab disappearance. A
definite refusal or an opposite-state supersession is a command failure.
The CLI therefore reports the daemon's state, never an optimistic echo.
Ordering is daemon-enforced by the batch's (epoch, revision) key, so
the GUI does not serialize sends and a stalled or reconnected send can
never reorder. Presentation is fail-closed and driven only by
authoritative signals: a tab is exposed only by the owning transition's
highest-key make-public applied: true or a fenced uniform-public
session.privacySnapshot; a definite rejection or a stale
applied: false triggers a fenced-snapshot reconcile (the tab stays
hidden unless that snapshot is fenced and uniform-public) rather than
any local guess.
New terminals added to a private tab via Route.openTerminalPane
inherit the privacy bit atomically at creation (session.create with
initialPrivate: true), never a follow-up toggle.
pane.openTerminal
- Params:
{tab?, cwd?, cmd?} - Result:
{ok} - Scope: session
Opens a terminal split in the resolved tab; unlike the tab verbs, the
tab ref itself is optional here. The new terminal is its own session.
With no tab and no current tab, the GUI opens a fresh window and tab
instead, and cwd/cmd are dropped on that fallback because the
open-window route has no surface for them.
pane.close
- Params:
{pane, mode} - Result:
{ok} - Scope: session
The user-facing deviceterm pane close, flowing through the
back-channel so the ref is resolved against the GUI's live workspace.
The lower-level pane.closeById is the daemon-internal primitive the
Router uses for tab and window close fan-out; both coexist deliberately.
mode is "detach" or "shutdown".
pane.rename, pane.move
- Params: typed per verb
- Result: error
- Scope: session
Registered for forward compatibility but not implemented. The GUI
returns intent.internalError. The CLI carries success renderers for
both, but they remain unreachable because the GUI fails the call first.
pane.info
- Params:
{pane} - Result:
{paneId, udid, shortId?, name?, displayName, family, linkedSessionId} - Scope: session
Read-only: answered inline from the workspace.
pane.attach
- Params:
{target, relinkExisting?} - Result:
{ok} - Scope: session
Mounts a device pane by target (a PaneTarget): {"sim": {"udid"}}
claims an unlinked sim (an external sim, or one left over from a closed
agent tab); {"device": {"deviceId"}} mounts a physically-connected
device. The GUI dispatches the matching route through the back-channel.
relinkExisting (absent reads as false) decides what happens when the
target is already mirrored elsewhere. The shim's contextual auto-attach
sends true, one-mirror-latest-wins, a cross-tab move; explicit
deviceterm device attach sends false and keeps the cross-tab reject,
leaving the GUI drag as the relocation path.
window.open
- Params:
{} - Result:
{ok} - Scope: session
Opens a new window.
window.close
- Params:
{window, mode} - Result:
{ok} - Scope: session
Closes the resolved window. Refused if the target window also holds a
tab the caller can't see, so it can't tear down a co-hosted foreign
private tab. mode is "detach" or "shutdown".
window.focus
- Params:
{window} - Result:
{ok} - Scope: session
Focuses the resolved window.
windows.list
- Params:
{all} - Result:
[{index, isKey, tabCount, selectedTabShortId?}] - Scope: daemon-wide
Defaults to the caller's window; all: true returns every window in the
caller-visible projection. Windows and tabs private to another session
are omitted, and indices and counts cover only the visible ones. An
out-of-tab caller gets an empty list by default and the public
caller-visible projection with all.
Orchestration
orchestrator.grant
- Params:
{sessionIds, revision} - Result:
{applied} - Scope: validated GUI
Issues live orchestration grants (leases) for a tab's sessions, attributed to the issuing GUI connection, which is read server-side from the dispatch context, never the payload. Orchestration authority is the presence of a live grant, checked per request, never a persisted role: a forged role grants nothing, and nothing role-bearing is persisted at all.
Ordering is last-write-wins by an (epoch, revision) key, where the
epoch is the issuing XPC connection id, so a reconnected GUI dominates
and a stale request loses; a revoke leaves a tombstone so a late grant
can't resurrect it. Every target must be a live session before anything
mutates (invalidParams, all-or-none).
Grants are never persisted: they live only in memory, so the store is
empty after a daemon restart and can be repopulated only over this
validated-GUI connection. The GUI is the sole issuer: it grants an
orchestrator tab's session once that terminal binds, so the grant rests
on a live, terminal-bound session, and it reissues on reconnect after
the terminal rebinds. Revocation is implicit too: closing a tab or
terminal calls session.close, and the store revokes on session
removal.
orchestrator.revoke
- Params:
{sessionIds, revision} - Result:
{applied} - Scope: validated GUI
Revokes the live grants for the given sessions (tab closed or
downgraded), ordered by the same (epoch, revision) key. Revocation is
immediate: a socket authenticated before the revoke fails its next
orchestrator call, because the scope check reads live grant state.
Grants are also revoked automatically when the issuing GUI connection
disappears and when a session is removed.
Idempotent: a target whose session is already gone is treated as
already-revoked and stores nothing, so a late or spurious revoke can't
accrete tombstones. applied: true means revoked for every target, a
convergence rather than necessarily a state change; applied: false
comes back when a live target's stored key is at least as new as this
one. Dominance is strict, so an exactly-replayed revision loses too.
tab.sendInput
- Params:
{tab, text, typeDelayMillis?} - Result:
{ok} - Scope: orchestrator tab
Writes text into the resolved tab's terminal as though the user had
typed it. Authorization is a live orchestration grant for the session,
checked per request, never a role; a caller without a grant is rejected
at the dispatcher's scope check with error.role_violation.
Reachable over both transports for a session that holds a grant: a
validated-GUI XPC connection, or the CLI inside a granted orchestrator
tab, since a UDS session authenticates via cap plus kernel
terminal-process provenance. The GUI issues the grant when an
orchestrator tab's terminal binds and reissues it on reconnect, so
deviceterm tab send-input works from inside such a tab; an ordinary
agent tab holds no grant and is refused. Only escalation stays
XPC-GUI-only: grants are minted solely by the validated GUI
(orchestrator.grant), so a UDS caller can only exercise a grant it was
given, never issue one.
The receipt reports the target tab and the UTF-8 byte count, plus
typeDelayMillis in the JSON receipt, and never the typed text. The CLI
decodes C-style escapes (\n, \r, \xNN, and the rest) at parse time
so the documented examples drive the shell. typeDelayMillis, when
positive, makes the GUI animate the injection one Character at a time
(for screencasts) on a per-tab serial task; the reply returns as soon as
the animation is enqueued, non-blocking, so the back-channel drain and
the daemon's publish deadline aren't held for the typing duration. Zero
or absent is the instant one-shot. The CLI caps the value at 1000 ms.
tab.capture
- Params:
{tab} - Result:
{text} - Scope: orchestrator tab
Returns the resolved tab's currently-visible viewport as plain text;
there are no scrollback or line-count flags. Authorization is a live
orchestration grant, per request, never a role, reachable over both
transports exactly as tab.sendInput.
CLI human mode writes the captured text to stdout, appending a trailing
newline only when the text lacks one (so
deviceterm tab capture > screen.txt saves the screen); --json emits
the payload.
Location simulation is a GUI affordance, enforced by scope
docs/PHILOSOPHY.md principle #3 rejects reimplementing Apple's tools:
the CLI fills the gaps xcrun simctl leaves, and anything simctl already
does runs through simctl unchanged. The CLI's own reject list (the "no
simctl wrappers" text deviceterm agents prints verbatim to every agent
that asks, in Sources/DeviceTermCLI/AgentsText.swift) applies that
principle by naming location explicitly.
Location therefore ships as a GUI menu (Device ▸ Location, and the pane's
right-click menu) with no CLI verb: no CLICommand, no
VerbCatalog entry, nothing in the man page or completions.
Both pane.location.* methods are tagged .validatedGUI, which turns
that from a convention anyone could quietly break into a dispatch fact.
UDS carries no audit token, so MethodScope.validatedGUIReachable
refuses it unconditionally: no CLI, script, or in-tab agent can reach
location even by hand-rolling a frame, and daemon.capabilities never
advertises the methods to a UDS caller, so the promise holds at the
discovery surface too. PaneLocationScopeTests pins all of it in CI.
The accepted cost is that a GUI running on the --smoke UDS fallback
can't reach location either, exactly as it already can't reach
app.commands. That refusal is terminal, so the submenu renders one
disabled row rather than retrying on every open.
The menu's saved Locations section is entirely GUI-side state: it
comes from <config home>/deviceterm/locations, a hand-editable file the
daemon neither reads nor knows exists. The wire carries only the
resulting location value, a coordinate or a route's waypoints, never the
saved label or path, so the daemon never stores a user-chosen string and
never inherits the GUI's file-access posture. That is the same split
location already uses for everything else: the GUI resolves what the user
meant and pane.location.set receives plain numbers. LocationsFile
appends and nothing more: it preserves comments, blanks, and lines it
cannot parse byte-for-byte, so the file stays safe to hand-edit against a
version that may later understand more of it than this one does.
Coordinates in the file parse as POSIX; only the Custom Coordinates
sheet's typed input is locale-aware, and the two deliberately do not
share a parser.
Use My Location applies the same split to CoreLocation.
CoreLocationProvider is the only file in the app that imports the
framework, it is constructed lazily on the first click, and the daemon
links CoreLocation nowhere: the GUI resolves the Mac's position and
pane.location.set receives the same plain coordinate a typed one
produces. The permission prompt is therefore the GUI's alone:
NSLocationWhenInUseUsageDescription lives in DeviceTerm.app's
Info.plist, and a binary launched outside that bundle reports the missing
usage string instead of waiting on a prompt macOS will never show.
CoreLocation's permission rules are the piece held outside that file,
in LocationAuthorization, because two timing constraints in them need
explicit handling. The first is when anything may happen: a freshly built
CLLocationManager has not synced with the location daemon, and until it
has, reading its status or asking it for permission is unreliable. The
status can report notDetermined for an app that is already decided, and
a requestWhenInUseAuthorization() issued before the sync is dropped
outright, with no prompt and no callback. So the manager's first status
report is what starts a request, rather than being filtered out as
noise; a five-second fallback goes ahead anyway if that report never
comes. The second trap is what a report means: reports reach the main
actor like every other callback, so one can land after the request that
followed it, and no ordering flag distinguishes them. The status does,
since notDetermined is the state a request asks from and therefore
never an answer to it. A longer timeout covers a prompt that is never
answered, so the provider cannot be left permanently pending. Readiness,
those fallbacks, and sharing one request between concurrent callers stay
with the provider; only the permission rules move out, where they can be
tested without CoreLocation.
The whole action coalesces, not just the fix. A second click joins the
one already running, so one prompt yields one pane.location.set, one
refresh, and one alert. Coalescing only the acquisition would leave every
click owning its own set and its own alert, which turns a slow or failing
fix into a stack of identical modal alerts, on the one menu item people
are most likely to click twice.
Every non-fix the provider returns produces an alert
(UseMyLocationDecision maps each one, and offers the Location Services
deep link only for the two the user can actually resolve), and so does a
transient apply failure, including a set the device refuses. That is
why Use My Location and the .gpx rows await their
pane.location.set rather than dispatching it like the others. They do
so for different reasons. Picking a trip or a saved row leaves a visibly
unmoved checkmark beside the row just clicked, but the Use My Location
row never carries a checkmark: a success checks a matching saved row, or
appends a coordinate row elsewhere, so its failure would look like
nothing happening at all.
A .gpx row does have a checkmark and it moves normally; what it cannot
express is why a route did not play, which for a route is usually a
fact about the file. Three outcomes
stay silent instead: a scope refusal disables the entire submenu, which
is a louder and more durable answer than an alert; a missing client means
the app is tearing down; and a pane closed while the fix was in flight
has nobody left to tell. Those messages come through ErrorText rather
than localizedDescription,
which for a DaemonClientError returns Foundation's "operation couldn't
be completed" placeholder and discards the daemon's own code and message.
The controller's own existence is re-checked after the fix arrives and
before anything is sent, because a pane id outlives the tab that closed
it: an orphaned sim keeps its record through transferOwnership and can
be adopted by another session, and the GUI connection is .guiPeer,
which is authorized for any live pane. The window for that is real, not
theoretical, since an unanswered permission prompt holds the request for
a minute before it times out. Unlike a typed coordinate, a fix is
not appended to the
locations file: that file is a curated list DeviceTerm never evicts from,
and positioning jitter would have every use add another near-duplicate
row that only a hand edit could remove. The fix is still rounded to the
file's six-decimal precision, because the menu matches the daemon's claim
by exact equality and a full-precision Double could never check a row
read from the file, even for the same place.
GPX routes apply the split a third time, and are the case that makes
it a rule rather than a habit: the GUI parses the file and
pane.location.set carries a RouteSpec of plain waypoints, so the
daemon needs no GPX parser, never opens a path the user chose, and
inherits none of the GUI's file access. A locations line ending in
.gpx is a route (the path token, that is: a label may follow it);
~ expands and a relative path resolves against the
locations file's own directory, both at parse time, since that is the
last point that knows where the file lives. An unquoted path ends at the
first space and a path with spaces is double-quoted, rather than a
heuristic deciding where it stops: a.gpx b.gpx Morning reads two ways,
and a format that guesses is one that reads some lines the opposite way
from how they were written. Requiring the extension is
what keeps the append-only rule intact: treating every unreadable line
as a path would render a line written by a later version as a broken
route instead of preserving it.
GPXDocument reads <trkpt>, <rtept>, and <wpt>, preferring them in
that order, and reads them only in GPX's own namespaces: 1.1, 1.0, or
no namespace at all, which is what a file that declares none reports.
Namespace processing has to be switched on, since it defaults off and a
file is free to bind GPX to a prefix, in which case <gpx:trkpt> would
otherwise arrive as that literal string and match nothing. Switching it
on then reports a bare local name for every vocabulary, which is why
the namespace filter is its pair: without it a vendor's <v:trkpt>
inside <extensions> is indistinguishable from a real point, and one
carrying no lat/lon would fail the whole file. Xcode reads only
<wpt>, but exported routes record the path as a track and often carry
landmark <wpt>s beside it, so reading one element would reject most
real files or replay two points of interest instead of the journey.
A file whose points fall into more than one run is refused rather
than spliced, and every container boundary starts a run: a second <trk>
or <rte> for a separate outing, a second <trkseg> for a gap in one
recording. Bridging any of them fabricates a leg the device then walks in
a straight line, over a distance that also inflates the derived pace. No
threshold decides which gaps are benign, because that is the same guess
the route-line grammar refuses to make and wrong in the same way:
quietly, and only for some people's files. A malformed point fails the
whole file rather than being skipped, since dropping it would silently
shorten the journey and reroute it around the gap. GPXRouteMapper then
decides what the points mean: one point is a position rather than a
one-point route (both backends reject a route shorter than two), and N
points become a route whose speed is the file's average pace when every
point is timestamped, falling back to simctl's documented 20 m/s
otherwise.
That average is necessarily lossy. A GPX records a time per
point, so a real journey speeds up and slows down, while both backends
accept a single scalar speed for the whole route. A recorded run
therefore replays with the right start, finish, and duration and none of
its pacing. The loss is stated in docs/USAGE.md rather than silently
misrepresented. Routes are capped at RouteSpec.maximumWaypoints
(10,000), which is far below the 16 MiB framing cap and exists so an
oversized file fails with a sentence rather than as a framing fault.
The mapper is total and throws nothing: whatever points arrive become a
route, and RouteSpec.defect is the single thing that judges one, so the
GUI and the daemon share one validation vocabulary and the alert shows
the same sentence invalidParams would have carried. A file with no
points at all never reaches it, having already failed parsing as
GPXParseError.noPoints. RouteFileReader validates
before framing so the failure lands next to the row that was clicked
rather than as an RPC error a menu would only log; the daemon re-checks
regardless, because it does not trust a client.
A route row is the one row whose checkmark cannot be derived from the
claim. The claim carries waypoints and the row carries a path, and
connecting them would mean opening the file while a menu is being drawn,
so PaneLocationViewModel remembers which row produced the claim and
releases it the moment the daemon's claim stops being the one that row
applied. A claim it can't attribute to any row (a route started before
this GUI came up, or a pane adopted from another session) still gets a
row appended naming its size, per the never-lose-the-checkmark rule.
Choosing a route alerts on failure and coalesces repeat clicks for the
same reasons Use My Location does, and applying one saves nothing to the
locations file: it is already a line in it.
On the simulator side a route reaches SimLocation's two route
selectors, whose waypoints parameter is declared as a bare NSArray
and is in fact a flat array of alternating latitude/longitude
NSNumbers: four values are two waypoints. That shape was recovered
from simctl's own machine code rather than guessed (its parser splits
each lat,lon argument, boxes both halves with
+[NSNumber numberWithDouble:] into one array, then reports count / 2
as the waypoint total), and CLLocation was never a candidate because
neither simctl nor CoreSimulator links CoreLocation. The selector
validates nothing behind it, so the wrapper checks arity and element
class itself. A physical device takes the documented
devicectl device simulate location route --route-file path, whose JSON
flattens the mode into a string plus a sibling field; the translation
lives in DeviceCtlRouteFile and is pinned as literal bytes. The file is
written before the spawn and removed after the tool exits, and the route
goes on playing device-side afterwards, exactly as a scenario does.
Physical device panes
A connected iPhone or iPad, USB or network-paired, can be mirrored as a
pane of the same kind as a sim, driven through the OS CoreDevice tunnel.
The daemon's PhysicalDeviceCoordinator (an
actor kept separate from the CoreSimulator-bound DeviceCoordinator)
enumerates connected devices; a pane's identity generalizes from a sim
UDID to PaneTarget { .sim(udid) | .device(deviceId) }, and its
capabilities are reported per-pane (PaneCapabilities) since a device
supports a subset of a sim's verbs.
The physical-device client is split by responsibility: reach the device, bootstrap its channels, then relay interaction and feed decoded frames.
flowchart TD
DR[DeviceReachability] -->|DeviceRoute| CB[ChannelBootstrap]
DR -->|"DeviceRoute (UDP receiver addresses)"| MP
CB --> IR[InteractionRelay]
CB --> MP[MirrorPipeline]
MP -->|decoded frames| D[Daemon]
DeviceReachabilitycorrelates a selected device to the live OSutunthat carries it, producing aDeviceRoute. It owns no subprocess; the daemon feeds it adevicectl-backed address source.ChannelBootstrapowns byte transport, the HTTP/2 framing subset, binary object coding, the session handshake, service-directory discovery, and role-based channel acquisition. Its private vocabulary is the transport's: service identifiers, theChannelRole→service map, envelope keys, and framing constants. Its package-facing surface is theChannelBroker,ChannelRole, typed channels, the neutral orderedDeviceObjectpayload, the device identity, and the typedChannelBrokerError/WireCompatibilityError; a message selector is an opaqueStringthe consuming target supplies toDeviceChannel.invoke. The daemon reasons about roles, never Apple service names.InteractionRelayturns typed interaction intents (touch, keyboard, buttons, orientation) into device reports, keeping the HID layouts, gesture geometry, and its own feature selectors private. Intents that share a surface stay strictly ordered.MirrorPipelineowns display negotiation and the RTP/HEVC/VideoToolbox receive path, keeping the mirror's message selectors and codec constants private, and ending at decoded pixel buffers.
The implementation models the CoreDevice tunnel contracts directly and validates required handshake and media-stream fields before use; an unsupported required shape fails with a typed compatibility error instead of sending a guessed request. The video receiver discards an access unit with RTP loss, requests a keyframe, and only resumes decoding at a complete keyframe so damaged references cannot persist.
Device reachability is distinct from surface leasing. MirrorPipeline ends
at decoded frames and owns none of the surface machinery: the daemon
(RealDeviceBackend) copies each decoded frame into a LeasedSurfacePool slot,
stamps the optional trace, and publishes it through the acknowledged-lease loop
(see Surface lifecycle across the daemon/GUI boundary below). The mirror never
hands its decoder surface to the publish path.
DeviceTerm holds the tunnel itself; Device Hub and Xcode can stay
closed.
The OS (remoted) only keeps a device's utun up while a trusted client
holds a CoreDevice session. DeviceTerm is that client by proxy, borrowing
Apple's own signed devicectl: a benign blocking devicectl device notification observe --device <udid> subprocess (TunnelKeepalive,
ref-counted per device) parks a session that keeps the tunnel up, and is
SIGINT'd when the last pane mirroring the device closes. This stays
user-scope (no entitlement, no root, no helper). Enumeration uses
devicectl list devices (usbmux/lockdown, works with the tunnel down),
so a device is selectable the moment it's plugged in and trusted; the
tunnel is brought up lazily on attach and the device's tunnelIPAddress
(from devicectl list) correlates the UDID to its utun.
Three properties hold for physical panes:
- Real UDID, resolved tunnel.
deviceIdis the device's real CoreDevice UDID (fromdevicectl list devices), stable across reconnects and the same iddevicectl --deviceaccepts. The tunnel address is no longer the handle; it's resolved on demand at attach. Physical attach is explicit or shim-contextual, never auto-resurrected: the GUI picker,deviceterm device attach, or the shim'sdevicectlinterception mount it; nothing re-attaches on restart. - Connection-auth, not cred params.
physicalDevice.attachcarries nocap: the originating session comes from the connection's authenticated context, plus an optionalsessionIdattribution field honored only for the validated GUI peer (which spans tabs over one shared connection) and ignored from UDS. It does NOT copy the legacy simdevice.attach({udid, sessionId, cap})credential shape. - Private-tab opacity.
devices.list's ownership annotation reuses thetabs.listopacity predicate verbatim: a device attached only in a private session the caller can't see reads as unattached.
GUI command back-channel
The workspace CLI's AppCommand-backed verbs (the deviceterm tab,
pane, device attach, window, and windows families) need to read
or mutate GUI-owned state (tabs, panes, windows), state the daemon
doesn't hold. Both the CLI back-channel for these verbs AND a future
deep-link / AppleScript / URL-handler input boundary share the same
need: translate an external request into a typed intent that the
GUI's existing navigation machinery executes.
The architecture is a single Intent layer that every external source funnels through:
flowchart TD
SRC1["CLI (back-channel)"] -->|AppCommand| CT[CLIIntentTranslator]
SRC2["Deep link (future)"] -->|URL| DP["DeepLinkParser (future)"]
SRC3["Menu (NSMenuItem action)"] -->|"RouteIntent built inline"| RI
CT --> RI["RouteIntent (source-agnostic; refs by sessionId / shortId / udid / name)"]
DP --> RI
RI --> ID["IntentDispatcher: resolves refs to GUI IDs, validates, synthesizes Routes"]
ID --> R["Router.dispatch(Route), the unchanged surface"]
R --> AK["AppKit reconcile (existing)"]
Components:
RouteIntent(Sources/App/Intent/RouteIntent.swift): typed intent enum with one case per verb. Carries external refs (TabRef,PaneRef,WindowRef) rather than GUI-internalWindowID/TabID.IntentResolver: reads the workspace to translate refs.TabRef.currentresolves to either the CLI's authenticated session or the key window's selected tab.IntentDispatcher: single consumer. For mutating intents, synthesizes aRouteand dispatches viaRouter. For read-only intents (*.info,windowsList), reads from the workspace and returns the payload inline. For actions that don't fit the Route shape (rename), calls an injectedIntentActionDelegate.AppCommandSubscriber(Sources/App/AppCommandSubscriber.swift): the GUI's drain loop on theapp.commandssubscription. Translates eachAppCommandto aRouteIntent, dispatches, and replies viaapp.commandResultso the daemon coordinator can resume the originating CLI handler.AppCommandCoordinator(Sources/Daemon/AppCommandCoordinator.swift): daemon-side actor. Owns the subscription stream + a map of pending continuations keyed bycommandId. Per-verb handlers (AppCommandMethods.publishVerb(kind:)) callpublishAndAwait(...)to ship a command and block until the GUI replies (5s timeout →intent.guiUnavailable).SessionDispatchContext.originatingSessionId: task-local, bound by both dispatchers (RPCConnection,XPCConnection) around every handler call. The publish-verb handlers read it to stamporiginatingSessionIdon theAppCommand, so the GUI'sIntentResolvercan resolveTabRef.current/PaneRef.currentagainst the calling tab's identity (the CLI never threads creds in params; the wire shape stays clean).
Mutating intents use optimistic-ok semantics: the CLI gets "ok" once the Router has accepted the Route; the actual reconcile happens shortly after on the MainActor drain. Read-only intents return real data inline. If a user-visible race ever surfaces, routes can carry completion handles for stricter semantics.
Single-subscriber assumption: DeviceTerm runs one GUI process per
daemon. The coordinator's subscribe() replaces any prior
subscription (a relaunching GUI takes over cleanly); a daemon with no
subscriber returns intent.guiUnavailable immediately rather than
blocking. Multi-GUI support (multiple DeviceTerm processes against one
daemon) is not supported.
Tab lifecycle
A tab's startup sequence is:
flowchart TD
A["session.create: the daemon mints (sessionId, cap)"]
B["GUI provisions the session directory<br/>(bin/, zsh/, owner.pid)"]
C["GUI builds a libghostty TerminalCommand carrying the daemon-minted env"]
D["GhosttyTerminalSurface.attach: the shell spawns in the GUI process"]
A --> B --> C --> D
The command's env carries DEVICETERM_SESSION, DEVICETERM_SESSION_CAP,
DEVICETERM_DAEMON_SOCK, and DEVICETERM_SHIM_DIR, plus the ZDOTDIR and
PATH overrides.
The daemon is not in the PTY path. libghostty owns the master FD; the
GUI consumes bytes through GhosttyTerminalSurface directly. The daemon
sees the tab as a session only.
Sim panes piggyback on the existing session:
device.attach(udid, sessionId, cap) transfers ownership of the booted sim
and creates the pane, then pane.subscribe(paneId) starts the
state.changed and surface.changed event stream.
Tab semantics (and CLI scoping)
- One terminal pane = one session UUID. A tab holds one or more
terminal panes; the primary terminal is the tab's representative
session for tab-scoped operations (
tab info, sim-pane attribution), and tab-wide operations fan out over every terminal's session. - Tab close with an owned booted sim → app-modal alert (unless suppressed): Detach (default, keeps sims running), Shut Down Sims, or Cancel. A tab owning no booted sim closes silently in detach mode.
- ⌘Q with running sims → app-modal alert: Keep Running (default) or Shut Down All & Quit.
- CLI scope: the
DEVICETERM_SESSIONenv var inside a terminal pane's shell scopesdevicetermcommands to that pane's session.deviceterm panes listshows the calling session's own panes; there is no daemon-wide pane view over the CLI, because panes are owner-scoped:PaneCoordinator.authorizeadmits a.sessionprincipal only to panes whoseRecord.sessionIdmatches (the validated GUI peer alone spans sessions), and a foreign paneId is indistinguishable from an unknown one (botherror.not_found), so a leaked UUID names nothing reachable. - Cross-tab attach: explicit is refused, shim relink moves.
deviceterm device attach <ref>naming a device already attached elsewhere is rejected rather than relinked, and the GUI's pane drag rejects drops whose source tab differs from the destination. The one supported cross-tab move is the shim's contextual auto-attach, which rehomes the mirror latest-wins. One mirror per device. (Whole tabs can be dragged between windows; that's a separate path.) - No layout persistence. Each launch starts with one new tab.
Data flows
Surface lifecycle across the daemon/GUI boundary
The daemon sends the GUI an IOSurface per frame; the GUI aliases it as an
MTLTexture (MTLDevice.makeTexture(descriptor:iosurface:plane:)) and
samples it on the GPU. Zero-copy through kernel shared memory. There are two
frame kinds with different ownership contracts.
Simulator frames are unleased. CoreSimulator owns the current surface and may mutate or replace it, firing a change callback; the daemon retains and use-counts each callback surface and passes it through, and the GUI re-samples the alias at display rate. No lease and no acknowledgement control CoreSimulator's reuse; the sim's own producer/consumer timing is Apple's design.
Device frames are leased, because the daemon copies each decoded frame
into a pool slot it owns and later reuses that slot. A bare use count would
not prevent the daemon overwriting a slot while the GPU still reads it
(IOSurfaceIncrementUseCount is a recycling advisory, not a write lease), so
a stalled consumer could sample a coherent-but-wrong-generation frame. The
leased surface pool supplies the missing happens-before edge.
Vocabulary.
- generation: a per-pane monotonic
UInt64stamped on each acquired slot. It never repeats, so a stale acknowledgement naming generation g can only ever match the one grant of g. - epoch: a per-pool
UInt64bumped on resize and on controlled recovery. Old- and new-epoch leases coexist during churn, so holds, watermarks, and acks are keyed by(epoch, token). - token: the
subscriptionTokenminted per XPC pane subscription. For a device pane it also keys the pool's per-subscription lease state; sims carry it only for side-band correlation. - holder: a slot's holders are a set (
.daemonCurrent|.subscription(token)), at most once each. A slot returns to the free list when its holder set empties. - hold states: a grant is provisional after reservation, committed once promoted (before the send), and exposed once sent. Only an accepted watermark acknowledgement removes an exposed hold; orphaning pins outstanding holds while preventing new grants.
Producer (daemon). LeasedSurfacePool (device panes only) hands out the
least-recently-freed slot with a fresh generation, or drops the frame when
none is free (never blocks decode, never allocates unboundedly). Delivery to
a token runs as one per-token serial transaction so exposure order equals
reservation order: reserve a provisional grant (rejects an unknown/non-active
token, a generation at-or-below the reservation frontier, or one below the
accepted watermark) → revalidate → commit before any send (bumps the
committed frontier) → revalidate → send through a synchronous closure so
no actor reentrancy can interleave between the final check and the send. Any
pre-commit failure cancels the grant; a post-commit-pre-send failure revokes
it.
Consumer (GUI). A SurfaceLease (a final class) is built the instant a
leased side-band arrives: it bumps IOSurfaceIncrementUseCount and registers
with the release accountant immediately, so a release can never precede its
acquire even if the frame is later dropped. Everything that can be reading the
surface holds a strong ref: the view model, the content view, and every
in-flight Metal command buffer, via
commandBuffer.addCompletedHandler { _ in withExtendedLifetime(lease) {} }
added before commit. ARC drops the lease, and only then signals its release,
when the surface is no longer current and every command buffer that sampled
it has GPU-completed. Metal fires completion handlers even on GPU error, so the
ref is always eventually dropped.
Acknowledgement. Release is a cumulative, idempotent, one-way
pane.surfaceRelease notification carrying lowestHeld = the minimum
generation the GUI still holds for (paneId, token, epoch), or one past the
highest received when it holds none. The daemon frees every committed
generation strictly below it. A cumulative watermark is self-healing (a lost
ack is corrected by the next absolute one) and completion-order-agnostic (a
generation may outlive its successor; the watermark waits). Authority is the
peer connection, not the payload: the pool rejects any ack whose source
connection ≠ the token's registering connection.
Teardown never frees a held slot (a disconnect is not proof the GPU
finished). Each token is active → draining (graceful drain: no new grants,
acks still accepted) or orphaned (abrupt loss: holds pinned in place,
never returned to a free list) → closed only when no provisional and no
committed hold remains. Graceful drain is the one-way pane.surfaceDrain,
keyed by the subscribe request id so it works before any token or side-band
exists.
Bounded recovery. A delinquency watchdog only diagnoses; it never
force-reclaims a live lease. On sustained exhaustion the pool retires
(quarantines) the current epoch, preserving its held slots, and the next
acquire rotates in one fresh epoch; a quarantined surface is never reused. A
quarantined epoch becomes eligible for pruning once every logical holder
releases: subscription transactions cancel (provisional), revoke
(committed-but-unexposed), or receive watermark acknowledgements (exposed) as
appropriate, while .daemonCurrent ends when the final PublishedSurface
owner releases it, which can outlive currentSurface through in-flight
delivery work. Orphaned holds can persist until backend teardown. Recovery is
bounded to a single fresh epoch: a second bout fails the pane rather than
growing memory.
Kill switch. DEVICETERM_SURFACE_LEASES=0 disables per-frame leasing: the
daemon sends leased:false and takes no per-subscription holds; the GUI still
wraps each surface in an unleased SurfaceLease (onRelease: nil: no
use-count bump, no release sink) and sends no release acks, while keeping the
token and drain lifecycle. The pool still takes its .daemonCurrent hold
and the daemon still use-counts each surface via RetainedSurface; only the
per-subscription lease layer is off. The pool degrades to a plain
least-recently-freed ring.
Terminal pane (PTY ownership)
libghostty posix_spawns the shell from inside the GUI process. The GUI
hands GhosttyTerminalSurface a TerminalCommand whose env carries the
daemon-minted env (DEVICETERM_SESSION, DEVICETERM_SESSION_CAP,
DEVICETERM_DAEMON_SOCK, DEVICETERM_SHIM_DIR, ZDOTDIR, and the rest)
so the shell and anything it spawns can talk
back to the daemon. The daemon never sees PTY bytes. It does see two
kinds of process identity, both kernel-verified rather than PTY-derived:
the GUI's own audit token / LOCAL_PEERTOKEN identity on every connection
(the trust boundary below), and, via session.bindTerminal, the terminal
surface's foreground pid + tty, which it uses once to derive the session's
terminal anchor and then discards (only the POSIX session id, tty device, and
session-leader start time are retained). The session-dir manifest (owner.pid
= GUI pid; see "Per-terminal-session directory" below) remains the
cross-restart orphan-recovery link.
HID
GUI translates NSEvent → normalized 0..1 coords → pane.input.* RPC →
the daemon dispatches via SimHIDClient (Indigo wire format) for a sim
pane, or as InteractionRelay reports for a physical device. Sub-16ms
round-trip in practice.
Per-terminal-session directory
~/Library/Caches/deviceterm/sessions/<session-uuid>/
├─ bin/{xcrun, simctl, deviceterm} -> deviceterm-shim, deviceterm-cli
├─ zsh/{.zshenv, .zshrc, …} -> ZDOTDIR, strip-and-prepend PATH hook
├─ owned-udids.json -> sims attached to this session
└─ owner.pid -> GUI pid (the GUI owns the session
env; orphan recovery keys on this
pid being dead)
Mode 0700 on the directory. It holds the shim symlinks, the generated
ZDOTDIR, and the ownership manifest, not a socket: the daemon's one UDS
listener lives under ~/Library/Application Support/deviceterm/, where
macOS gates UDS access by directory permissions, not socket file mode.
Provenance & trust model
A session's capability is necessary but not sufficient. The cap (the
32-byte token from session.create, injected as DEVICETERM_SESSION_CAP into
the terminal pane's shell env) is deliberately readable by every process in
that shell's terminal, and therefore by any same-uid process (ps -E). So
possession of a cap does NOT by itself say anything about where the caller is
running. Authenticating as a session requires a valid cap on a live session
PLUS the caller's kernel identity matching one provenance arm
(ProvenanceMatcher):
- Validated GUI (XPC): the peer's audit token validates against the daemon's own code signature. The GUI spans every session it created.
- Exact owner: the peer is the exact process (pid, pid-generation, euid)
that created the session. Captured server-side at
session.createfrom the transport peer, never from a wire field. This is the GUI's own path. - Bound terminal (UDS): the caller's POSIX session id, controlling tty,
and session-leader start time match the session's terminal anchor. The GUI
binds that anchor via
session.bindTerminalfrom the surface's foreground pid + tty; the daemon re-derives it from the kernel. This is what lets a non-owner in-tab process (the CLI, the shim) authenticate while an out-of-tab cap thief, on a different POSIX session and tty, cannot.
session.authenticate installs the connection's session principal only after
an arm matches, and the check is re-run on every scoped request, so closing
a session or revoking its terminal anchor invalidates an already-authenticated
socket. A handler that additionally accepts a payload (sessionId, cap)
confirms the target equals the connection's own provenance-checked session;
only the validated GUI spans sessions. Anything running in a terminal pane's
shell is trusted to control that session, because it shares the controlling
terminal the anchor names; the cap is deliberately visible to it and must not
be filtered out before launching subprocesses. The arm matches on terminal
membership, not ancestry, so a descendant that detaches from that terminal
(setsid, a daemonized helper) no longer authenticates.
Revocation linearization
Revocation is defined by when a call linearizes against the loss of
authority. "Delivery" here means a daemon-side yield or committed surface
send (a continuation.yield for a JSON/event stream, a committed surface
send for the side-band lane), not a consumer read. AsyncStream.finish()
does not discard already-buffered events, so the guarantee is "no new
yield/send after the linearization point," while pre-revocation buffered
events remain legitimately readable.
- Orchestrator grants (one-shot verbs).
hasGrantis consulted per request. A call that passed the check may finish; the next call after the grant is revoked (tab closed/downgraded, issuing GUI connection lost, or session removed) is refused. In-flight finishes; nothing new begins. - Subscriptions (pane JSON events, the surface lane, and
daemon.events). A hard session removal (an explicitsession.closeor an authoritative-restore ghost omission) tears the closing session's subscriptions down before the close returns. Pane subscriptions linearize at the synchronous removal of the subscriber record and the registry entry (the registryunregisteris the no-further-send fence, ordered ahead of the suspending device-pool drain); an in-flight leased surface transaction either completes its already-committed send or, at its next revalidation, finds the entry gone and cancels/revokes without sending.daemon.eventslinearizes after the closing session's final self-directed.sessionClosedyield, delivered atomically with the removal of its subscribers in one actor turn, after which the id is retired. A validated.guiPeersubscription is never dropped by a session close, because the GUI spans sessions. The same revocation primitive backs ownership transfer (adoption), so an old subscriber cannot follow a pane into its new session; and the producer registries refuse a subscribe that slipped past the dispatch scope check and resumed after the close (an orphaned pane is owner-revoked until re-adopted; a retireddaemon.eventsid is refused until a restored incarnation reactivates it), so a late subscribe mints no surviving authority. - Soft anchor pause (accepted, documented). Losing the terminal anchor on
a still-live session is a retryable pause, not a hard revocation: a
missing anchor + live session + non-owner UDS peer yields the retryable
-32002, blocking new subscriptions and every new scoped/elevated call while an already-open stream keeps flowing for the whole pause, indefinitely until connection loss, session close, or daemon exit if a rebind never succeeds. Stream teardown is tied to hard revocation (session removal), not to this soft pause; this is the intentional GUI-disconnect / rebind model and is out of scope to harden.
Two further accepted limitations sit alongside these: device.shutdown is
.daemonWide and unattributed (parity with xcrun simctl shutdown; UDS is
user-scoped, so a same-uid caller can shut down any sim, a device-lifecycle
action rather than tab-private state); and the back-channel's cross-session
reach to any public tab (tab.close/select/move/rename, windows.list) is
the deliberate public/private split. tab.sendInput/tab.capture still
additionally require an orchestrator grant, and private tabs stay owner-scoped
and opaque.
UI framework boundaries
SwiftUI is the default for product UI: preferences, onboarding, empty
states, error/info banners, inspector-style side panels, simple sheets,
status/detail views, the About panel, and anything else whose shape is
"render state, dispatch a few actions." Reach for SwiftUI first; the
declarative shape simplifies the work and the NSHostingController /
NSHostingView integration surface with AppKit is small.
AppKit owns the hard native surfaces: the terminal pane (responder
chain, IME, input latency), the simulator pane (Metal rendering, custom
hit-testing for letterboxed content, multi-touch synthesis), the
NSStatusItem menu bar, the main menu, custom window/tab chrome (where
NSWindow/NSResponder machinery is essential), and any sheet that
needs precise pre-existing macOS behavior (modal alerts respecting
window-modality, file pickers).
The dividing line is responder-chain or rendering specificity. If a surface needs precise control over draw timing, input dispatch order, or responder chain participation, it's AppKit. Otherwise SwiftUI.
A PR that moves a surface across the line (SwiftUI → AppKit or vice versa) needs an explicit justification line in the description.
Input & key handling
This section sits beside the UI-framework boundary because the line it draws is input dispatch order.
A keystroke passes through three stages, in this order:
- View-hierarchy
performKeyEquivalent. AppKit offers the event to the key window's view tree first. A view returningtruehere claims the chord outright and the menu never sees it. DeviceTerm overrides this nowhere; the ordering alone gives it what it needs. - Main-menu key-equivalent matching. AppKit finds items whose chord
matches, calls
validateUserInterfaceItemon the responder chain, and fires the first enabled one. A disabled item does not consume the event. keyDown:down the responder chain, where the focused view receives it. Terminal panes forward to libghostty, while device panes synthesize HID for non-Command keys and pass Command-modified events back up.
Consequences the code depends on. An enabled menu item beats libghostty's own keybinds without any interception code, because libghostty only ever sees a key at stage 3. And a conditionally disabled item falls through to the focused pane instead of swallowing the event, so a shortcut can be gated on context without costing the focused pane that key.
KeybindingCatalog is the single source of truth
(Sources/App/Keybindings/). MainMenu owns menu structure and asks the
catalog for every bound item, so no key equivalent is spelled as a
literal anywhere else. A drift guard asserts equality in both directions:
every catalog entry appears in some menu (a shortcut is always
discoverable by browsing) and every menu key equivalent comes from the
catalog (nothing is bound behind the table's back). Menus populated by an
NSMenuDelegate are empty at construction, so each is declared
explicitly and every row shape it can render is asserted shortcut-free.
Every chord carries ⌘, or ⌃⇧. Bare-Option chords never reach stage 2 at all: an item bound to ⌥A is silently dead, apparently because AppKit matches the item's key equivalent against the event's composed characters and ⌥A composes to "å". Option is also the terminal's Meta/compose modifier, which the app must not take. The drift guard enforces the invariant.
⌘` is deliberately unbound. Cycling windows is an OS-owned, user-configurable system shortcut, not DeviceTerm's to claim.
A fallback carries the same selector name, never a new one. One menu
item names one selector, so a differently-named method further up the
chain forms no fallback at all: AppKit searches for the original name and
never finds it. Split Right and Split Down illustrate the pattern. A
focused terminal pane claims splitTerminalRight: and splits itself,
while a focused device pane implements neither, so the search continues to
PaneLayoutViewController, which anchors the new terminal beside the device.
The Device-menu forwarders in PaneLayoutViewController+DeviceMenu.swift work
the same way. The catalog's responders list names every class in the
chain, and a test asserts each implements the selector.
Pane focus moves synchronously, outside the Router. Which pane holds
first responder is AppKit state PaneLayoutViewController already owns and
reconciles, not navigation state the workspace models, so a route would
have nothing to mutate and would only queue the press behind the serial
drain. Two walks share the controller: PaneFocusOrderMath cycles display
order for ⌘[ / ⌘], and PaneDirectionalFocusMath resolves the ⌥⌘ arrows
against a [PaneSlot: CGRect] snapshot taken at the keypress. The
snapshot is required rather than convenient. PaneNode.split carries
extents, but those are seeds that a divider drag never updates, so a
tree-only walk would answer against proportions the user cannot see.
A layout reconcile decides who keeps focus, before it tears anything
down. reconcile rebuilds the whole split hierarchy on any tree
change, and removing a pane's view drops the window's first responder, so
it reads the focused slot first and hands off to the nearest surviving
neighbor when that pane is one of the ones going away
(PaneFocusOrderMath.survivor). This is the shared path, which is what
makes it the right home: every pane removal that leaves the tab open
converges here, whether it started at ⌘W, either context menu, the
shutdown overlay's button, the placeholder's Close, or a shell exiting on
its own, and a handoff written into any one of them would leave the rest
dropping focus. TerminalPaneViewController claims first responder
only on a pane's first appearance for the same reason. Appearance
callbacks land after the reconcile, so re-claiming on every rebuild hands
focus to whichever pane comes last in display order and overrides the
decision above.
Device chords are focus-scoped; the menu items are not. A catalog
entry carries a KeybindingScope, and the device controls (Home, Lock,
Rotate, Screenshot, Record, the AX inspector, the size presets) are
.devicePane. Pressing ⌘← reaches a device only while that pane holds
focus, so a mixed tab leaves the key to the terminal the user is typing
into. Clicking Device ▸ Rotate Left with a terminal focused forwards to
the tab's first device pane.
Both paths run through validateUserInterfaceItem, so that is where they
are told apart, and PaneLayoutViewController's copy is the only one that
needs the gate: arriving there means the focused pane declined the
selector, so no device pane has focus. DeviceShortcutScopeDecision
reads the path off NSApp.currentEvent (the raw keyDown on the key
path, a mouse event during menu tracking) and compares its chord with
the item's, because keyboard menu navigation also yields a keyDown and
would otherwise be misfiled. An unrecognized event reads as the pointer
path, so a misidentification leaves the item enabled rather than dead.
Withholding the item is safe because a disabled item is not consumed: the
event falls through to the focused pane. That makes one guard necessary
rather than merely tidy: SimulatorContentView asks
KeybindingCatalog.claims(_:) before forwarding a keystroke as guest
HID, or a disabled catalog chord that falls through would be typed into
the device. The ⌃⇧ chords are the ones that reach it without Command.
⌘W names what it will close. PaneCloseTargetDecision resolves the
chord to the focused pane or to the whole tab, and
validateUserInterfaceItem retitles the item to match on every validation
pass. The deciding input is the tab's terminal count, not its pane
count: a tab must keep at least one terminal (TabState.init's
precondition), so a tab holding one terminal beside one sim has two panes
while ⌘W on that terminal still means the tab.
Router.closeTerminalPane enforces the same rule by refusing to drop the
last terminal, which a pane resolution would hit as a silent no-op. The
tab case re-enters the responder chain at TabStripViewController.closeTab
so the detach-or-shut-down prompt runs, and ⌥⌘W reaches that item directly
whatever ⌘W currently resolves to.
Pane roots publish an accessibility identity. Each terminal and
sim/device pane's wrapper view is an AXGroup carrying
deviceterm.pane.<kind>.<key> and answering AXFocused. The pending
(attach-in-flight) placeholder carries its deviceterm.pane.pending.<key>
identifier but is not promoted to an AXGroup and answers no
AXFocused. This is an observability contract with the out-of-process
UI-test harness rather than user-facing text: pane identity lives in GUI
nav state no CLI verb exposes, and panes list is a daemon RPC that
enumerates device panes only, so without it a harness can neither count
panes nor say which one has focus. scripts/test-uitest.sh asserts a
split adds a pane and an arrow moves focus through exactly this.
Concurrency model
- Package-wide strict concurrency. Every type crossing an actor or task
boundary is
Sendable(or@unchecked Sendablewith a comment explaining the manual invariant). - All shared mutable daemon state (
DeviceCoordinator,SessionManager,PaneCoordinator,RPCServer's connection registry) lives behind actors or explicit serialDispatchQueues. No ad hocNSLock/os_unfair_lockfor new state. Where Obj-C bridging requires queue isolation (e.g.CoreSimulatorBridgecallbacks), use a documented queue + a wrapper that gates access. - Long-lived async events use
AsyncSequence/AsyncStream. One-shot async work usesasync throwsreturning a value. The pane-subscription event stream (AsyncStream<PaneEvent>inside the daemon, surfaced to the GUI as RPCevtframes) is the canonical shape.
Reactive state
SwiftUI-facing state uses @Observable. Combine is allowed only as an
adapter: bridging third-party APIs that already expose publishers,
wrapping AppKit notifications when no Concurrency-shaped alternative
exists, or porting legacy code. Combine is not the default
state-management or event-bus layer.
GUI architecture
The GUI is a thin AppKit shell over a testable view-model layer:
- Daemon access goes through narrow role protocols
(
SessionControlling/DeviceControlling/PaneControlling/PaneSubscribing);DaemonClientis the sole concrete client, injected from the composition root. A consumer depends only on the role(s) it uses, so it's unit-tested against aFakeDaemonClient. - View models (
@MainActor @Observable) hold presentation/daemon state; the view controller binds once withobserve { render() }(Observe.swift) and otherwise only builds views and forwards input to VM intents. Pure logic lives in reducers/math (SimPaneReducer,SimGestureMath) and decision types (DiscoveryDecision,OrphanDecision), all unit-tested headlessly. - Navigation is unidirectional. Every intent is a
Routevalue dispatched through oneRouter(@MainActor, a serialAsyncStream<Route>drain so navigation is ordered and non-reentrant). The router performs the daemon record operations (session.create, device.attach, pane.close, the shutdown fan-out) and mutates the nav view models,WorkspaceViewModel(windows + selection) and each window'sTabListViewModel(tabs + selection), the single "what's open" representation. The AppKit glue reconciles itsWindowController/TabStripViewController/pane controllers to that state by stableWindowID/TabIDidentity (it provisions the session env, builds the terminal/sim views, and owns the pane subscriptions). Menu actions, the discovery/resurrect loops, and orphan re-attach all dispatch routes: one path for all navigation and pane mounting. The router enables session restoration, but no layout is persisted.
Configuration domains
Two configuration domains with disjoint concerns, not a precedence
chain. No resolver consults both, so neither overrides the other; the
built-in defaults cover only DeviceTerm's own domain, where a value from
~/.config/deviceterm/config overrides the matching built-in default.
-
Built-in defaults: compiled into
DeviceTermConfigDefaults(Sources/DaemonProtocol/), for DeviceTerm's own keys only;Sources/App/Config/Config.swiftis a thin re-export of that table. For the two prompt-suppression keys the tabled value is the choice a present key selects, not a value applied on absence: with the key absent the app shows the prompt. -
The Ghostty config: loaded whole, by
ghostty_config_load_default_files. DeviceTerm uses that entry point and the C API exposes no keybind mutator, so the file governs everything inside a surface: presentation (font, theme, palette, cursor) and terminal-local keybinds alike. Makes DeviceTerm look right out of the box for users with a Ghostty config."The Ghostty config" is up to four files, not one:
<config home>/ghostty/config(pre-1.3.0) and<config home>/ghostty/config.ghostty, plus two Application Support variants on macOS. libghostty resolves all of them.What it does not govern is a catalog chord that is enabled for the current focus and scope. Main-menu key-equivalent matching precedes
keyDown:, and libghostty only ever sees a key atkeyDown:(see Input & key handling), so an enabled item wins with no interception code. The scope qualifier is essential. An item that validates disabled, such as a device-scoped shortcut under terminal focus, falls through to the surface on purpose, and the user's keybind takes it.A keybind that fires an action DeviceTerm answers itself (new tab, new split, goto tab) or doesn't implement at all goes unanswered.
GhosttyActionDispositionnames it on stderr once per tag, so the shortcut reads as refused rather than broken.Every false path passes through
reportUnhandledAction; tags are not filtered. Whether a keystroke caused an action is not answerable from its tag.readonlycomes only from a binding despite looking like engine feedback, andshow_gtk_inspectorships on every apprt despite the name. Each wrong guess is a silently broken shortcut, and since reporting is one-shot, filtering nothing stays bounded at one line per distinct tag. The wording is "unhandled" rather than "declined" for the same reason: it claims only what is observable. Stating the rule as every false return rather than every unhandled tag also covers the arms that handle an action in general but reject one payload, such as a non-backgroundcolor_change.Two exceptions are suppressed at that gate rather than by tag: a soft
reload_configand a non-.togglesecure_input, both cases where libghostty states the origin in the payload. libghostty also dispatches a class of bindings (quit,undo,reload_config, …) against the app rather than a surface, so the callback reports from that guard too. -
~/.config/deviceterm/config: DeviceTerm-specific keys, over the built-in defaults for those same keys. It does not reach the Ghostty domain: the two key sets are disjoint and no resolver spans them.
Both domains use the same surface syntax, Ghostty-style key = value plus
# comments, and ConfigFile is the shared line parser for that
shape. There is no shared semantic loader: nothing resolves keys across
both domains or applies precedence between them.
-
The Ghostty config is parsed by libghostty, whole, inside the engine. DeviceTerm additionally reads three keys directly through
ConfigFile(GhosttyThemeColors):selection-backgroundandbackground, each interpreted in the colorspacewindow-colorspaceselects. That is what lets the focus ring, drag overlay, and window chrome inherit the terminal's palette. It honors direct keys only and does not followtheme =indirection.That reader sees only
<config home>/ghostty/config, the pre-1.3.0 path, while libghostty resolves all four. A user whose config lives atconfig.ghosttyor under Application Support therefore gets a correctly themed terminal, while the focus ring and drag overlay fall back tocontrolAccentColorand the window chrome towindowBackgroundColor. Closing the gap means teachingXDGPaths.ghosttyConfig()libghostty's resolution order. It is declared inDaemonProtocolbutGhosttyThemeColorsis its only production caller. -
~/.config/deviceterm/configis read and written through the sameConfigFile, which preserves surrounding lines so an edit doesn't reformat the user's file.DeviceTermConfigDefaultssupplies the recognized-key specifications and the missing-key defaults that actually apply.deviceterm dump-configreports the effective value and source for every recognized key and warns about unrecognized keys; the two prompt-suppression keys reportunsetwith an explanatory note when absent, because no default applies then (the app shows the prompt). It does not parse or report Ghostty's semantic configuration.
The app itself ignores unrecognized DeviceTerm keys. Malformed lines without
= are also ignored by both the app parser and dump-config. A future unified
preferences loader would need to resolve Ghostty presentation separately from
DeviceTerm behavior while retaining a warning channel.
Private API boundary
Sources/CoreSimulatorBridge/ is the single quarantine for private
CoreSimulator types. Swift code outside this module never sees private
selectors or protocols. The module exposes Swift-friendly wrappers
(SimDeviceHandle, SimDisplayHandle, SimHIDClient, SimAccessibility,
SimPurpleHID, SimLocation) plus the loader and device-notification
types (CoreSimulatorLoader, SimDeviceNotifier).
Sources/CompatProbe/ is the canary. It dlopens CoreSimulator, enumerates
required classes/protocols/selectors against a hardcoded inventory, and
prints OK or a structured failure report. The probe runs:
- when a developer runs
make probe, - inside the default
make verifygate, - before every release tag through the release checklist.
The probe is bundled with the app so the release contains the same check that was validated during development. The production app does not invoke it.
Distribution
- Direct download as a signed, notarized DMG.
- Homebrew Cask through
sethdeckard/tap/deviceterm. - Sparkle 2 for in-app updates.
- Apple Developer ID Application certificate signs the main bundle and the embedded helper bundle, with the helper signed first.
- Hardened runtime ON; sandbox OFF (private API access requires it).
make release builds and notarizes the DMG. make publish publishes the
GitHub release and Sparkle appcast, then updates the Homebrew tap after the
release artifact exists.
Where to look first
- Daily conventions, lint, testing layers, dev surface:
AGENTS.md. - Driving principles:
docs/PHILOSOPHY.md. - Build steps, the libghostty package dependency, signing setup:
docs/BUILDING.md. - The private-API symbols the bridge depends on (the probe's inventory):
Sources/CoreSimulatorBridge/as-tested.md.