Documentation
Mobile companion
An iOS client for a paired desktop, with certificate pinning, project review, and shared assistant sessions.
Platform and capabilities
apps/mobile is an Expo SDK 57 application using React Native 0.86.3, React 19.2, Expo Router, NativeWind, Reanimated, and Gesture Handler. Its supported pinned network transport is implemented in Swift by the local companion-net module. The iOS bundle identifier is com.cadcamfun.mobile.
The phone reads projects, reviews history and pull requests, dispatches workflows, receives activity, and interacts with assistant sessions running on the paired desktop. The desktop continues to own the database, geometry computation, AI provider credentials, and workflow execution. A reachable, running desktop is required for those operations.
Use a native iOS build
Expo Go does not include companion-net and cannot connect to a paired desktop. Native requests fail when the pinning module is unavailable. The web development fallback uses browser fetch and local storage; it does not provide native certificate pinning.
Application structure
Expo Router separates connection screens from authenticated application tabs. app/connect.tsx handles pairing input, app/pair.tsx accepts deep links, and app/(app) contains Projects, Assistant, Activity, and Settings. Project screens include overview/history/pull/workflow panels, commit details, and pull-request review.
components/: reusable controls, project panels, assistant transcript, and connection UI.lib/client.ts: typed client over an injected transport; API types are imported without the server runtime.lib/pairing.ts,native.ts, andstorage.ts: pairing validation, transport selection, and persisted credentials.lib/sse.ts,live-events.ts, andchat-log.ts: stream parsing, live subscriptions, and transcript state.modules/companion-net/: Expo module and Swift pinned HTTPS implementation.
Transport and gateway scope
The companion gateway is separate from the sidecar's loopback listener. It is off by default. Enabling it starts an HTTPS server on 0.0.0.0, using port 8443 unless another valid port is selected. The service persists its enabled state and resumes it after sidecar startup. The desktop firewall and network route must permit the phone to reach this listener.
The phone sends a revocable device token. The gateway stores its SHA-256 hash, checks the request method/path against an allow-list, and forwards accepted requests to the loopback API using the desktop token. The phone does not receive that launch token. Invalid authentication returns 401, denied routes return 403, repeated failed authentication can return 429, and an unavailable upstream returns 502.
Allowed families include project reads; version-control, pull-request, and workflow endpoints; project events; notifications; assistant status/models/agents; streaming chat; queue/abort; permissions; and session state/control. Files, credentials, plugins, billing, gateway administration, and GitHub remote/push/pull endpoints are excluded.
Device access includes write operations
The gateway currently permits version-control commit, branch, checkout, and restore requests as well as pull-request review and merge. Device tokens are scoped to the gateway route policy; they are not a read-only role or a per-project restriction. Revoke a lost or untrusted phone in desktop companion settings.
The gateway limits failed authentication attempts: after ten failed bearer attempts from one IP address within 60 seconds, further requests from that address receive HTTP 429 until the window expires (apps/sidecar/src/services/companion-gateway.ts).
Assistant sessions and offline messages
The companion sends chat requests to POST /api/ai/chat/stream and parses server-sent events. A session identifier allows continuation of the desktop conversation. The client can steer or queue messages, abort a turn, answer questions, and reply to permission requests. Configure the model provider on the desktop; the phone queries AI status before use.
If a stream ends without a terminal done or error frame and was not cancelled, the client reports an incomplete connection. The native transport closes its stream handle on cancellation and completion. Native decoding preserves incomplete UTF-8 scalars between byte chunks.
Assistant messages that cannot be sent can enter a persistent queue. It retains at most 50 entries, dropping the oldest when full, and retries on foreground and connectivity recovery. Failed entries remain queued. This queue stores messages for later execution; it is not an offline copy of project geometry or an offline AI runtime.
Build and run
bun install
bun run --cwd apps/mobile test
bun run --cwd apps/mobile typecheck
bun run --cwd apps/mobile build
bun run --cwd apps/mobile ios
bun run --cwd apps/mobile ios:release --device The build command exports the iOS JavaScript bundle to apps/mobile/dist; it does not produce an IPA. The iOS run commands require macOS, Xcode, and CocoaPods. Device installation also requires a trusted device, Developer Mode, and an Apple signing team configured for the generated Xcode project. native:generate runs Expo prebuild for iOS without installing pods. For day-to-day work, start (alias dev) runs the Expo development-client bundler, ios:dev starts it on localhost and opens the iOS simulator, ios:build compiles the debug app without starting a bundler, and web starts the Expo web target. The test script runs bun test test inside apps/mobile.
.github/workflows/ci.yml defines companion typechecking, logic tests, and JavaScript export. .github/workflows/ios.yml generates the Xcode project, builds an unsigned Release simulator app, pairs it with a live sidecar, checks authenticated project listing, and captures a screenshot. These configured checks do not establish physical-device, signing, TestFlight, or real-provider acceptance.
Storage and device security
On native iOS, pairing credentials are stored with Expo SecureStore. The pairing entry uses WHEN_UNLOCKED_THIS_DEVICE_ONLY. Offline queue entries and session-seen markers also use SecureStore. The browser fallback uses local storage for development.
The app queries biometric capability and uses expo-local-authentication for local authentication, with system passcode fallback enabled. Biometric UI does not change the gateway's server-side route permissions. Device revocation is controlled by the paired desktop.
The desktop keeps its gateway certificate and private key in its application data companion directory; the key is written with owner-only permissions. Replacing that certificate changes the pinned fingerprint and requires a fresh trusted pairing.
Project review workflow
- Open Projects and choose a desktop project.
- Inspect its working status, branches, commits, pull requests, or workflow runs.
- Open a commit to review its diff, or open a pull request to review changes and submit a verdict.
- Use the available merge/close or workflow dispatch actions when appropriate for the project.
- Open Assistant with the selected project context to continue or start an AI session.
All changes execute on the desktop. Network interruption, revoked authorization, or a stopped desktop prevents new server work; locally queued assistant messages are retried later.
Client interface
CompanionClient accepts a Transport with JSON request and text-stream methods. It exposes project reads; VCS status, history, diff, commit, branch, checkout, and restore; pull requests and reviews; workflow runs and dispatch; AI status/models/agents; session control; notifications; and project event streams.
JSON failures become CompanionError with a status and message. Streaming chat and live events use the shared SSE parser so arbitrary network chunk boundaries do not become application event boundaries. Tests inject transports without importing native modules or running a simulator.
Pair a phone
- On the desktop, open Settings and its Mobile companion panel, or
/settings/companion, and turn the gateway on. - Create a named device pairing and display the QR code. The link contains HTTPS host candidates, a device token, and the certificate fingerprint.
- On iOS, scan the QR code or paste the
cadcamfun://pairlink. Allow local-network access when prompted. - The app validates the link, races the advertised host candidates, and keeps the first successful health connection.
- Confirm the device appears in desktop settings. Use the desktop's revoke action to remove its access.
Host candidates include suitable private IPv4 and overlay-network addresses; the gateway advertises at most six discovered addresses. The current listener is IPv4, so discovered IPv6 addresses are not advertised. A VPN or overlay network must already provide routing for use away from the local network; pairing does not create a relay or a VPN.
Certificate pinning
The Swift implementation uses ephemeral URLSession instances with a trust delegate. It accepts a leaf certificate only when the SHA-256 hash of its DER bytes equals the fingerprint from pairing. Requests require HTTPS, redirects are refused, and native callers never silently downgrade to the browser transport.
Certificate trust therefore comes from the pairing link. Treat the QR code and pasted link as credentials. A link with an unexpected origin or fingerprint should be replaced through the desktop pairing flow rather than bypassing TLS validation.
Diagnose a connection
- No native transport: install an iOS development or Release build containing the local module.
- No reachable host: check local-network permission, desktop firewall, advertised address, gateway port, and network routing.
- 401: the device token is invalid or revoked; create a fresh pairing.
- 403: the endpoint is outside the companion allow-list.
- Certificate mismatch: pair again with the current desktop certificate.
- Assistant unavailable: check desktop provider configuration and whether the sidecar is running.
Phone settings and local state
| Setting | Default | Behavior |
|---|---|---|
| Appearance | System | Choose System, Light, or Dark. System follows iOS until overridden. |
| Haptics | Enabled | Selection ticks and success/error feedback have separate toggles. |
| Face ID lock | Disabled | Enabling requires available, enrolled biometrics and a successful system authentication. |
| Lock on background | Enabled | When biometric lock is enabled, moving to inactive/background locks the paired app. |
| Desktop connection | Current pairing | Shows the address and fingerprint prefix; tapping the desktop row rechecks connectivity. |
| Queued messages | Zero | Shows pending assistant messages while the desktop is unreachable. |
UI preferences live in AsyncStorage under cadcamfun.ui.v1; pairing credentials, queued messages, and seen-session markers use SecureStore. The UI store remembers the selected project and last session per project for one paired desktop. Changing desktop identity clears the remembered project/session mapping so it is not reused against an unrelated server.
The lock coordinator skips boot and connection screens and shows a full-screen unlock overlay for a paired app. It does not suspend server-side sessions or revoke a token. Unpairing removes the phone's saved pairing and returns to Connect; desktop revocation is the separate action that prevents the token from authenticating again.
Gateway administration example
These requests run against the desktop loopback API with its launch token, not against the phone gateway. Use an isolated development instance. Replace the example port with the port from its ready event and supply that process's token.
CADCAM_URL=http://127.0.0.1:43111
CADCAM_TOKEN=local-development-token
curl --fail-with-body "$CADCAM_URL/api/companion" -H "Authorization: Bearer $CADCAM_TOKEN"
curl --fail-with-body -X POST "$CADCAM_URL/api/companion/enable" -H "Authorization: Bearer $CADCAM_TOKEN" -H 'Content-Type: application/json' -d '{"port":18443}' The status response contains enabled, a nullable port, a nullable fingerprint, the suggested host, and devices. The service accepts explicit ports from 1024 through 65535. A port already in use fails to start the listener; choose another port and regenerate the pairing address.
curl --fail-with-body -X POST "$CADCAM_URL/api/companion/pair" -H "Authorization: Bearer $CADCAM_TOKEN" -H 'Content-Type: application/json' -d '{"name":"Development iPhone","host":"192.168.1.20"}' Replace the example host with an address the phone can route to. Successful pairing creation returns HTTP 201 with a device id and a credential-bearing link. The trimmed name must contain 1–60 characters. Scan/paste that link privately; do not copy it into a bug report. Creating the token does not automatically turn the gateway on.
To remove access, send DELETE /api/companion/devices/<id> using desktop authorization. To stop all new gateway traffic, send POST /api/companion/disable. Device hashes are checked on each request, so revoked tokens are refused on subsequent requests.
Recovery without losing context
- Tap the desktop connection row in Settings to retry health against the advertised hosts.
- If only one network path fails, verify the correct Wi-Fi/hotspot/VPN route and regenerate a QR code after desktop address changes.
- If authorization fails, revoke the obsolete device on the desktop and create a fresh pairing.
- If only an assistant stream failed, reopen the project session and inspect its state before resending the same instruction.
- Inspect queued-message count before submitting a duplicate prompt; queued operations retry later.
The offline queue has bounded retention and replays each pending operation through a new chat request carrying the stored session identifier when available. It is not an exactly-once delivery guarantee. A network drop after server execution can leave the client uncertain about completion; inspect resulting project/session state before repeating a change.
Implementation anchors: app/(app)/settings.tsx, components/layout/AppLockCoordinator.tsx, lib/store.ts, lib/server.tsx, and lib/offline-queue.ts.
Gateway allow-list
The phone talks to the desktop only through the companion gateway, which admits this fixed set of routes and forwards them to the loopback API:
| Methods | Path pattern | Purpose |
|---|---|---|
| GET | /api/health | Reachability check. |
| GET | /api/projects, /api/projects/:id | Project list and header. |
| GET, POST | /api/projects/:id/vcs/* (except remote, push, pull), /pulls/*, /actions/* | History, pull requests, reviews, and workflow runs. GitHub sync stays on the desktop because it needs the owner's GitHub token. |
| GET | /api/projects/:id/events | Live SSE stream of history, workflow, and assistant activity; the phone's only push channel. |
| GET, POST, PUT, DELETE | /api/notifications, /mark-all-read, /:id | Read, mark read, and delete notifications. |
| GET | /api/ai/status, /models, /agents | Assistant discovery. |
| POST | /api/ai/chat/stream, chat/abort, chat/queue, permission/reply, sessions/state, sessions/control | Assistant sessions. |
Because the list is fixed in the sidecar, a feature that needs another route (for example file upload or GitHub push) is a desktop-only feature until the list changes.
What the phone stores
| Item | Storage | Notes |
|---|---|---|
| Gateway origin(s) | SecureStore | The preferred host plus alternates from the pairing link. |
| Device token | SecureStore, WHEN_UNLOCKED_THIS_DEVICE_ONLY | Never leaves the device except as a bearer header to the gateway. |
| Certificate fingerprint | SecureStore | Compared with the gateway's certificate on every connection. |
| Offline queue, session markers | SecureStore | Messages composed while offline and the last-seen state of sessions. |
In a browser development build, the same data falls back to local storage, which is not encrypted. Use it only with throwaway pairings.
Screens
| Route file | Screen |
|---|---|
app/index.tsx | Entry: no pairing → the pair screen; paired → projects |
app/connect.tsx, app/pair.tsx | Pairing: scan the QR code with the camera or paste the link |
app/(app)/projects/index.tsx | Project list |
app/(app)/projects/[id].tsx | One project |
app/(app)/projects/[id]/commit/[oid].tsx | A commit and its changes |
app/(app)/projects/[id]/pulls/[number].tsx | A pull request: review and merge |
app/(app)/assistant.tsx | Assistant chat |
app/(app)/activity.tsx | Live activity |
app/(app)/settings.tsx | Connection and device settings |
Routes use Expo Router file conventions: (app) is a group that shares a layout without adding a URL segment, and [id] is a parameter.
Offline assistant queue
When the desktop cannot be reached, a message typed in the assistant is queued on the phone instead of lost:
| Rule | Value |
|---|---|
| Where | SecureStore, so a killed app keeps the queue |
| What is stored | projectId, text, optional sessionId, agent, mode (general, cad, cam, gcode, toolpath; default general) and queuedAt |
| Capacity | 50 entries; the oldest are dropped first |
| When it drains | When the app returns to the foreground with a stored pairing |
| How | Each entry opens a fresh streaming turn and reads until done; an error event or a background transition keeps it queued |
| Feedback | A toast: "Queued message sent" or "N queued messages sent" |
Only connectivity failures queue a message. A send counts as offline when the client reports status 0, or the error text mentions a network failure, timeout, refused connection or similar; HTTP and business errors ("request to … failed with …") are shown instead of queued.