PreviewsMCP's CLI and iOS simulator host app communicate over a TCP loopback socket. This document describes the protocol design and the reasoning behind it.
┌──────────────────┐ TCP 127.0.0.1:port ┌──────────────────┐
│ CLI / MCP Server│◄──────────────────────────────────►│ iOS Host App │
│ (macOS process) │ newline-delimited JSON │ (simulator) │
└──────────────────┘ └──────────────────┘
│ │
│ creates socket, binds, listens │ connects on launch
│ accepts after app launch │ reads via DispatchSource
│ writes commands, reads responses │ dispatches to main queue
The CLI acts as the TCP server. The host app connects as a client on launch. One connection per session.
We evaluated three approaches:
The original implementation used 6 temp files polled by 4 independent Timers in the host app (100-300ms intervals). Problems:
- Latency: 100-300ms polling delay on every interaction
- Resource waste: 4 timers firing constantly even when idle
- No cleanup: 140+ session directories accumulating in /tmp (~63MB)
- Fragile ack mechanism: reload acknowledgment required polling a separate ack file
UDS would provide instant delivery and proper lifecycle, but the iOS simulator may resolve sockaddr_un paths against the simulated filesystem rather than the macOS host filesystem. While the simulator transparently maps host paths for file I/O (the host app reads its app bundle via absolute host paths), socket connect() path resolution is kernel-level and may not follow the same mapping. This makes UDS unreliable for cross-boundary communication.
127.0.0.1 is guaranteed to work — the iOS simulator shares the host's network stack. Additional benefits:
- No path length limits (
sun_pathis only 104 bytes) - No
sockaddr_unstruct casting (simpler code, especially in the string-embedded host app) - Ephemeral port binding avoids conflicts
- Standard networking — easy to debug with tools like
netcat
Xcode Preview passes a Unix domain socket fd to XCPreviewAgent via posix_spawn fd inheritance. We can't do this because the host app is launched via simctl launch, which delegates to the CoreSimulator daemon — no fd inheritance is possible.
Newline-delimited JSON. Each message is a single JSON object followed by \n (0x0A).
{"type":"elements","id":"abc123","filter":"all"}\n
type(string, required): Message type identifierid(string, optional): Request ID for matching responses. Present on request/response messages, absent on fire-and-forget messages.
Preview loading and reload run over a separate JIT EPC socket, not this JSON channel. The daemon binds a second loopback listener (
--jit-port); the host app's in-process ORC executor connects back, and the daemon links each compiled preview object into the executor and runs its render entry. This JSON channel now carries onlytouchandelements. iOS previews are JIT-only — the former dylibreload/reloadAck/literalsmessages were removed in dylib Phase B.
Injects touch events via the Hammer approach (IOHIDEvent + BKSHIDEventSetDigitizerInfo).
Tap:
{"type": "touch", "action": "tap", "x": 200.0, "y": 400.0}Swipe:
{"type": "touch", "action": "swipe", "fromX": 200, "fromY": 300, "toX": 50, "toY": 300, "duration": 0.3, "steps": 10}No response is sent. The CLI waits a fixed duration after sending (250ms for tap, duration+200ms for swipe) to allow the UI to settle before taking screenshots.
Requests the accessibility tree for element inspection.
{"type": "elements", "id": "def456", "filter": "interactable"}The host app walks the accessibility tree starting from the window and sends:
{"type": "elementsResponse", "id": "def456", "tree": {"role": "group", "children": [...]}}The filter parameter is passed through but filtering is applied on the CLI side after receiving the full tree. Valid values: "all", "interactable", "labeled".
- CLI binds to
127.0.0.1:0(ephemeral port) and callslisten() - CLI launches the host app via
simctl launchwith--port <port> - Host app connects to
127.0.0.1:<port>on launch - CLI accepts the connection (up to 10 second timeout)
- Both sides set up
DispatchSource.makeReadSourcefor non-blocking reads - Communication flows bidirectionally over the single connection
- On
preview_stop: CLI callsstop()which closes fds and cancels read sources - On host crash: CLI's read source fires with
read() == 0(EOF), pending continuations fail withconnectionLost
- Accept timeout: If the host app doesn't connect within 10 seconds,
socketAcceptTimeoutis thrown - Response timeout:
elementsrequests time out after 3s - Disconnect: Any pending request/response continuations are failed with
connectionLost - Timeout safety: Timeouts use a racing
Task.sleeppattern. The continuation is removed frompendingDataResponsesby whichever side fires first (response arrival or timeout), preventing double-resume
The previewSetUp function is generated by BridgeGenerator when a setup plugin is configured. It bridges async setUp() via Task + DispatchSemaphore. Under JIT it is run once by the JIT reloader via JITRenderBuild.setupEntrySymbol over the EPC channel, not over this JSON socket.
Screenshots are NOT sent over the socket. They're captured via SimulatorManager using either IOSurface (direct framebuffer) or simctl io screenshot (fallback). This keeps the socket protocol simple and avoids sending large binary payloads over a text-based protocol.