mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
add mobile pairing desktop support
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
Vendored
+17
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user