iOS: packages, launch, and the relay connection
How the iPhone app is split into Swift packages, why launch waits for the Keychain, how its Network.framework socket registers, times out, recovers, and filters live events, and how it reads agent limits.
Checked against the source on 5 October 2026
On this page
Three Swift packages and a thin app target
The Xcode project has two targets: the HB Code 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 Swift | Core and Vibe |
| Vibe | Every screen, the UIKit chat collection, the code and document viewers, Review Mode, and the bundled mermaid. | Core, Vibe |
| Core | One library target built from Sources/ | Keychain |
| Vibe | Markdown text helpers: the path classifier, the inline reference linkifier, and the ordered list normalizer. | Nothing. |
| Vibe | The test bundle, hosted inside the app. | Core, Vibe |
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/ | Launch, push registration, and the background and foreground transitions. |
| mobile/ | The relay socket, registration, recovery epochs, and the timeline interest lease. |
| mobile/ | Relay event intake before the main actor. |
| mobile/ | Chat hosts, the collection view, the outbox, and chat previews. |
| mobile/ | 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.hbcode.ai or https://api-eu.hbcode.ai, 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.7, 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 69 methods: 10 in the system namespace, 23 for files, 15 for sessions, and 21 for runs. 24 of them are mutations that 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.
| Default timeout | Methods |
|---|---|
| 8 seconds | files. |
| 90 seconds | File trees, run list and get, Codex usage limits and auth profiles, Claude accounts, the Codex profile switch and the Claude account switch, agent usage history, voice vocabulary and context, and history state. |
| 60 seconds | Session and project lists, session get and update, project and session reorder, missing-workspace recovery, reconciliation, project skills, workspaces, existing sessions, cancel and delete, diffs, command output, web page Markdown, binary and media reads, and attachment uploads. |
| 30 seconds | Every other method. |
| 115, 60, and 20 seconds | Chat policy: the initial timeline, an older page, and the outbox acknowledgement. |
| 60 seconds | Chat policy: run. |
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.
| Relay event | Goes to |
|---|---|
| device-status, device-unlinked | Desktop presence with the recovery epoch, and the removal of a desktop that was unlinked. |
| project-directory-updated | The project directory of that desktop. |
| session-created, session-updated, session-deleted, session-snapshot, history-state-changed | The session service. |
| chat:timeline-updated | The visible chat, after the lease check. The same event also updates the run summaries. |
| chat:outbox-updated | A chat |
| run:created, run:deleted, run:list-invalidated, run:status-changed, run:tokens-updated, run:cost-updated, run:finalized, run:stream-progress, run:error-details | The run summaries in Jobs |
The activity indicator of a session comes from the run summaries. A chat:timeline-updated event with the event started or blocks marks its run as running. The events completed, failed, and aborted mark it as completed, failed, and canceled and set the end time. An event without run metadata is dropped with a warning, and one without a run ID changes nothing.
Agent limits and account switching
- Codex readssystem.codexUsageLimits · system.codexAuthProfiles
A full load sends both in parallel. A profile list without an active profile means “Not set up”, and the failed limits read stays hidden.
- Claude readsystem.claudeAccounts
One read returns every account with its usage windows. A failed read keeps the last accounts, and the accounts of another desktop are cleared before the load.
- RefreshlimitRefreshIntervalSeconds = 60
Runs while the projects list is on screen. Codex reads only the limits again. Claude reads the account list again.
- Switchsystem.activateCodexAuthProfile · system.activateClaudeAccount
One keyed request for each tap, with a new UUID in the key. Only one switch runs at a time. The answer is the new list. Codex then loads again because the limits belong to the active subscription.
The projects list shows one tile for each agent, and Settings has an Agents section with the same two status lines. A tap opens the usage screen of that agent. Accounts and profiles live on the desktop. The phone reads them through the relay and can only pick the active one. People add an account and sign in on the desktop.
| Tile text | When the Codex tile shows it | When the Claude tile shows it |
|---|---|---|
| Checking… | A load runs and no limits are known. | The first read of this desktop has not answered. |
| Not set up | The profile list has no active profile, and the limits are cleared. | The account list is empty and the read did not fail. |
| Unavailable | No limit window is known after the load. | The account list is empty and the read failed. |
| Signed out | The Codex tile never shows it. | No account is active, or the active account is signed out. |
| Usage rows | The primary and the secondary window of the active profile, named from their length, 5h and Weekly. | The 5h window and the weekly window of all models of the active account. |
| Could not read usage | The Codex tile never shows it. | The active account has no usage windows and an error message. |
| Plan name, or No plan limits | The Codex tile never shows it. | The active account has no usage windows and no error. |
A usage row shows the percent that is left, which is 100 minus usedPercent, clamped to 0 through 100. The row turns to the warning color at 25 percent or less and to the destructive color at 10 percent or less. The Claude usage screen also lists the weekly windows of single models, which the tile leaves out.
On the Codex usage screen a tap on a profile that is authenticated and not active sends system.activateCodexAuthProfile. On the Claude usage screen a tap on an account that is signed in and not active sends system.activateClaudeAccount, and that account runs the next Claude turn. Both calls are keyed mutations. The key is the method, the ID, and a new UUID, so each tap is its own mutation. A view model runs one switch at a time. A failed Codex switch sets the failed load state, and the tile keeps the limits it already has. A failed Claude switch keeps the accounts and stores the error.
Each usage screen also reads system.agentUsageHistory for its agent. The weekly line is drawn from weeklySamples. When a reset time lies between two samples and the later sample is lower, the line gets two extra points: the old value one second before the reset and zero at the reset. The tokens chart always spans the last 30 local days and exists only for Codex, because dailyTokens is null for Claude. A failed read keeps history that is already on screen.