iOS: packages, launch, and the relay connection
How the iPhone app is split into Swift packages, why launch waits for the Keychain, and how its Network.framework socket registers, times out, recovers, and filters live events.
Checked against the source on 17 September 2026
On this page
Three Swift packages and a thin app target
The Xcode project has two targets: the PlanToCode app and a VibeUITests bundle that runs hosted inside it. The app target holds three Swift files, the SwiftUI entry point, the app delegate, and a delegate extension for timeline interest. It links Core and VibeUI directly, and its delegate calls Core for push, the background, and recovery. Everything else lives in three local Swift packages built with Swift 6.3 for iOS 18.
| Target or package | What it holds | Depends on |
|---|---|---|
| App target | Three Swift files: the SwiftUI entry point, the app delegate for launch, push, and the background, and its timeline interest extension. | Core and VibeUI. |
| VibeUI | Every screen, the UIKit chat collection, the code and document viewers, Review Mode, and the bundled mermaid.min.js. | Core, VibeUIFormatting, Runestone with 37 Tree-sitter grammars, swift-syntax, swift-cmark, MarkdownUI, and SwiftMath. |
| Core | One target built from Sources/Core and Sources/Security: the relay client, sign-in and tokens, the RPC command router, data services, StoreKit, push, voice, and the Keychain wrappers. | KeychainAccess. |
| VibeUIFormatting | Markdown text helpers: the path classifier, the inline reference linkifier, and the ordered list normalizer. | Nothing. |
| VibeUITests | The test bundle, hosted inside the app. | Core, VibeUI, VibeUIFormatting, Runestone, and MarkdownUI. |
Core cannot import VibeUI, so the relay client, sign-in, and data services build and test without a single screen.
| Path from repository root | Start here for |
|---|---|
| mobile/ios/App/ | Launch, push registration, and the background and foreground transitions. |
| mobile/ios/Core/Sources/Core/Connectivity/ | The relay socket, registration, recovery epochs, and the timeline interest lease. |
| mobile/ios/Core/Sources/Core/Data/Manager/Ingress/ | Relay event intake before the main actor. |
| mobile/ios/VibeUI/Sources/VibeUI/Features/Workspace/Chat/ | Chat hosts, the collection view, the outbox, and chat previews. |
| mobile/ios/VibeUI/Sources/VibeUI/Features/Workspace/ReviewMode/ | Review Mode capture, transcription, and video composition. |
Launch waits for the Keychain
- Device IDAfterFirstUnlockThisDeviceOnly
The phone’s identity for the device list and the relay. It never syncs to iCloud. The platform keeps this class readable while the phone is locked, but not before the first unlock after a restart.
- App JWT and resume tokensWhenUnlockedThisDeviceOnly
The default for the app’s Keychain items. The platform makes this class readable only while the phone is unlocked.
- LaunchprotectedDataDidBecomeAvailable
The app delegate and AppView read the device ID off the main thread. While it can’t be read, Core doesn’t start and AppView keeps its loading screen. Both try again when protected data becomes available or the app returns to the foreground.
- Sign-in checkcheckStoredToken
An app JWT that can’t be read because the phone is locked keeps the saved sign-in. The check runs again when protected data becomes available or the app becomes active.
The device ID is the phone’s identity for the device list and the relay, so nothing that talks to the network starts without it. It is a Keychain item with AfterFirstUnlockThisDeviceOnly accessibility that never syncs to iCloud. The app JWT, its expiry, and the relay resume tokens use the default for the app’s items, WhenUnlockedThisDeviceOnly.
The app delegate reads the device ID on a background task before it initializes Core, and AppView resolves the identity off the main thread as well. During a background launch before the first unlock, AppView stays on the loading screen and tries again when the system posts protectedDataDidBecomeAvailable. While the phone is locked, the platform keeps the device ID readable but not the app JWT, so a launch in that state keeps the saved sign-in and checks it again once protected data is available or the app becomes active. An item that cannot be read at all offers Reset Device Identity.
Sign-in needs no callback URL. ASWebAuthenticationSession opens /auth/auth0/initiate-login with callbackURLScheme set to nil, and the app polls /auth0/poll-status every 2 seconds, up to 60 times, before it exchanges the code with its PKCE verifier and finalizes the login. One refresh of the app JWT runs at a time. Its timer fires 300 seconds before expiry and never sooner than 5 seconds from now, and a 401 or 403 from the refresh endpoint ends the session.
The selected region is stored in UserDefaults. Its base URL, https://api-us.plantocode.com or https://api-eu.plantocode.com, drives both the REST client and the /ws/device-link socket. iOS persists the selected region before it publishes navigation state and binds each registration attempt to the origin it used.
A WebSocket on Network.framework
The relay socket is an NWConnection with NWProtocolWebSocket: version 13, automatic replies to pings, and a 32 MiB maximum message size. The parameters allow expensive and constrained paths and use the handover multipath service, so the connection can move between Wi-Fi and cellular. Only wss URLs are accepted. The upgrade request carries the app JWT as a bearer token, X-Device-ID and X-Token-Binding with the device ID, X-Client-Type: mobile, and X-Target-Desktop-Device-ID for the selected desktop.
Every socket gets a transport generation. The state object that installs a socket also performs sends that are bound to a generation, under the same lock, so a send prepared for an earlier socket can never reach its replacement. State callbacks from a connection that is no longer current are ignored.
After the upgrade the client sends register with the device ID, the device name, relayProtocolVersion 1.6, the target desktop, and the relay session ID and resume token when it has them. The handshake times out after 10 seconds, and a send that fails for a transient reason retries after min(0.25 × attempt, 1.5) seconds. Once registered, the client sends its own heartbeat message every 5 seconds, and a watchdog fails the connection after 15 seconds without inbound traffic.
Resume tokens are stored in the Keychain per phone and desktop pair, under the key <device ID>::<desktop ID>. A serial store gives each relay client an ownership generation for its key and rejects writes from a client that no longer owns it, so a stale client cannot overwrite a newer token. An invalidResume error makes the client register again without the token after 0.2 seconds. Three invalid resumes within a rolling 30 seconds stop the loop with invalidResumeLoop.
Requests, timeouts, and trace context
RpcMethod lists 65 methods, and 22 of them require an idempotency key. The client refuses an unknown method or a keyless mutation before anything is sent, and the outbound encoder throws when it meets a snake_case key anywhere in a payload. Pending calls are keyed by request ID together with an ownership token. A second in-flight request with the same ID is refused, and the cleanup of an old call cannot remove a newer one.
| Methods | Default timeout |
|---|---|
| files.readContent | 8 seconds. |
| File trees, run list and get, usage limits, auth profiles, voice vocabulary and context, history state | 90 seconds. |
| Session and project lists and updates, reconciliation, existing sessions, cancel and delete, diffs, command output, web page Markdown, binary and media reads, attachment uploads | 60 seconds. |
| Everything else | 30 seconds. |
| Chat policy: initial timeline, older page, outbox acknowledgement | 115, 60, and 20 seconds. |
Every request carries a W3C traceparent, and a response is rejected unless its trace ID matches the request’s. A timeout fails that call and reconnects the socket generation that sent it. When that socket was already replaced, the timeout touches nothing else.
Recovery epochs, network changes, and the background
- Recovery epochdesktopReconnectGraceSeconds = 90
One per desktop. It ends at the reconnectDeadline the relay sent, or without one 90 seconds after the phone noticed the loss. Silence, an RPC timeout, or a lost path inside the epoch keep that deadline.
- Probesystem.ping
Before each retry the phone waits a random time between half and all of a ceiling that doubles from 0.25 s and stops at 5 s, never past the deadline. A try without a socket opens one first, and it registers with its resume token.
- Reconnect graceDESKTOP_RECONNECT_GRACE_SECS = 90
When a desktop’s socket closes, the relay sends reconnecting with a reconnectDeadline and answers requests for that desktop with -32011. It sends online when the desktop registers again and disconnected when the deadline passes.
- OutcomecanSendDesktopRPC
The desktop counts as connected only after it answers system.ping itself. The phone shows it offline at the deadline, at once on disconnected, or when the probe stops for lack of a network path.
Recovery runs in epochs, one per desktop, each with a deadline. Inside an epoch the app probes the desktop with system.ping on a backoff, and when the relay sends online it pings at once. A desktop that has not answered by the deadline, or that the relay reports disconnected, is shown as temporarily offline. So is one whose probe stops because the phone has no network path.
NWPathMonitor reports lost paths and interface switches. A lost path closes every connected relay, fails its pending calls, and opens an epoch. A switch between Wi-Fi and cellular closes the old relay and fails in-flight calls before recovery opens exactly one replacement, because a socket that migrated can keep accepting sends without ever answering a registration. The switch discards the current epoch, so the replacement gets a new 90-second window.
When the app resigns active it suspends timeline interest. When it enters the background it disconnects every relay inside a GracefulDisconnect background task, unless desktop-backed media is playing, which the audio background mode allows. Back in the foreground, recovery for the active desktop starts 1.5 seconds later, and an existing socket is kept only when a ping answers within 3 seconds. A silent desktop_online push starts the same recovery right away.
Event intake before the main actor
- Snapshot slotchat:timeline-updated · event: blocks
One waiting slot per session, run, and thread, and a newer snapshot replaces the one waiting. Past 128 slots the oldest is dropped. Snapshots leave one at a time, with a 100 ms pause after each delivery.
- Control eventsrun:status-changed · device-status
Every other relay event waits in arrival order and is delivered without a pause, so it can pass a waiting snapshot.
- Lease checkWorkspaceTimelineRelayEventAuthorization
Runs on the main actor when a timeline event is delivered and drops the event before decoding when its desktop and session hold no current lease. It runs again right before the view model changes.
- Overload32 MiB · 64 MiB · 256 events
An event over 32 MiB, more than 64 MiB waiting, or more than 256 waiting control events drops the rest of that subscription generation. The phone then subscribes again and starts desktop recovery.
RelayEventIngress admits relay events before any work reaches the main actor. Control events keep their arrival order and never wait behind snapshots. Block snapshots from chat:timeline-updated are best-effort UI state. A newer snapshot for the same session, run, and thread replaces one that is still waiting, and snapshots leave one at a time. The lease is checked when a snapshot is delivered on the main actor, before its payload is decoded, so content for a chat you left never reaches preparation.
Timeline content reaches the phone only while a chat is visible. Each visible chat view holds a claim, and the interest coordinator keeps one lease per desktop, for the base session of the newest claim. Claims keep the handoff between a parent chat and a subagent viewer safe while both views briefly exist, and a claim counts only for the foreground generation in which its view last confirmed visibility, so coming back to the foreground cannot revive a view that is gone. The lease is sent as chat:timeline-interest with the appSessionId and an active flag, renewed every 20 seconds, and issued again after the relay registers. A failed delivery is tried up to 4 times, starting at 0.25 seconds with at most 2 seconds between attempts and 20 percent jitter.