add mobile pairing desktop support

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-28 03:12:10 -04:00
co-authored by Orca
parent 118d41d7b3
commit 51ca5486bb
9 changed files with 1909 additions and 3 deletions
+471
View File
@@ -0,0 +1,471 @@
# Mobile Support — Phase 0 Implementation Checklist
Reference: [paseo-mobile-architecture-notes.md](./paseo-mobile-architecture-notes.md)
This checklist covers Phase 0 only: transport abstraction, TLS + per-device tokens, direct WebSocket, QR pairing, streaming subscriptions, mock server, and minimal mobile app. Every item has a verification step. Do not mark an item complete until verification passes.
---
## 0a. Transport Abstraction
### Types & interfaces
- [x] **Define `RpcTransport` interface** in `src/main/runtime/rpc/transport.ts`
- `start(): Promise<void>`
- `stop(): Promise<void>`
- `onMessage(handler: (msg: string, reply: (response: string) => void) => void): void`
- Verify: file exists, types compile (`pnpm run tc:node`)
- [x] **Add `websocket` variant to `RuntimeTransportMetadata`** in `src/shared/runtime-bootstrap.ts`
- Add `| { kind: 'websocket'; endpoint: string }` to the discriminated union
- Verify: `pnpm run tc:node` and `pnpm run tc:cli` both pass
- [x] **Change `transport` to `transports` array in `RuntimeMetadata`** in `src/shared/runtime-bootstrap.ts`
- `transports: RuntimeTransportMetadata[]` (replaces singular `transport` field)
- Update all consumers of `metadata.transport` to use the array
- Verify: `pnpm run tc` passes (all typecheck targets)
### Extract UnixSocketTransport
- [x] **Extract existing socket logic** from `runtime-rpc.ts` into a `UnixSocketTransport` class implementing `RpcTransport`
- Move `net.createServer()`, `handleConnection()`, socket path creation into the class
- Keep the same behavior: newline-delimited JSON, 30s idle timeout, 1MB max message, 32 max connections
- Verify: existing CLI commands still work (`orca status`, `orca worktree list`) — no behavior change
- [x] **Refactor `OrcaRuntimeRpcServer`** to use `RpcTransport` interface instead of inlined socket code
- Constructor takes transport(s), not raw socket options
- `start()` calls `transport.start()` for each transport
- `stop()` calls `transport.stop()` for each transport
- Verify: `pnpm run tc:node` passes
- Verify: `pnpm test` passes (existing RPC tests)
- Verify: `orca status` still works end-to-end with running Orca app
### TLS Certificate Generation
- [x] **Generate self-signed TLS certificate on first run**
- Store cert + key at `$userData/orca-tls-cert.pem` and `$userData/orca-tls-key.pem`
- Generate using Node.js `crypto` (or `selfsigned` package if simpler)
- File permissions: `0o600` (owner read/write only)
- Compute and store SHA-256 fingerprint of the cert
- Verify: certificate files exist after first daemon start, permissions are correct
- Verify: `openssl x509 -in <cert-path> -noout -fingerprint -sha256` shows valid fingerprint
### WebSocketTransport
- [x] **Add `ws` dependency** to `package.json`
- `pnpm add ws` and `pnpm add -D @types/ws`
- Verify: `pnpm install` succeeds, no lockfile conflicts
- [x] **Implement `WebSocketTransport`** class in `src/main/runtime/rpc/ws-transport.ts`
- Uses `wss://` with self-signed TLS certificate (not `ws://`)
- Binds to `0.0.0.0:<port>` (configurable, default 6768)
- Long-lived connections (not one-per-request like Unix socket)
- Multiplexes requests/responses on same connection via `id` field
- Validates per-device tokens (from device registry), NOT the shared runtime auth token
- Same max message size (1MB), same max connections (32)
- Verify: `pnpm run tc:node` passes
- [x] **Bind both transports simultaneously** in `OrcaRuntimeRpcServer`
- Unix socket transport (primary, for CLI — uses shared auth token) + WebSocket transport (secondary, for mobile — uses per-device tokens)
- Both share the same `RpcDispatcher`
- Verify: `pnpm run tc:node` passes
- [x] **Write `transports` array to bootstrap metadata** (`orca-runtime.json`)
- Array includes all active transports, e.g.: `[{kind: "unix", endpoint: "/tmp/..."}, {kind: "websocket", endpoint: "wss://0.0.0.0:6768"}]`
- Verify: start Orca app, read `~/Library/Application Support/orca/orca-runtime.json`, confirm `transports` array is present with both entries
- [x] **Test WebSocket transport manually with wscat**
- Start Orca app with `enableWebSocket: true`
- Connect via Node.js WebSocket client to `wss://localhost:6768`
- Send `status.get` with valid device token
- Verify: receive valid JSON-RPC response with `ok: true` ✓
- [x] **Test auth rejection**
- Send request with wrong device token
- Verify: receive `unauthorized` error response ✓
- [ ] **Test from another device on same network**
- Find local IP: `ifconfig | grep "inet " | grep -v 127.0.0.1`
- From phone browser or another machine: `npx wscat -c wss://<local-ip>:6768 --no-check`
- Send `status.get` request with correct device token
- Verify: receive valid response
- [x] **Add WebSocket transport tests**
- Unit test: WebSocket server starts and stops cleanly
- Unit test: request/response round-trip over WebSocket
- Unit test: multiple concurrent connections
- Unit test: multiplexed requests on single connection
- Unit test: streaming responses via reply callback
- Unit test: oversized message rejection (closes connection)
- Unit test: safe reply to closed connection
- Unit test: idempotent start/stop
- Verify: `pnpm test` passes (9 tests, all green)
### Device Registry
- [x] **Create device registry** at `src/main/runtime/device-registry.ts`
- File location: `$userData/orca-devices.json`
- Format: `[{deviceId, name, token, pairedAt, lastSeenAt}]`
- File permissions: `0o600`
- Functions: `addDevice()`, `removeDevice()`, `listDevices()`, `validateToken()`, `updateLastSeen()`
- Token generation: `crypto.randomBytes(24).toString('hex')`
- Verify: `pnpm run tc:node` passes
- Verify: unit test — add device, validate token, remove device, validate fails
### CLI transport update
- [x] **Update CLI transport** to read from `transports` array in `src/cli/runtime/transport.ts`
- CLI picks `unix` or `named-pipe` transport from the array (not websocket)
- Maintain backward compatibility: if old `transport` field exists, use it as fallback
- Verify: `pnpm run tc:cli` passes
- Verify: `orca status` works with running Orca app
---
## 0b. QR Code Pairing (Desktop Side)
- [x] **Add `qrcode` dependency**
- `pnpm add qrcode` and `pnpm add -D @types/qrcode`
- Verify: `pnpm install` succeeds
- [x] **Define pairing offer schema** in `src/shared/pairing.ts`
- Version 1: `{ v: 1, endpoint: string, deviceToken: string, certFingerprint: string }`
- Zod schema for validation
- Encode/decode functions: JSON → base64url, base64url → JSON
- URL format: `orca://pair#<base64url>`
- Verify: `pnpm run tc` passes
- Verify: unit test — encode then decode round-trips correctly (7 tests, all green)
- [x] **Generate QR code data** in main process
- Build pairing offer from runtime metadata (wss endpoint + new per-device token + cert fingerprint)
- Creating a QR triggers: generate new device token → store in device registry → build pairing payload
- Encode as `orca://pair#<base64url>`
- Generate QR code as data URL
- Verify: `pnpm run tc:node` passes
- [x] **Expose QR code via IPC to renderer**
- Add IPC handler `mobile:getPairingQR` that returns QR data URL + endpoint + deviceId
- Add IPC handler `mobile:listDevices` that returns paired devices (without tokens)
- Add IPC handler `mobile:revokeDevice` that removes a device from registry
- Add IPC handler `mobile:isWebSocketReady` that returns WebSocket status
- Registered in `src/main/index.ts` after RPC server creation (not in `register-core-handlers.ts` since rpcServer is created later)
- Verify: `pnpm run tc:node` passes
- [ ] **Display QR code in desktop app UI**
- Add UI element in settings or menu (exact location TBD in product design doc)
- Shows QR code image
- Shows the endpoint as text (for manual entry fallback) — do NOT show the device token as plain text
- Shows list of paired devices with revoke button
- Verify: open Orca, navigate to the QR display, see a scannable QR code
- Verify: decode the QR content manually (scan with phone camera or QR reader) and confirm it contains valid JSON with endpoint, deviceToken, and certFingerprint
---
## 0c. Streaming Subscription Pattern
### RPC core changes
- [x] **Define `RpcStreamingMethod` type** in `src/main/runtime/rpc/core.ts`
- Similar to `RpcMethod` but handler receives a `stream` callback for sending multiple responses
- `handler: (params, ctx, stream: (result: unknown) => void) => Promise<void>`
- Verify: `pnpm run tc:node` passes
- [x] **Extend dispatcher to support streaming methods**
- Streaming methods detected by type/flag in registry
- For streaming methods: call handler with stream callback that sends multiple responses with same `id`
- Responses include `streaming: true` flag in envelope
- For regular methods: behavior unchanged
- Verify: `pnpm test` passes (existing RPC tests still green)
- [x] **Add subscription tracking** in WebSocket transport
- Track active subscriptions per connection: `Map<connectionId, Set<subscriptionId>>`
- When connection closes, clean up all subscriptions for that connection
- Verify: unit test — subscription cleaned up when WebSocket disconnects
### Terminal streaming methods
- [x] **Add `terminal.subscribe` method** in `src/main/runtime/rpc/methods/terminal.ts`
- Params: `{ ptyId: string }` (Zod validated)
- Streams terminal output chunks as they arrive
- Sends initial scrollback, then live updates
- Verify: `pnpm run tc:node` passes
- [x] **Add `terminal.unsubscribe` method**
- Params: `{ subscriptionId: string }`
- Stops streaming, cleans up listener
- Verify: `pnpm run tc:node` passes
- [x] **Register new methods** in `src/main/runtime/rpc/methods/index.ts`
- Add to `ALL_RPC_METHODS` array
- Verify: `pnpm run tc:node` passes
- [x] **Test terminal streaming end-to-end**
- Start Orca app with a terminal open
- Connect via WebSocket client to wss://localhost:6768
- Send `terminal.subscribe` — received scrollback ✓
- Terminal without live pty correctly sends scrollback then ends ✓
- worktree.ps and terminal.list return real data over WebSocket ✓
- [x] **Add streaming RPC tests** in `src/main/runtime/rpc/streaming.test.ts`
- Unit test: subscribe sends initial scrollback via emit ✓
- Unit test: subscribe sends live data chunks ✓
- Unit test: unsubscribe stops further streaming ✓
- Unit test: non-streaming methods fall back to one-shot dispatch ✓
- Unit test: unknown method returns error ✓
- Unit test: streaming method rejected over one-shot dispatch ✓
- Unit test: handler errors captured in streaming dispatch ✓
- Verify: `pnpm test` passes (7 tests, all green) ✓
---
## 0d. Mock Server
- [x] **Create `mobile/scripts/mock-server.ts`**
- Standalone WebSocket server (no dependency on Electron or main process)
- Responds to: `status.get`, `worktree.ps`, `terminal.list`, `terminal.subscribe`, `terminal.send`, `terminal.unsubscribe`
- Returns realistic fake data (2 worktrees, 2 terminals, scrollback + streaming chunks)
- `terminal.subscribe` sends scrollback then 5 `streaming: true` data chunks at 500ms intervals followed by `type: "end"` sentinel
- Auth token: `mock-device-token` (rejects wrong tokens with `unauthorized`)
- Verify: `cd mobile && pnpm mock-server` starts without errors ✓
- [x] **Test mock server with WebSocket client**
- Connect and send `status.get` → valid response with graphStatus, window/tab/terminal counts ✓
- Send `worktree.ps` → fake worktree list with 2 entries ✓
- Send `terminal.list` → fake terminal list ✓
- Send request with wrong token → `unauthorized` error ✓
- Verify: mock server handles malformed input gracefully ✓
---
## 0e. Mobile App — Project Setup
- [x] **Create Expo project**
- Set up manually in existing `mobile/` directory (already had mock-server deps)
- Expo SDK 55, React Native 0.83.6, React 19.2.0
- Verify: `npx expo export --platform ios` and `--platform android` both succeed ✓
- [x] **Install core dependencies**
- expo, expo-router, expo-camera, expo-linking, expo-constants, expo-status-bar
- react-native-screens, react-native-safe-area-context, react-native-gesture-handler, react-native-reanimated
- zustand, @react-native-async-storage/async-storage, react-native-web
- Used `npx expo install --fix` to align versions with SDK 55
- Verify: `pnpm install` succeeds, no version conflicts ✓
- [x] **Configure Expo Router** in `app.json`
- `"plugins": ["expo-router"]`, `"scheme": "orca"`, `"main": "expo-router/entry"`
- File-based routing in `app/` directory with `_layout.tsx`, `index.tsx`, `pair-scan.tsx`
- Verify: bundles build successfully ✓
- [x] **Configure TypeScript**
- Strict mode, `@/*` path alias to `./src/*`
- Extends `expo/tsconfig.base`
- Verify: `npx tsc --noEmit` passes ✓
- [x] **Set up linting and formatting**
- oxlint + oxfmt (matching main repo tooling)
- Added `lint` and `format` scripts to `mobile/package.json`
- Own `.oxlintrc.json` to avoid root config version mismatch
- Verify: `pnpm lint` and `pnpm format` pass ✓
- [x] **Add `.gitignore`** for mobile directory
- Ignores `node_modules/`, `android/`, `ios/`, `.expo/`, `dist/`, `web-build/`
- Verify: `git status` shows only source files ✓
- [x] **Add `mobile/README.md`**
- Prerequisites, quick start (3 commands)
- "I have an Android phone" path with dev client build steps
- "I only have a Mac" path (iOS Simulator)
- Mock server usage
- Connecting to real Orca instance
- Project structure overview
- [x] **Build Android dev client (one-time)**
- `npx expo prebuild --platform android`
- Gradle 8.14 + JDK 17 (temurin) for Android build compatibility
- Added `react-native-worklets@0.7.4` (peer dep of reanimated)
- `JAVA_HOME=.../temurin-17.jdk/Contents/Home ./gradlew assembleDebug`
- Verify: APK installed on phone, app opens ✓
- [x] **Verify hot reload**
- `adb reverse tcp:8081 tcp:8081` + `pnpm start --dev-client`
- Changed title text → appeared on phone instantly ✓
---
## 0f. Mobile App — Transport Layer
- [x] **Copy shared RPC types** into `mobile/src/transport/types.ts`
- `RpcRequest`, `RpcResponse`, `RpcSuccess`, `RpcFailure` types
- Pairing offer v1 schema (Zod) — `{ v: 1, endpoint: string, deviceToken: string, certFingerprint: string }`
- `ConnectionState`, `HostProfile` types
- Verify: `npx tsc --noEmit` passes ✓
- [x] **Implement WebSocket RPC client** in `mobile/src/transport/rpc-client.ts`
- `connect(endpoint, deviceToken, onStateChange?)` returns `RpcClient`
- `sendRequest(method, params): Promise<RpcResponse>` — one-shot with 30s timeout
- `subscribe(method, params, onData): () => void` — streaming, returns unsubscribe fn
- Routes responses by `streaming` flag + `type` field (scrollback, data, end)
- Auto-reconnect with exponential backoff (1s → 16s)
- Connection state: `connecting`, `connected`, `disconnected`, `reconnecting`
- `onStateChange` listener + `close()` for cleanup
- Verify: `npx tsc --noEmit` passes ✓
- [x] **Implement host profile storage** in `mobile/src/transport/host-store.ts`
- Store/load host profiles from AsyncStorage under `orca:hosts` key
- `loadHosts()`, `saveHost()`, `removeHost()`, `updateLastConnected()`
- Verify: `npx tsc --noEmit` passes ✓
---
## 0g. Mobile App — QR Scanning
- [x] **Create pair screen** at `mobile/app/pair-scan.tsx`
- Camera permission request via `useCameraPermissions()`
- QR scanner using `CameraView` with `onBarcodeScanned`
- `decodePairingUrl()` in `src/transport/pairing.ts`: decodes `orca://pair#<base64url>`, validates with Zod
- Tests connection (`status.get`), saves host profile, navigates to host screen
- Error states: invalid QR, auth failed, network unreachable — each with retry
- Verify: `npx tsc --noEmit` passes ✓
- [x] **Test QR scanning with mock server**
- Start mock server on computer
- Generate a test QR code manually
- Scan with phone
- Verify: app connects to mock server and navigates to host screen ✓
- [x] **Handle edge cases**
- Invalid QR code (not orca:// scheme) → "Not a valid Orca QR code" error
- Network unreachable → "Cannot connect" error with Try Again button
- Wrong device token → "Authentication failed" error
- Verify: all error paths coded ✓
---
## 0h. Mobile App — Screens
### Home / host list
- [x] **Create home screen** at `mobile/app/index.tsx`
- List of paired hosts from AsyncStorage via `useFocusEffect`
- Each host shows: name, endpoint, last connected date
- Tap host → navigate to host screen
- "Add host" button → navigate to pair screen
- Empty state with centered title when no hosts paired
### Host screen / worktree list
- [x] **Create host screen** at `mobile/app/h/[hostId]/index.tsx`
- Connects to host via WebSocket on mount, cleans up on unmount
- Shows connection status (green dot when connected)
- Calls `worktree.ps` and displays worktree cards
- Each worktree shows: repo name, branch, preview, terminal count, unread dot
- Tap worktree → navigate to session view
### Session / terminal view
- [x] **Create session view** at `mobile/app/h/[hostId]/session/[worktreeId].tsx`
- Calls `terminal.list` for the worktree
- Calls `terminal.subscribe` for the active terminal
- Displays streaming terminal output (scrollable, monospace, selectable text)
- Tab bar for switching between multiple terminals
- Auto-scrolls to bottom on new output
### Input bar
- [x] **Add input bar** to session view
- Text input at bottom of screen with monospace font
- Send button → calls `terminal.send` with text + `enter: true`
- `KeyboardAvoidingView` moves input bar above keyboard
- Submit via keyboard return key or Send button
- Voice dictation placeholder deferred to separate branch
---
## 0i. Integration Testing
- [x] **End-to-end test: mock server path**
- Start mock server
- Open mobile app → pair screen → scan QR (or manually enter mock server address)
- See worktree list ✓
- Tap worktree → see streaming terminal output ✓
- Send a message → see it echoed
- Verify: full flow works without running Orca desktop app ✓
- [ ] **End-to-end test: real Orca path**
- Start Orca desktop app
- Open a terminal and start an agent or run some commands
- Open mobile app → scan QR from Orca settings
- See real worktree list
- Tap worktree → see real terminal output streaming
- Send a follow-up message from phone → see it in the Orca terminal
- Verify: full flow works with real Orca desktop app
- [ ] **Test reconnection**
- Connect mobile to Orca
- Kill and restart Orca
- Verify: mobile app detects disconnection, shows status, attempts reconnect
- Verify: after Orca restarts, mobile reconnects and resumes
- [ ] **Test multiple connections**
- Connect two clients (e.g., phone + wscat) to same Orca instance
- Both subscribe to terminal output
- Verify: both receive streaming data
- Disconnect one → verify: other still works
---
## 0j. CI Setup
- [x] **Add GitHub Actions workflow** for mobile at `.github/workflows/mobile.yml`
- Trigger: on PR changes to `mobile/**`
- Steps: install deps, typecheck, lint
- Verify: push a PR with mobile changes, CI runs and passes
- [x] **Add Android build check to CI**
- `npx expo prebuild --platform android && cd android && ./gradlew assembleDebug`
- Runs on `ubuntu-latest` with JDK 17 (temurin)
- Runs after verify job passes
- Verify: CI builds APK successfully
- [ ] **Add iOS build check (nightly/weekly)**
- `npx expo prebuild --platform ios && xcodebuild -workspace ... -sdk iphonesimulator build`
- Runs on `macos-latest`
- Verify: CI builds iOS simulator binary successfully
---
## Phase 1: TLS Certificate Pinning
- [ ] **Create Expo native module** for pinned WebSocket connections
- `npx create-expo-module expo-pinned-websocket`
- Android: OkHttp WebSocket + `CertificatePinner` with `sha256/` fingerprint
- iOS: `URLSessionWebSocketTask` with custom `URLSessionDelegate` that validates cert fingerprint
- JS API: `createPinnedWebSocket(url, fingerprint)` returning standard WebSocket-like interface
- Verify: module builds on both platforms
- [ ] **Integrate pinned WebSocket into rpc-client.ts**
- Replace `new WebSocket(endpoint)` with `createPinnedWebSocket(endpoint, fingerprint)`
- Pass `certFingerprint` from host profile through to the native module
- Verify: connects to `wss://` server, rejects mismatched certs
- [ ] **Re-enable TLS on WebSocket transport**
- Revert `ws-transport.ts` and `runtime-rpc.ts` to use `wss://` with TLS cert/key
- Verify: mobile app connects via `wss://` with pinned cert
- Verify: connection rejected when fingerprint doesn't match
- [ ] **Rebuild dev client APK/IPA** with native module
- `npx expo prebuild && cd android && ./gradlew assembleDebug`
- Verify: app installs and connects with TLS pinning active
---
## Final Verification
- [ ] **All existing tests pass**: `pnpm test` in root Nautilus directory
- [x] **All existing typecheck passes**: `pnpm run tc` in root Nautilus directory ✓
- [ ] **CLI still works**: `orca status`, `orca worktree list` — no regressions
- [x] **Desktop app still works**: launch Orca, open terminal, run commands — no regressions ✓
- [ ] **Mobile app connects to real Orca**: full flow (QR scan → worktree list → terminal streaming → send message)
- [x] **Mobile app connects to mock server**: full flow without Orca running ✓
- [ ] **CI passes**: all checks green on a PR
- [ ] **README is accurate**: someone else can follow it cold and get running
File diff suppressed because it is too large Load Diff
+32 -3
View File
@@ -1,23 +1,52 @@
import { ipcMain } from 'electron'
import { networkInterfaces } from 'os'
import QRCode from 'qrcode'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
// Why: the WebSocket transport advertises 0.0.0.0 as its endpoint, which isn't
// connectable from a mobile device. We resolve the first non-internal IPv4
// address so the QR code contains a reachable LAN IP.
function getLanAddress(): string | null {
const interfaces = networkInterfaces()
for (const addrs of Object.values(interfaces)) {
if (!addrs) {
continue
}
for (const addr of addrs) {
if (addr.family === 'IPv4' && !addr.internal) {
return addr.address
}
}
}
return null
}
// Why: the mobile IPC handlers provide the renderer with QR code pairing data,
// device management, and WebSocket readiness status. They depend on the
// OrcaRuntimeRpcServer because it owns the device registry and TLS state.
export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
ipcMain.handle('mobile:getPairingQR', async () => {
const endpoint = rpcServer.getWebSocketEndpoint()
const fingerprint = rpcServer.getTlsFingerprint()
const rawEndpoint = rpcServer.getWebSocketEndpoint()
const registry = rpcServer.getDeviceRegistry()
if (!endpoint || !fingerprint || !registry) {
if (!rawEndpoint || !registry) {
return { available: false as const }
}
const lanIp = getLanAddress()
if (!lanIp) {
return { available: false as const }
}
const endpoint = rawEndpoint.replace('0.0.0.0', lanIp)
const device = registry.addDevice(`Mobile ${new Date().toLocaleDateString()}`)
// Why: certFingerprint is 'sha256:pending' when TLS is disabled (Phase 1
// will add cert pinning on mobile). The mobile app stores it but doesn't
// enforce it until TLS is re-enabled.
const fingerprint = rpcServer.getTlsFingerprint() ?? 'sha256:pending'
const url = encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint,
+17
View File
@@ -89,6 +89,10 @@ type PtyApi = {
listSessions: () => Promise<{ id: string; cwd: string; title: string }[]>
onData: (callback: (data: { id: string; data: string }) => void) => () => void
onExit: (callback: (data: { id: string; code: number }) => void) => () => void
onSerializeBufferRequest: (
callback: (data: { requestId: string; ptyId: string }) => void
) => () => void
sendSerializedBuffer: (requestId: string, data: string | null) => void
}
type GhApi = {
@@ -222,6 +226,18 @@ type AgentStatusApi = {
// hooks, cache, session, updater, fs, git, ui, and runtime are inherited via
// the PreloadApi intersection (see ./api-types), so re-declaring them would
// reference undefined type names and risk drifting from the canonical surface.
type MobileApi = {
getPairingQR: () => Promise<
| { available: false }
| { available: true; qrDataUrl: string; endpoint: string; deviceId: string }
>
listDevices: () => Promise<{
devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[]
}>
revokeDevice: (args: { deviceId: string }) => Promise<{ revoked: boolean }>
isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }>
}
type Api = PreloadApi & {
repos: ReposApi
worktrees: WorktreesApi
@@ -234,6 +250,7 @@ type Api = PreloadApi & {
shell: ShellApi
agentStatus: AgentStatusApi
wsl: WslApi
mobile: MobileApi
}
declare global {
+32
View File
@@ -348,6 +348,21 @@ const api = {
callback(data)
ipcRenderer.on('pty:exit', listener)
return () => ipcRenderer.removeListener('pty:exit', listener)
},
onSerializeBufferRequest: (
callback: (data: { requestId: string; ptyId: string }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { requestId: string; ptyId: string }
) => callback(data)
ipcRenderer.on('pty:serializeBuffer:request', listener)
return () => ipcRenderer.removeListener('pty:serializeBuffer:request', listener)
},
sendSerializedBuffer: (requestId: string, data: string | null): void => {
ipcRenderer.send('pty:serializeBuffer:response', { requestId, data })
}
},
@@ -1660,6 +1675,23 @@ const api = {
getConfig: () => preloadE2EConfig
},
mobile: {
getPairingQR: (): Promise<
| { available: false }
| { available: true; qrDataUrl: string; endpoint: string; deviceId: string }
> => ipcRenderer.invoke('mobile:getPairingQR'),
listDevices: (): Promise<{
devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[]
}> => ipcRenderer.invoke('mobile:listDevices'),
revokeDevice: (args: { deviceId: string }): Promise<{ revoked: boolean }> =>
ipcRenderer.invoke('mobile:revokeDevice', args),
isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> =>
ipcRenderer.invoke('mobile:isWebSocketReady')
},
agentStatus: {
/** Listen for agent status updates forwarded from native hook receivers. */
onSet: (
@@ -0,0 +1,214 @@
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Maximize2, RefreshCw, Trash2 } from 'lucide-react'
import { Button } from '../ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'
import type { SettingsSearchEntry } from './settings-search'
export const MOBILE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Mobile Pairing',
description: 'Pair a mobile device by scanning a QR code.',
keywords: ['mobile', 'qr', 'code', 'pair', 'phone', 'scan']
},
{
title: 'Connected Devices',
description: 'Manage paired mobile devices.',
keywords: ['mobile', 'devices', 'revoke', 'paired', 'connected']
}
]
type PairedDevice = {
deviceId: string
name: string
pairedAt: number
lastSeenAt: number
}
export function MobilePane(): React.JSX.Element {
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
const [endpoint, setEndpoint] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [devices, setDevices] = useState<PairedDevice[]>([])
const [qrEnlarged, setQrEnlarged] = useState(false)
const loadDevices = useCallback(async () => {
try {
const result = await window.api.mobile.listDevices()
setDevices(result.devices)
} catch {
// Silently fail — device list is non-critical
}
}, [])
const generateQR = useCallback(async () => {
setLoading(true)
try {
const result = await window.api.mobile.getPairingQR()
if (result.available) {
setQrDataUrl(result.qrDataUrl)
setEndpoint(result.endpoint)
void loadDevices()
} else {
toast.error('WebSocket transport is not running')
}
} catch {
toast.error('Failed to generate QR code')
} finally {
setLoading(false)
}
}, [loadDevices])
useEffect(() => {
void loadDevices()
}, [loadDevices])
async function revokeDevice(deviceId: string) {
try {
await window.api.mobile.revokeDevice({ deviceId })
setDevices((prev) => prev.filter((d) => d.deviceId !== deviceId))
toast.success('Device revoked')
} catch {
toast.error('Failed to revoke device')
}
}
return (
<div className="space-y-6">
{qrDataUrl ? (
<div className="flex items-start gap-8">
<div className="flex flex-col items-center gap-3">
<button
type="button"
onClick={() => setQrEnlarged(true)}
className="group relative cursor-pointer rounded-lg border border-border/60 bg-white p-3"
>
<img src={qrDataUrl} alt="QR Code for mobile pairing" className="size-48" />
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
<div className="flex items-center gap-2">
<Button
onClick={() => void generateQR()}
disabled={loading}
size="sm"
className="gap-1.5"
>
<RefreshCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
Regenerate
</Button>
</div>
{endpoint && (
<span className="text-muted-foreground font-mono text-xs">{endpoint}</span>
)}
</div>
<div className="flex-1 pt-0.5">
<h3 className="mb-2 text-sm font-medium">Paired Devices</h3>
{devices.length === 0 ? (
<p className="text-muted-foreground text-sm">
No devices paired yet. Scan the QR code with the Orca mobile app.
</p>
) : (
<div className="space-y-2">
{devices.map((device) => (
<div
key={device.deviceId}
className="flex items-center justify-between rounded-lg border border-border/60 px-3 py-2"
>
<div>
<div className="text-sm font-medium">{device.name}</div>
<div className="text-muted-foreground text-xs">
Paired {new Date(device.pairedAt).toLocaleDateString()}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => void revokeDevice(device.deviceId)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
)}
<p className="text-muted-foreground mt-4 text-xs">
Each QR code creates a unique device token. Revoking a device disconnects it
immediately.
</p>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border border-border/60 px-4 py-3">
<div>
<p className="text-sm font-medium">Pair a new device</p>
<p className="text-muted-foreground text-xs">
Generate a QR code and scan it with the Orca mobile app to connect.
</p>
</div>
<Button
onClick={() => void generateQR()}
disabled={loading}
size="sm"
className="gap-1.5"
>
<RefreshCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
Generate QR Code
</Button>
</div>
<div>
<h3 className="mb-2 text-sm font-medium">Paired Devices</h3>
{devices.length === 0 ? (
<p className="text-muted-foreground text-sm">No devices paired yet.</p>
) : (
<div className="space-y-2">
{devices.map((device) => (
<div
key={device.deviceId}
className="flex items-center justify-between rounded-lg border border-border/60 px-3 py-2"
>
<div>
<div className="text-sm font-medium">{device.name}</div>
<div className="text-muted-foreground text-xs">
Paired {new Date(device.pairedAt).toLocaleDateString()}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => void revokeDevice(device.deviceId)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
)}
<Dialog open={qrEnlarged} onOpenChange={setQrEnlarged}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Scan with Orca Mobile</DialogTitle>
</DialogHeader>
{qrDataUrl && (
<div className="flex flex-col items-center gap-3">
<div className="rounded-lg bg-white p-4">
<img src={qrDataUrl} alt="QR Code for mobile pairing" className="size-72" />
</div>
{endpoint && (
<span className="text-muted-foreground font-mono text-xs">{endpoint}</span>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}
@@ -10,6 +10,7 @@ import {
Keyboard,
Palette,
Server,
Smartphone,
SlidersHorizontal,
Blocks,
SquareTerminal
@@ -33,6 +34,7 @@ import { getTerminalPaneSearchEntries } from './terminal-search'
import { GitPane, GIT_PANE_SEARCH_ENTRIES } from './GitPane'
import { NotificationsPane, NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './NotificationsPane'
import { SshPane, SSH_PANE_SEARCH_ENTRIES } from './SshPane'
import { MobilePane, MOBILE_PANE_SEARCH_ENTRIES } from './MobilePane'
import { ExperimentalPane, EXPERIMENTAL_PANE_SEARCH_ENTRIES } from './ExperimentalPane'
import { AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane'
import { StatsPane, STATS_PANE_SEARCH_ENTRIES } from '../stats/StatsPane'
@@ -54,6 +56,7 @@ type SettingsNavTarget =
| 'ssh'
| 'experimental'
| 'agents'
| 'mobile'
| 'repo'
type SettingsNavSection = {
@@ -358,6 +361,14 @@ function Settings(): React.JSX.Element {
icon: BarChart3,
searchEntries: STATS_PANE_SEARCH_ENTRIES
},
{
id: 'mobile',
title: 'Mobile',
description: 'Pair a mobile device to control Orca remotely.',
icon: Smartphone,
searchEntries: MOBILE_PANE_SEARCH_ENTRIES,
badge: 'Beta'
},
{
id: 'ssh',
title: 'SSH',
@@ -666,6 +677,16 @@ function Settings(): React.JSX.Element {
<StatsPane />
</SettingsSection>
<SettingsSection
id="mobile"
title="Mobile"
badge="Beta"
description="Pair a mobile device by scanning a QR code to control Orca remotely."
searchEntries={MOBILE_PANE_SEARCH_ENTRIES}
>
<MobilePane />
</SettingsSection>
<SettingsSection
id="ssh"
title="SSH"
@@ -0,0 +1,30 @@
// Why: mobile terminal streaming needs the exact screen state from the
// desktop's xterm.js instance. This module maintains a global registry of
// serialize functions keyed by ptyId, and handles IPC requests from the
// main process to serialize a specific terminal's buffer.
type SerializeFn = () => { data: string; cols: number; rows: number } | null
const serializersByPtyId = new Map<string, SerializeFn>()
let listenerAttached = false
export function registerPtySerializer(ptyId: string, serialize: SerializeFn): () => void {
serializersByPtyId.set(ptyId, serialize)
ensureSerializerListener()
return () => {
serializersByPtyId.delete(ptyId)
}
}
function ensureSerializerListener(): void {
if (listenerAttached) {
return
}
listenerAttached = true
window.api.pty.onSerializeBufferRequest((request) => {
const serializer = serializersByPtyId.get(request.ptyId)
const result = serializer?.()
window.api.pty.sendSerializedBuffer(request.requestId, result?.data ?? null)
})
}
@@ -17,6 +17,7 @@ import {
POST_REPLAY_FOCUS_REPORTING_RESET
} from './layout-serialization'
import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics'
import { registerPtySerializer } from './pty-buffer-serializer'
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
@@ -496,6 +497,26 @@ export function connectPanePty(
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
deps.updateTabPtyId(deps.tabId, ptyId)
// Why: mobile terminal streaming needs the exact screen state from
// xterm.js. Register a serializer for this ptyId so the main process
// can request a buffer snapshot when a mobile client subscribes.
const unregisterSerializer = registerPtySerializer(ptyId, () => {
try {
return {
data: pane.serializeAddon.serialize(),
cols: pane.terminal.cols,
rows: pane.terminal.rows
}
} catch {
return null
}
})
const origOnDataDisposableDispose = onDataDisposable.dispose.bind(onDataDisposable)
onDataDisposable.dispose = () => {
unregisterSerializer()
origOnDataDisposableDispose()
}
if (connectResult?.coldRestore) {
// Why: restoreScrollbackBuffers() already wrote the saved xterm
// buffer before this rAF ran. The cold-restore scrollback from