mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(mobile): Expo companion app [beta] (#1245)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
name: Mobile Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'mobile-v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
android-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Why: the "Create GitHub Release" step below uses the default GITHUB_TOKEN
|
||||
# to call `gh release create`, which requires contents:write. Without this,
|
||||
# the job builds the APK fine but fails at release time with
|
||||
# "HTTP 403: Resource not accessible by integration".
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Expo prebuild
|
||||
run: npx expo prebuild --platform android --no-install
|
||||
|
||||
- name: Build Android release APK
|
||||
run: cd android && ./gradlew assembleRelease
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: orca-mobile-apk
|
||||
path: mobile/android/app/build/outputs/apk/release/*.apk
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/mobile-v')
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
gh release create "$tag" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "Orca Mobile $tag" \
|
||||
--prerelease \
|
||||
--latest=false \
|
||||
--generate-notes \
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Mobile Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- 'mobile/**'
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# Mobile Phone-Fit Debug Status
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Mobile Client Server (orca-runtime) Desktop Renderer
|
||||
───────────── ──────────────────── ────────────────
|
||||
subscribeToTerminal(handle) → terminal.subscribe handler → IPC: terminalFitOverrideChanged
|
||||
sends { client, viewport } calls handleMobileSubscribe() setFitOverride() → banner render
|
||||
resizes PTY, sets override
|
||||
serializes scrollback
|
||||
← scrollback { cols, rows,
|
||||
serialized, displayMode }
|
||||
|
||||
switchTab(handle) → terminal.unsubscribe (old)
|
||||
unsub old, subscribe new handleMobileUnsubscribe()
|
||||
starts 300ms restore timer
|
||||
→ terminal.subscribe (new)
|
||||
handleMobileSubscribe()
|
||||
cancels timer, inline-restores old
|
||||
resizes new PTY
|
||||
|
||||
toggleDisplayMode(handle) → terminal.setDisplayMode
|
||||
applyMobileDisplayMode()
|
||||
← resized event on stream
|
||||
```
|
||||
|
||||
## Key Data Structures (Server)
|
||||
|
||||
- `mobileSubscribers: Map<ptyId, { clientId, viewport, wasResizedToPhone, previousCols, previousRows }>`
|
||||
- `pendingRestoreTimers: Map<ptyId, { timer, clientId }>` (changed from clientId-keyed to ptyId-keyed)
|
||||
- `terminalFitOverrides: Map<ptyId, { mode, cols, rows, previousCols, previousRows, clientId }>`
|
||||
- `mobileDisplayModes: Map<ptyId, 'auto' | 'phone' | 'desktop'>`
|
||||
|
||||
## Key Data Structures (Mobile Client)
|
||||
|
||||
- `viewportRef: { cols, rows } | null` — measured once from xterm, passed with every subscribe
|
||||
- `viewportMeasuredRef: boolean` — true after first successful measurement
|
||||
- `terminalUnsubsRef: Map<handle, unsub()>` — active subscription cleanup closures
|
||||
- `initializedHandlesRef: Set<handle>` — tracks which terminals have been init'd (prevents double-init)
|
||||
- `subscribeSeqRef: Map<handle, number>` — monotonic counter to ignore stale scrollback
|
||||
|
||||
---
|
||||
|
||||
## Bug 1: pendingRestoreTimers lost when 2 unsubscribes happen back-to-back
|
||||
|
||||
**Status: FIXED (verification inconclusive — may need more testing)**
|
||||
|
||||
**Root cause**: `pendingRestoreTimers` was keyed by `clientId` (one slot per device). When two terminals were unsubscribed in quick succession, the second timer overwrote the first.
|
||||
|
||||
**Fix applied**:
|
||||
1. Changed `pendingRestoreTimers` from `Map<clientId, {timer, ptyId}>` to `Map<ptyId, {timer, clientId}>`
|
||||
2. `handleMobileSubscribe` cancels only the restore timer for the SAME ptyId (re-subscribe case). Other terminals' timers fire normally so their desktop banners clear.
|
||||
3. `handleMobileSubscribe` skips resize if PTY is already at target phone dims
|
||||
4. `handleCreateTerminal` now unsubscribes the old active terminal before setting the new one
|
||||
5. Restore happens via: 300ms timer (tab switch), or `onClientDisconnected` (full disconnect)
|
||||
|
||||
**Bug 8 (banners accumulate on tab switch)**: The original Bug 1 fix was too aggressive — it cancelled ALL pending restore timers for the client, not just the one for the ptyId being re-subscribed. This prevented the 300ms restore timer from firing for the old terminal, so its desktop banner persisted. Fixed by narrowing the cancel scope to only the same ptyId.
|
||||
|
||||
**Files**: `orca-runtime.ts`, `[worktreeId].tsx`
|
||||
|
||||
---
|
||||
|
||||
## Bug 2: Intermittent blank terminal on tab switch
|
||||
|
||||
**Status: FIX v2 APPLIED — needs testing**
|
||||
|
||||
**Symptom**: After creating a new terminal tab, the original 2 terminals occasionally show blank. Leaving the worktree and re-entering fixes it.
|
||||
|
||||
**Root cause**: The WebView loads xterm.js from CDN, which takes time. Messages sent before `web-ready` queue in `pendingMessagesRef` and flush when ready. The original `handleTerminalWebReady` would unsub+resub ALL initialized terminals (even inactive ones), creating stale server-side subscriptions and disrupting the data stream.
|
||||
|
||||
**Fix v1 (FAILED)**: Gated `subscribeToTerminal` on webReady. This was too aggressive — it prevented subscriptions entirely, so no scrollback arrived, no init was queued, and the terminal stayed blank.
|
||||
|
||||
**Fix v2 (current)**:
|
||||
1. `subscribeToTerminal` has NO webReady guard — subscriptions start immediately, init messages queue in `pendingMessagesRef`, and flush when `web-ready` fires
|
||||
2. `handleTerminalWebReady` distinguishes first load vs reload:
|
||||
- **First load** (`wasAlreadyReady=false`): just marks webReady, triggers viewport measurement for active terminal. Pending messages flush after this callback returns → terminal renders.
|
||||
- **Reload** (`wasAlreadyReady=true`): unsubscribes and resubscribes to get fresh scrollback (old xterm buffer is gone). Only resubscribes if active.
|
||||
3. `setTerminalWebViewRef` simplified to just store the ref (no subscription logic)
|
||||
|
||||
**Flow (first load)**:
|
||||
1. `fetchTerminals` → `subscribeToTerminal(active)` → stream starts
|
||||
2. Scrollback arrives → `init()` queued in pendingMessages (WebView not ready yet)
|
||||
3. WebView loads xterm from CDN → `web-ready` fires
|
||||
4. `handleTerminalWebReady`: marks webReady, triggers viewport measurement (async)
|
||||
5. `flushPendingMessages()`: sends queued init → terminal renders
|
||||
6. Viewport measured → resubscribe with dims → server phone-fits → reinit with phone dims
|
||||
|
||||
---
|
||||
|
||||
## Bug 5: Desktop banner not showing for initial split pane after creating new tab
|
||||
|
||||
**Status: FIXED (verified via CDP + e2e testing)**
|
||||
|
||||
**Root cause**: The bug was caused by the inline restore mechanism in `handleMobileSubscribe`. When mobile switched tabs, the server would restore the previous terminal to desktop dims (sending `desktop-fit` IPC), which cleared the override from `overridesByPtyId`. After the next subscribe, the override was re-set via `mobile-fit` IPC, but the timing was tight and the banner could flicker or fail to appear.
|
||||
|
||||
**Fix**: Removed inline restore from `handleMobileSubscribe` (Bug 1 fix). PTYs now stay at phone dims when the mobile client switches tabs. Overrides persist in `overridesByPtyId` until the mobile client disconnects. This means ALL terminals the mobile client has subscribed to show the banner — which is correct since the mobile client "owns" those terminals.
|
||||
|
||||
**Verification**: E2e testing via CDP (desktop renderer) and agent-device (mobile) confirmed:
|
||||
- `setFitOverride()` is called correctly via IPC
|
||||
- `onOverrideChange` fires and triggers re-renders
|
||||
- `getFitOverrideForPane()` finds overrides when `ptyIdByPaneId` bindings exist
|
||||
- Banners show correctly for all terminals (1, 2, and 3 tabs) including after creating new tabs
|
||||
- The earlier diagnostic showed HMR clearing module-level maps was a test artifact, not a production issue
|
||||
|
||||
---
|
||||
|
||||
## Bug 7: Terminals disappear ("0 terminals") during rapid tab switching
|
||||
|
||||
**Status: FIXED (verified via e2e testing)**
|
||||
|
||||
**Symptom**: During rapid tab switching (3+ toggles in quick succession), all terminals disappear from the mobile UI. The terminal list shows "0 terminals". Leaving the worktree and re-entering fixes it.
|
||||
|
||||
**Root cause**: The periodic `fetchTerminals()` (every 2s) calls with `allowEmptyLoaded: true`. The server can transiently return an empty terminal list during rapid operations. The old code's subscription cleanup loop (`liveHandles` check) ran before the empty guard, unsubscribing all terminals, which then made the empty guard's `terminalUnsubsRef.current.size > 0` check fail.
|
||||
|
||||
**Fix applied**:
|
||||
1. Added `lastKnownTerminalCountRef` to track the previous non-zero terminal count
|
||||
2. When the server returns 0 terminals but `lastKnownTerminalCountRef > 0`, skip the FIRST empty response (set ref to 0 and return early)
|
||||
3. On the NEXT fetch, if still empty, `lastKnownTerminalCountRef` is 0, so the guard doesn't trigger and terminals are cleared normally
|
||||
4. This gives a ~2s grace period (one polling interval) to filter out transient empty responses
|
||||
5. The guard runs BEFORE the subscription cleanup loop, preventing premature unsubscription
|
||||
|
||||
**Verification**: Rapid tab switching (8 cycles across 3 terminals in ~1.6s) with 20s wait — terminals survived. Previous code cleared to "0 terminals" within 18s.
|
||||
|
||||
**Files**: `[worktreeId].tsx`
|
||||
|
||||
---
|
||||
|
||||
## Bug 3: Viewport measurement chicken-and-egg
|
||||
|
||||
**Status: SOLVED (workaround in place)**
|
||||
|
||||
The first subscribe has `viewport=none`. After scrollback init, async measure viewport, resubscribe with dims. Takes 2-3 round-trips. Works reliably.
|
||||
|
||||
---
|
||||
|
||||
## Bug 4: Desktop terminal focus doesn't follow mobile tab switching
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
`switchTab` now calls `terminal.focus` RPC.
|
||||
|
||||
---
|
||||
|
||||
## Bug 6: Claude not launching on new workspace
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**Root cause**: Mobile's `NewWorktreeModal` sends `startupCommand` (e.g. `'claude'`) in the `worktree.create` RPC call, but the Zod schema (`WorktreeCreate`) did not include `startupCommand`, so it was silently stripped during validation. The runtime's `createManagedWorktree` never received the startup command, so `args.startup` was always undefined and the activation IPC never included a startup payload. The left pane spawned a plain shell instead of the selected agent.
|
||||
|
||||
**Fix applied**:
|
||||
1. Added `startupCommand: OptionalString` to the `WorktreeCreate` Zod schema in `src/main/runtime/rpc/methods/worktree.ts`
|
||||
2. RPC handler maps `params.startupCommand` → `{ command }` and passes to `runtime.createManagedWorktree()`
|
||||
3. Added `startup?: WorktreeStartupLaunch` to `createManagedWorktree`'s args type in `orca-runtime.ts`
|
||||
|
||||
**Bug 6b: `waitForLeafPtyId` times out due to handle invalidation**
|
||||
|
||||
When a leaf's ptyId changes from null to a real value, `syncWindowGraph` invalidates the old handle (deletes it from `this.handles`). The `waitForLeafPtyId` callback calls `resolveLeafForHandle(handle)` which returns null because the handle no longer exists. The wait never resolves.
|
||||
|
||||
**Fix**: `waitForLeafPtyId` now captures the handle's `tabId` and `leafId` before the handle can be invalidated. The callback falls back to direct `this.leaves.get(getLeafKey(tabId, leafId))` lookup when the handle-based lookup fails.
|
||||
|
||||
**Files**: `src/main/runtime/rpc/methods/worktree.ts`, `src/main/runtime/orca-runtime.ts`
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic Logging
|
||||
|
||||
All logs prefixed with `[mobile-fit]`.
|
||||
|
||||
### Server-side (main process stdout)
|
||||
| Location | What it logs |
|
||||
|----------|-------------|
|
||||
| `terminal.subscribe` handler | handle, ptyId, client type, viewport |
|
||||
| `handleMobileSubscribe` | ptyId, mode, viewport, skip reasons, resize details |
|
||||
| `handleMobileUnsubscribe` | ptyId, subscriber state, wasResized |
|
||||
| `applyMobileDisplayMode` | ptyId, mode, subscriber state |
|
||||
|
||||
### Mobile client (React Native console)
|
||||
| Location | What it logs |
|
||||
|----------|-------------|
|
||||
| `subscribeToTerminal` | handle, seq, viewport, measured state |
|
||||
| scrollback handler | cols, rows, displayMode, hasSerialized, alreadyInit |
|
||||
| resized handler | cols, rows, displayMode, reason |
|
||||
| `switchTab` | prev/next handle, hasUnsub, hasRef |
|
||||
| `toggleDisplayMode` | handle, current mode, next mode |
|
||||
| `setTerminalWebViewRef` | handle, isActive, activeHandle |
|
||||
|
||||
### Desktop renderer (DevTools console)
|
||||
| Location | What it logs |
|
||||
|----------|-------------|
|
||||
| `useIpcEvents.ts` | fitOverrideChanged IPC events received |
|
||||
| `TerminalPane.tsx` | onOverrideChange callbacks fired |
|
||||
|
||||
---
|
||||
|
||||
## Priority Order
|
||||
|
||||
1. ~~**Bug 2 (blank terminal)**~~ — **FIXED**. WebView readiness lifecycle corrected.
|
||||
2. ~~**Duplicate prompt lines**~~ — **FIXED**. Removed inline restore, added alreadyAtTarget skip.
|
||||
3. ~~**Bug 7 (0 terminals)**~~ — **FIXED**. Consecutive-empty guard with `lastKnownTerminalCountRef`.
|
||||
4. ~~**Bug 5 (banner missing)**~~ — **FIXED**. Side effect of inline restore removal + verified via CDP.
|
||||
5. ~~**Bug 1 (timer overwrite)**~~ — **FIXED**. `pendingRestoreTimers` keyed by ptyId, cancel without restore.
|
||||
6. **Cleanup** — Remove `[mobile-fit]` diagnostic logs, Debug Test button, dead code.
|
||||
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
android/
|
||||
ios/
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"rules": {}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
# Orca Mobile
|
||||
|
||||
React Native companion app for Orca. Monitor worktrees, view terminal output, and send commands from your phone.
|
||||
|
||||
Local development uses two processes:
|
||||
|
||||
- Orca desktop/Electron from the repo root. This hosts the mobile WebSocket RPC server on port `6768`.
|
||||
- Expo Metro from `mobile/`. This serves the React Native app on port `8081`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 24+
|
||||
- pnpm
|
||||
- Xcode and/or Android Studio tooling for simulator or device builds
|
||||
- Expo Go on your phone, or a development client build when native modules are needed
|
||||
- Phone and desktop on the same LAN when testing a physical phone
|
||||
|
||||
## Start Desktop Orca
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Confirm the mobile RPC server is listening:
|
||||
|
||||
```bash
|
||||
lsof -nP -iTCP:6768 -sTCP:LISTEN
|
||||
```
|
||||
|
||||
Restart `pnpm dev` after changing Electron main-process code. Metro hot reload only applies to the mobile JavaScript bundle.
|
||||
|
||||
## Start The Mobile App
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
pnpm install
|
||||
pnpm start
|
||||
```
|
||||
|
||||
Scan the Expo QR code with your phone's camera on iOS, or Expo Go on Android.
|
||||
|
||||
For a native dev-client build:
|
||||
|
||||
```bash
|
||||
pnpm exec expo run:android
|
||||
pnpm exec expo run:ios
|
||||
pnpm start --dev-client
|
||||
```
|
||||
|
||||
## Pair With Desktop Orca
|
||||
|
||||
1. Open Orca desktop.
|
||||
2. Go to Settings > Mobile.
|
||||
3. Scan the pairing QR code from the mobile app.
|
||||
4. Confirm the mobile host endpoint is `ws://<desktop-ip>:6768`.
|
||||
|
||||
For the Android emulator, use `ws://10.0.2.2:6768`. For a physical phone, use the desktop LAN IP, for example `ws://192.168.0.179:6768`.
|
||||
|
||||
If the phone has a stale host entry, remove it from the app and pair again.
|
||||
|
||||
## Development Paths
|
||||
|
||||
### Android Phone
|
||||
|
||||
1. Install Expo Go from Google Play
|
||||
2. Run `pnpm start`, scan QR with Expo Go
|
||||
3. For native modules: `pnpm exec expo run:android`
|
||||
4. Run with `pnpm start --dev-client`
|
||||
|
||||
### iOS Simulator
|
||||
|
||||
1. Install Xcode from the App Store
|
||||
2. Run `pnpm start --ios` to open in iOS Simulator
|
||||
|
||||
## Physical Phone Debugging
|
||||
|
||||
The phone can be inspected through the connected device tooling:
|
||||
|
||||
```bash
|
||||
orca snapshot --json
|
||||
orca click --element @e3 --json
|
||||
orca fill --element @e1 --value "ls" --json
|
||||
orca screenshot --json
|
||||
```
|
||||
|
||||
Use `snapshot` first to find the current element refs, then click/fill those refs. After mobile file edits, Metro usually hot reloads automatically, but navigating out of and back into the session screen can be useful because it re-runs `terminal.subscribe`.
|
||||
|
||||
## Terminal Streaming Repro Without A Phone
|
||||
|
||||
Use this when terminal output does not render on device and you need to split server streaming bugs from WebView/UI bugs:
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64>
|
||||
```
|
||||
|
||||
You can pass a worktree selector as the third argument:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "id:<worktreeId>"
|
||||
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "path:/absolute/worktree/path"
|
||||
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "name:my-worktree"
|
||||
```
|
||||
|
||||
The expected result includes:
|
||||
|
||||
```text
|
||||
streamSawMarker: true
|
||||
readSawMarker: true
|
||||
```
|
||||
|
||||
If this repro fails, debug the desktop runtime/PTY path before the mobile WebView. If it passes but the phone is blank, debug the session screen or `TerminalWebView` readiness/queueing path.
|
||||
|
||||
## Terminal Color Repro Without A Phone
|
||||
|
||||
Use this when terminal colors disappear after switching tabs. Open a Claude Code terminal and at least one other terminal in the target worktree, then run:
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/repro-terminal-colors.ts \
|
||||
<deviceToken> <serverPublicKeyB64> "id:<worktreeId>"
|
||||
```
|
||||
|
||||
The script captures `terminal.subscribe` snapshots in an A → B → A sequence and writes raw snapshots to `mobile/terminal-color-repro/`. If the two A snapshots have different `sgrColor` counts, the desktop snapshot changed during the switch. If they match, the ANSI color data is still present and the bug is in mobile replay/rendering.
|
||||
|
||||
## Validation
|
||||
|
||||
Run these checks before committing mobile terminal changes:
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm lint
|
||||
cd ..
|
||||
pnpm typecheck:node
|
||||
```
|
||||
|
||||
## Mock Server
|
||||
|
||||
Develop the mobile app without a running Orca desktop instance:
|
||||
|
||||
```bash
|
||||
pnpm mock-server # starts mock WebSocket server on port 6768
|
||||
```
|
||||
|
||||
Connect from the app using endpoint `ws://localhost:6768` and token `mock-device-token`.
|
||||
|
||||
## Connecting to Real Orca
|
||||
|
||||
1. Start Orca desktop with WebSocket transport enabled
|
||||
2. In Orca, go to Settings > Mobile and scan the QR code with this app
|
||||
3. The QR encodes the connection endpoint, device token, and TLS fingerprint
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mobile/
|
||||
├── app/ # Expo Router screens (file-based routing)
|
||||
│ ├── _layout.tsx # Root layout with navigation stack
|
||||
│ ├── index.tsx # Home screen — paired hosts list
|
||||
│ └── pair-scan.tsx # QR code scanning screen
|
||||
├── src/
|
||||
│ ├── terminal/ # Terminal WebView and xterm bridge
|
||||
│ └── transport/ # WebSocket RPC client
|
||||
├── scripts/
|
||||
│ ├── test-subscribe.ts # Desktop streaming repro without a phone
|
||||
│ └── mock-server.ts # Standalone mock WebSocket server
|
||||
└── assets/ # App icons and splash screen
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"scheme": "orca",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#111111"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.stably.orca.mobile",
|
||||
"buildNumber": "2",
|
||||
"infoPlist": {
|
||||
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsLocalNetworking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#111111"
|
||||
},
|
||||
"usesCleartextTraffic": true,
|
||||
"allowBackup": false,
|
||||
"package": "com.stably.orca.mobile"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/splash-icon.png",
|
||||
"backgroundColor": "#111111"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow Orca to use the camera for QR code scanning."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"usesCleartextTraffic": true
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback } from 'react'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Stack } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { colors } from '../src/theme/mobile-theme'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
|
||||
// Why: keeps the native splash screen visible until the React tree is mounted
|
||||
// and ready to render. Without this the user sees a blank white/black frame
|
||||
// between the native splash and the first React paint.
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
// Why: without this, expo-notifications silently drops notifications when
|
||||
// the app is in the foreground. Setting all three to true makes iOS/Android
|
||||
// display the banner, play the sound, and show the badge even while the
|
||||
// app is active. This runs once at module load time before any notification
|
||||
// is scheduled.
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false
|
||||
})
|
||||
})
|
||||
|
||||
export default function RootLayout() {
|
||||
// Why: hide the native splash only once the navigation Stack has been laid
|
||||
// out — this is the earliest moment the user will see actual app content.
|
||||
// Previously the splash hid when a placeholder View rendered, leaving a
|
||||
// grey gap before the real screen appeared.
|
||||
const onNavigatorLayout = useCallback(async () => {
|
||||
await SplashScreen.hideAsync()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={styles.root} onLayout={onNavigatorLayout}>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgPanel },
|
||||
headerTintColor: colors.textPrimary,
|
||||
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
|
||||
contentStyle: { backgroundColor: colors.bgBase },
|
||||
headerShadowVisible: false
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: false,
|
||||
headerTitle: () => <OrcaLogo size={22} />
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="notifications" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="about" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="h" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Globe } from 'lucide-react-native'
|
||||
import Svg, { Path } from 'react-native-svg'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
function GithubIcon({ size = 16, color = colors.textSecondary }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
function XIcon({ size = 16, color = colors.textSecondary }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AboutScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>About</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.brand}>
|
||||
<OrcaLogo size={28} />
|
||||
<Text style={styles.brandName}>Orca</Text>
|
||||
<Text style={styles.brandSub}>Open-source agent IDE for 100x builders</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://onOrca.dev')}
|
||||
>
|
||||
<Globe size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowValue}>onOrca.dev</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://github.com/stablyai/orca')}
|
||||
>
|
||||
<GithubIcon />
|
||||
<Text style={styles.rowValue}>stablyai/orca</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://x.com/orca_build')}
|
||||
>
|
||||
<XIcon />
|
||||
<Text style={styles.rowValue}>@orca_build</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
brand: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xl,
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
brandName: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
color: colors.textPrimary,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
brandSub: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowLabel: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
rowValue: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
import { Stack } from 'expo-router'
|
||||
import { colors } from '../../src/theme/mobile-theme'
|
||||
|
||||
export default function HostGroupLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.bgBase }
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="[hostId]/index" options={{ title: 'Host' }} />
|
||||
<Stack.Screen name="[hostId]/session/[worktreeId]" options={{ title: 'Terminal' }} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,947 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, FlatList } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter, useFocusEffect } from 'expo-router'
|
||||
import {
|
||||
Monitor,
|
||||
QrCode,
|
||||
Settings,
|
||||
Bot,
|
||||
Clock,
|
||||
GitPullRequest,
|
||||
ChevronRight,
|
||||
Terminal,
|
||||
Plus
|
||||
} from 'lucide-react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { loadHosts, removeHost, renameHost } from '../src/transport/host-store'
|
||||
import { connect, type RpcClient } from '../src/transport/rpc-client'
|
||||
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
|
||||
import type { ConnectionState, HostProfile } from '../src/transport/types'
|
||||
import { triggerMediumImpact } from '../src/platform/haptics'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ActionSheetModal } from '../src/components/ActionSheetModal'
|
||||
import { ConfirmModal } from '../src/components/ConfirmModal'
|
||||
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
|
||||
import { colors, spacing, radii } from '../src/theme/mobile-theme'
|
||||
|
||||
function endpointLabel(endpoint: string): string {
|
||||
try {
|
||||
const url = new URL(endpoint)
|
||||
return `${url.hostname}${url.port ? `:${url.port}` : ''}`
|
||||
} catch {
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<ConnectionState, string> = {
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting…',
|
||||
disconnected: 'Disconnected',
|
||||
reconnecting: 'Reconnecting…',
|
||||
handshaking: 'Connecting…',
|
||||
'auth-failed': 'Auth failed'
|
||||
}
|
||||
|
||||
type StatsSummary = {
|
||||
totalAgentsSpawned: number
|
||||
totalPRsCreated: number
|
||||
totalAgentTimeMs: number
|
||||
firstEventAt: number | null
|
||||
}
|
||||
|
||||
type WorktreeSummary = {
|
||||
worktreeId: string
|
||||
repo: string
|
||||
branch: string
|
||||
displayName: string
|
||||
liveTerminalCount: number
|
||||
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
}
|
||||
|
||||
type HostWorktreeInfo = {
|
||||
hostId: string
|
||||
totalWorktrees: number
|
||||
activeCount: number
|
||||
lastActiveWorktree: WorktreeSummary | null
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalMinutes = Math.floor(ms / 60_000)
|
||||
const totalHours = Math.floor(totalMinutes / 60)
|
||||
const days = Math.floor(totalHours / 24)
|
||||
const hours = totalHours % 24
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
const minutes = totalMinutes % 60
|
||||
if (totalHours > 0) return `${totalHours}h ${minutes}m`
|
||||
return `${totalMinutes}m`
|
||||
}
|
||||
|
||||
function fetchStats(
|
||||
client: RpcClient,
|
||||
setStats: (s: StatsSummary) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
client
|
||||
.sendRequest('stats.summary')
|
||||
.then((response) => {
|
||||
if (disposed()) return
|
||||
if (response.ok) {
|
||||
setStats(response.result as StatsSummary)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function fetchWorktreeInfo(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setInfo: (
|
||||
updater: (prev: Record<string, HostWorktreeInfo>) => Record<string, HostWorktreeInfo>
|
||||
) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
const markLoaded = () => {
|
||||
setInfo((prev) => ({
|
||||
...prev,
|
||||
[hostId]: {
|
||||
hostId,
|
||||
totalWorktrees: 0,
|
||||
activeCount: 0,
|
||||
lastActiveWorktree: null
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
client
|
||||
.sendRequest('worktree.ps')
|
||||
.then((response) => {
|
||||
if (disposed()) return
|
||||
if (response.ok) {
|
||||
const result = response.result as { worktrees: WorktreeSummary[] }
|
||||
const worktrees = result.worktrees ?? []
|
||||
setCachedWorktrees(hostId, worktrees)
|
||||
const activeStatuses = new Set(['working', 'active', 'permission'])
|
||||
const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status))
|
||||
const lastActive = active.length > 0 ? active[0] : (worktrees[0] ?? null)
|
||||
setInfo((prev) => ({
|
||||
...prev,
|
||||
[hostId]: {
|
||||
hostId,
|
||||
totalWorktrees: worktrees.length,
|
||||
activeCount: active.length,
|
||||
lastActiveWorktree: lastActive
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
markLoaded()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed()) markLoaded()
|
||||
})
|
||||
}
|
||||
|
||||
// Why: repo names get a stable color derived from hashing, matching the
|
||||
// host detail page's colored dots for visual consistency.
|
||||
const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
|
||||
function repoColor(name: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) | 0
|
||||
}
|
||||
return REPO_COLORS[Math.abs(hash) % REPO_COLORS.length]
|
||||
}
|
||||
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter()
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<HostProfile | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
|
||||
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null)
|
||||
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
|
||||
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
|
||||
null
|
||||
)
|
||||
const clientsRef = useRef<Array<{ hostId: string; client: RpcClient }>>([])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let stale = false
|
||||
void loadHosts().then((h) => {
|
||||
if (!stale) setHosts(h)
|
||||
})
|
||||
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
|
||||
if (stale || !raw) return
|
||||
try {
|
||||
setLastVisited(JSON.parse(raw))
|
||||
} catch {}
|
||||
})
|
||||
for (const entry of clientsRef.current) {
|
||||
if (entry.client.getState() === 'connected') {
|
||||
fetchStats(entry.client, setStats, () => stale)
|
||||
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [])
|
||||
)
|
||||
|
||||
const sortedHosts = useMemo(
|
||||
() => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected),
|
||||
[hosts]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
const notifCleanups: Array<() => void> = []
|
||||
const entries = hosts.flatMap((host) => {
|
||||
if (!host.publicKeyB64 || !host.deviceToken) {
|
||||
setHostStates((prev) => ({ ...prev, [host.id]: 'auth-failed' }))
|
||||
return []
|
||||
}
|
||||
setHostStates((prev) => ({
|
||||
...prev,
|
||||
[host.id]: prev[host.id] ?? 'connecting'
|
||||
}))
|
||||
let client: ReturnType<typeof connect>
|
||||
try {
|
||||
client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => {
|
||||
if (disposed) return
|
||||
setHostStates((prev) => ({ ...prev, [host.id]: state }))
|
||||
})
|
||||
} catch {
|
||||
setHostStates((prev) => ({ ...prev, [host.id]: 'auth-failed' }))
|
||||
return []
|
||||
}
|
||||
|
||||
let unsubNotif: (() => void) | null = null
|
||||
let statsFetched = false
|
||||
const unsubState = client.onStateChange((state) => {
|
||||
if (state === 'connected') {
|
||||
if (!unsubNotif) {
|
||||
unsubNotif = subscribeToDesktopNotifications(client)
|
||||
}
|
||||
if (!statsFetched) {
|
||||
statsFetched = true
|
||||
fetchStats(client, setStats, () => disposed)
|
||||
fetchWorktreeInfo(client, host.id, setWorktreeInfo, () => disposed)
|
||||
}
|
||||
} else if (unsubNotif) {
|
||||
unsubNotif()
|
||||
unsubNotif = null
|
||||
}
|
||||
})
|
||||
notifCleanups.push(() => {
|
||||
unsubState()
|
||||
unsubNotif?.()
|
||||
})
|
||||
|
||||
return [{ hostId: host.id, client }]
|
||||
})
|
||||
|
||||
clientsRef.current = entries
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
clientsRef.current = []
|
||||
for (const cleanup of notifCleanups) cleanup()
|
||||
for (const entry of entries) entry.client.close()
|
||||
}
|
||||
}, [hosts])
|
||||
|
||||
// Why: prefer the worktree the user last opened on this device so the
|
||||
// "Resume" card reflects their mobile session history, not just the
|
||||
// desktop's most-recently-outputting worktree.
|
||||
const resumeWorktree = useMemo(() => {
|
||||
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
|
||||
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
|
||||
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
|
||||
if (match) return { hostId: lastVisited.hostId, worktree: match }
|
||||
}
|
||||
for (const host of sortedHosts) {
|
||||
if (hostStates[host.id] !== 'connected') continue
|
||||
const info = worktreeInfo[host.id]
|
||||
if (info?.lastActiveWorktree) {
|
||||
return { hostId: host.id, worktree: info.lastActiveWorktree }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
|
||||
|
||||
const resumeLoading = useMemo(
|
||||
() =>
|
||||
sortedHosts.some((host) => {
|
||||
const state = hostStates[host.id] ?? 'connecting'
|
||||
return (
|
||||
state === 'connecting' ||
|
||||
state === 'handshaking' ||
|
||||
state === 'reconnecting' ||
|
||||
(state === 'connected' && !worktreeInfo[host.id])
|
||||
)
|
||||
}),
|
||||
[sortedHosts, hostStates, worktreeInfo]
|
||||
)
|
||||
|
||||
async function handleRename(newName: string) {
|
||||
if (!renameTarget) return
|
||||
try {
|
||||
await renameHost(renameTarget.id, newName)
|
||||
setRenameTarget(null)
|
||||
setHosts(await loadHosts())
|
||||
} catch {
|
||||
setRenameTarget(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirmRemove) return
|
||||
try {
|
||||
await removeHost(confirmRemove.id)
|
||||
setConfirmRemove(null)
|
||||
setHosts(await loadHosts())
|
||||
} catch {
|
||||
setConfirmRemove(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* ─── Top bar ─── */}
|
||||
<View style={styles.topBar}>
|
||||
<View style={styles.brandLockup}>
|
||||
<View style={styles.logoMark}>
|
||||
<OrcaLogo size={18} />
|
||||
</View>
|
||||
<Text style={styles.brandName}>Orca</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
|
||||
onPress={() => router.push('/settings')}
|
||||
>
|
||||
<Settings size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{hosts.length === 0 ? (
|
||||
/* ─── Empty state: onboarding ─── */
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyHero}>
|
||||
<Text style={styles.emptyTitle}>Connect your desktop</Text>
|
||||
<Text style={styles.emptyBody}>
|
||||
Pair with Orca on your computer to monitor worktrees, watch agents work, and manage
|
||||
terminals — all from your phone.
|
||||
</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={() => router.push('/pair-scan')}>
|
||||
<QrCode size={17} color={colors.bgBase} />
|
||||
<Text style={styles.primaryButtonText}>Scan Pairing Code</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.stepsSection}>
|
||||
<Text style={styles.sectionHeading}>How it works</Text>
|
||||
{ONBOARDING_STEPS.map((step, i) => (
|
||||
<View key={step.title} style={[styles.stepRow, i > 0 && styles.stepRowBorder]}>
|
||||
<View style={styles.stepNum}>
|
||||
<Text style={styles.stepNumText}>{i + 1}</Text>
|
||||
</View>
|
||||
<View style={styles.stepText}>
|
||||
<Text style={styles.stepTitle}>{step.title}</Text>
|
||||
<Text style={styles.stepDesc}>{step.desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
/* ─── Populated state ─── */
|
||||
<FlatList
|
||||
data={sortedHosts}
|
||||
keyExtractor={(h) => h.id}
|
||||
contentContainerStyle={styles.list}
|
||||
ListHeaderComponent={
|
||||
<View>
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.heroTitle}>Welcome back</Text>
|
||||
</View>
|
||||
|
||||
{stats && (
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statCard}>
|
||||
<View style={styles.statIcon}>
|
||||
<Bot size={14} color={colors.textMuted} />
|
||||
</View>
|
||||
<Text style={styles.statValue}>
|
||||
{stats.totalAgentsSpawned.toLocaleString()}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Agents spawned</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<View style={styles.statIcon}>
|
||||
<Clock size={14} color={colors.textMuted} />
|
||||
</View>
|
||||
<Text style={styles.statValue}>{formatDuration(stats.totalAgentTimeMs)}</Text>
|
||||
<Text style={styles.statLabel}>Agent time</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<View style={styles.statIcon}>
|
||||
<GitPullRequest size={14} color={colors.textMuted} />
|
||||
</View>
|
||||
<Text style={styles.statValue}>{stats.totalPRsCreated.toLocaleString()}</Text>
|
||||
<Text style={styles.statLabel}>PRs created</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.sectionHeading}>Desktops</Text>
|
||||
</View>
|
||||
}
|
||||
ItemSeparatorComponent={CardGap}
|
||||
renderItem={({ item }) => {
|
||||
const state = hostStates[item.id] ?? 'connecting'
|
||||
const connected = state === 'connected'
|
||||
const info = worktreeInfo[item.id]
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
|
||||
onPress={() => router.push(`/h/${item.id}`)}
|
||||
onLongPress={() => {
|
||||
triggerMediumImpact()
|
||||
setActionTarget(item)
|
||||
}}
|
||||
delayLongPress={400}
|
||||
>
|
||||
<View style={styles.hostIcon}>
|
||||
<Monitor
|
||||
size={20}
|
||||
color={connected ? colors.textPrimary : colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.hostMain}>
|
||||
<Text
|
||||
style={[styles.hostName, !connected && { color: colors.textSecondary }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
<View style={styles.hostMeta}>
|
||||
<View
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{ backgroundColor: connected ? colors.statusGreen : colors.textMuted }
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.hostMetaItem}>
|
||||
{STATUS_LABELS[state]}
|
||||
{connected && info
|
||||
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
|
||||
: ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
ListFooterComponent={
|
||||
<View>
|
||||
{/* ─── Resume card ─── */}
|
||||
{resumeWorktree ? (
|
||||
<>
|
||||
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Resume</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.resumeCard, pressed && styles.hostCardPressed]}
|
||||
onPress={() =>
|
||||
router.push(
|
||||
`/h/${resumeWorktree.hostId}/session/${encodeURIComponent(resumeWorktree.worktree.worktreeId)}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<View style={styles.resumeIcon}>
|
||||
<Terminal size={18} color={colors.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.resumeMain}>
|
||||
<Text style={styles.resumeTitle} numberOfLines={1}>
|
||||
{resumeWorktree.worktree.displayName}
|
||||
</Text>
|
||||
<View style={styles.resumeSub}>
|
||||
<View
|
||||
style={[
|
||||
styles.repoDot,
|
||||
{ backgroundColor: repoColor(resumeWorktree.worktree.repo) }
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.resumeSubText} numberOfLines={1}>
|
||||
{resumeWorktree.worktree.repo}
|
||||
{' · '}
|
||||
{resumeWorktree.worktree.branch}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</>
|
||||
) : hosts.length > 0 && resumeLoading ? (
|
||||
<>
|
||||
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Resume</Text>
|
||||
<View style={styles.resumeCard}>
|
||||
<View style={[styles.resumeIcon, styles.skeletonBlock]} />
|
||||
<View style={styles.resumeMain}>
|
||||
<View style={[styles.skeletonLine, { width: '55%' }]} />
|
||||
<View style={[styles.skeletonLine, { width: '35%', marginTop: 6 }]} />
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ─── Quick actions ─── */}
|
||||
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
|
||||
<View style={styles.quickActions}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.quickAction, pressed && styles.hostCardPressed]}
|
||||
onPress={() => router.push('/pair-scan')}
|
||||
>
|
||||
<View style={styles.quickActionIcon}>
|
||||
<QrCode size={20} color={colors.textSecondary} />
|
||||
</View>
|
||||
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.quickAction, pressed && styles.hostCardPressed]}
|
||||
onPress={() => {
|
||||
const connectedHost = sortedHosts.find((h) => hostStates[h.id] === 'connected')
|
||||
if (connectedHost) {
|
||||
router.push(`/h/${connectedHost.id}?action=newWorktree`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={styles.quickActionIcon}>
|
||||
<Plus size={20} color={colors.textSecondary} />
|
||||
</View>
|
||||
<Text style={styles.quickActionLabel}>New Worktree</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ─── Action sheets (shared by both states) ─── */}
|
||||
<ActionSheetModal
|
||||
visible={actionTarget != null}
|
||||
title={actionTarget?.name}
|
||||
message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined}
|
||||
actions={[
|
||||
{
|
||||
label: 'Rename',
|
||||
onPress: () => {
|
||||
const host = actionTarget
|
||||
setActionTarget(null)
|
||||
if (host) setRenameTarget(host)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Remove',
|
||||
destructive: true,
|
||||
onPress: () => {
|
||||
const host = actionTarget
|
||||
setActionTarget(null)
|
||||
if (host) setConfirmRemove(host)
|
||||
}
|
||||
}
|
||||
]}
|
||||
onClose={() => setActionTarget(null)}
|
||||
/>
|
||||
|
||||
<TextInputModal
|
||||
visible={renameTarget != null}
|
||||
title="Rename Host"
|
||||
message="Enter a new name for this host."
|
||||
defaultValue={renameTarget?.name ?? ''}
|
||||
placeholder="Host name"
|
||||
onSubmit={(name) => void handleRename(name)}
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
visible={confirmRemove != null}
|
||||
title="Remove Host"
|
||||
message={`Remove "${confirmRemove?.name}"? You can re-pair later.`}
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
onConfirm={() => void handleRemove()}
|
||||
onCancel={() => setConfirmRemove(null)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
function CardGap() {
|
||||
return <View style={styles.cardGap} />
|
||||
}
|
||||
|
||||
const ONBOARDING_STEPS = [
|
||||
{
|
||||
title: 'Open Orca desktop',
|
||||
desc: 'Go to Settings → Mobile and generate a pairing QR code.'
|
||||
},
|
||||
{
|
||||
title: 'Scan the code',
|
||||
desc: 'Tap the button above to open the scanner. Point at the QR code on your screen.'
|
||||
},
|
||||
{
|
||||
title: "You're connected",
|
||||
desc: 'Your desktop will appear here. Everything is encrypted end-to-end.'
|
||||
}
|
||||
]
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
|
||||
/* ─── Top bar ─── */
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.md
|
||||
},
|
||||
brandLockup: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minWidth: 0
|
||||
},
|
||||
logoMark: {
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
brandName: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 17,
|
||||
fontWeight: '700'
|
||||
},
|
||||
iconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
iconButtonPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
|
||||
/* ─── Hero / greeting ─── */
|
||||
hero: {
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
heroTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 26,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.3
|
||||
},
|
||||
|
||||
/* ─── Stat cards ─── */
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(26,26,26,0.6)',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: 10,
|
||||
padding: spacing.md
|
||||
},
|
||||
statIcon: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 7,
|
||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 10
|
||||
},
|
||||
statValue: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.3
|
||||
},
|
||||
statLabel: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
marginTop: 3
|
||||
},
|
||||
|
||||
/* ─── Section heading ─── */
|
||||
sectionHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
|
||||
/* ─── List ─── */
|
||||
list: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.xl
|
||||
},
|
||||
cardGap: {
|
||||
height: spacing.sm
|
||||
},
|
||||
|
||||
/* ─── Host cards ─── */
|
||||
hostCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.md,
|
||||
paddingVertical: 14,
|
||||
borderRadius: radii.card,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
hostCardPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
hostIcon: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 13,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised,
|
||||
marginRight: 14,
|
||||
position: 'relative'
|
||||
},
|
||||
hostMain: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
hostName: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
lineHeight: 20
|
||||
},
|
||||
hostMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginTop: 3
|
||||
},
|
||||
hostMetaItem: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
hostMetaDot: {
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: colors.textMuted,
|
||||
marginHorizontal: 8
|
||||
},
|
||||
statusDot: {
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 3.5
|
||||
},
|
||||
|
||||
/* ─── Resume card ─── */
|
||||
resumeCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.card,
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.md,
|
||||
paddingVertical: 14
|
||||
},
|
||||
resumeIcon: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 13,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 14
|
||||
},
|
||||
resumeMain: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
resumeTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
resumeSub: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginTop: 3
|
||||
},
|
||||
repoDot: {
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 3.5
|
||||
},
|
||||
resumeSubText: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary,
|
||||
flex: 1
|
||||
},
|
||||
|
||||
/* ─── Skeleton ─── */
|
||||
skeletonBlock: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
opacity: 0.5
|
||||
},
|
||||
skeletonLine: {
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.bgRaised,
|
||||
opacity: 0.5
|
||||
},
|
||||
|
||||
/* ─── Quick actions ─── */
|
||||
quickActions: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
quickAction: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.card,
|
||||
padding: spacing.lg,
|
||||
alignItems: 'center',
|
||||
gap: 10
|
||||
},
|
||||
quickActionIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
quickActionLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center'
|
||||
},
|
||||
|
||||
/* ─── Empty state ─── */
|
||||
emptyContainer: {
|
||||
flex: 1
|
||||
},
|
||||
emptyGreeting: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.sm
|
||||
},
|
||||
emptyHero: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
paddingBottom: 40
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center',
|
||||
marginBottom: 10
|
||||
},
|
||||
emptyBody: {
|
||||
fontSize: 15,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
marginBottom: 32
|
||||
},
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 14,
|
||||
borderRadius: radii.card
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: 15,
|
||||
fontWeight: '700'
|
||||
},
|
||||
|
||||
/* ─── Onboarding steps ─── */
|
||||
stepsSection: {
|
||||
paddingHorizontal: spacing.xl
|
||||
},
|
||||
stepRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 14,
|
||||
paddingVertical: spacing.lg
|
||||
},
|
||||
stepRowBorder: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderSubtle
|
||||
},
|
||||
stepNum: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 1
|
||||
},
|
||||
stepNumText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
stepText: {
|
||||
flex: 1
|
||||
},
|
||||
stepTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 3
|
||||
},
|
||||
stepDesc: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 17
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, Switch } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter, useFocusEffect } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import {
|
||||
loadPushNotificationsEnabled,
|
||||
savePushNotificationsEnabled
|
||||
} from '../src/storage/preferences'
|
||||
import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications'
|
||||
|
||||
export default function NotificationsScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [pushEnabled, setPushEnabled] = useState(true)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void loadPushNotificationsEnabled().then(setPushEnabled)
|
||||
}, [])
|
||||
)
|
||||
|
||||
const togglePush = async (value: boolean) => {
|
||||
if (value) {
|
||||
const granted = await ensureNotificationPermissions()
|
||||
if (!granted) return
|
||||
}
|
||||
setPushEnabled(value)
|
||||
await savePushNotificationsEnabled(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Notifications</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Push Notifications</Text>
|
||||
<Switch
|
||||
value={pushEnabled}
|
||||
onValueChange={(v) => void togglePush(v)}
|
||||
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.hint}>
|
||||
Receive notifications when an agent task completes on your desktop.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowLabel: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
hint: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 18,
|
||||
paddingHorizontal: spacing.md + 2,
|
||||
paddingBottom: spacing.md
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { decodePairingUrl } from '../src/transport/pairing'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import { saveHost, getNextHostName } from '../src/transport/host-store'
|
||||
import type { PairingOffer } from '../src/transport/types'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
function Step({ number, text }: { number: number; text: string }) {
|
||||
return (
|
||||
<View style={styles.step}>
|
||||
<View style={styles.stepBadge}>
|
||||
<Text style={styles.stepNumber}>{number}</Text>
|
||||
</View>
|
||||
<Text style={styles.stepText}>{text}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [permission, requestPermission] = useCameraPermissions()
|
||||
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const processingRef = useRef(false)
|
||||
|
||||
const handleBarCodeScanned = useCallback(
|
||||
({ data }: { data: string }) => {
|
||||
if (processingRef.current) return
|
||||
processingRef.current = true
|
||||
|
||||
const offer = decodePairingUrl(data)
|
||||
if (!offer) {
|
||||
setStatus('error')
|
||||
setErrorMessage('Not a valid Orca QR code')
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
void testAndSave(offer)
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
async function testAndSave(offer: PairingOffer) {
|
||||
setStatus('connecting')
|
||||
let client: ReturnType<typeof connect> | null = null
|
||||
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64)
|
||||
const response = await client.sendRequest('status.get')
|
||||
client.close()
|
||||
client = null
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.error.code === 'unauthorized') {
|
||||
setStatus('error')
|
||||
setErrorMessage('Authentication failed — token may be expired')
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
setStatus('error')
|
||||
setErrorMessage(`Server error: ${response.error.message}`)
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const hostId = `host-${Date.now()}`
|
||||
const hostName = await getNextHostName()
|
||||
|
||||
await saveHost({
|
||||
id: hostId,
|
||||
name: hostName,
|
||||
endpoint: offer.endpoint,
|
||||
deviceToken: offer.deviceToken,
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
lastConnected: Date.now()
|
||||
})
|
||||
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setErrorMessage('Cannot connect — check that your computer is on the same network')
|
||||
processingRef.current = false
|
||||
} finally {
|
||||
client?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
setStatus('scanning')
|
||||
setErrorMessage('')
|
||||
processingRef.current = false
|
||||
}
|
||||
|
||||
const containerPadding = { paddingTop: insets.top + spacing.sm }
|
||||
|
||||
if (!permission) {
|
||||
return (
|
||||
<View style={[styles.container, containerPadding]}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<View style={[styles.container, containerPadding]}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.title}>Camera Permission</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Orca needs camera access to scan the pairing QR code from your desktop.
|
||||
</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={requestPermission}>
|
||||
<Text style={styles.primaryButtonText}>Grant Camera Access</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerPadding]}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.steps}>
|
||||
<Step number={1} text="Open Orca on your computer" />
|
||||
<Step number={2} text="Go to Settings → Mobile" />
|
||||
<Step number={3} text="Scan the QR code" />
|
||||
</View>
|
||||
|
||||
{status === 'scanning' && (
|
||||
<View style={styles.cameraWrap}>
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={handleBarCodeScanned}
|
||||
/>
|
||||
<View style={styles.reticle} pointerEvents="none">
|
||||
<View style={[styles.corner, styles.cornerTL]} />
|
||||
<View style={[styles.corner, styles.cornerTR]} />
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{status === 'connecting' && (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={colors.textSecondary} />
|
||||
<Text style={styles.connectingText}>Connecting…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={retry}>
|
||||
<Text style={styles.primaryButtonText}>Try Again</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
steps: {
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
marginLeft: 7
|
||||
},
|
||||
step: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
stepBadge: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
stepNumber: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
stepText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
cameraWrap: {
|
||||
flex: 1,
|
||||
borderRadius: radii.camera,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
camera: {
|
||||
...StyleSheet.absoluteFillObject
|
||||
},
|
||||
reticle: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderColor: 'rgba(255,255,255,0.7)'
|
||||
},
|
||||
cornerTL: {
|
||||
top: '30%',
|
||||
left: '20%',
|
||||
borderTopWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderTopLeftRadius: 6
|
||||
},
|
||||
cornerTR: {
|
||||
top: '30%',
|
||||
right: '20%',
|
||||
borderTopWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderTopRightRadius: 6
|
||||
},
|
||||
cornerBL: {
|
||||
bottom: '30%',
|
||||
left: '20%',
|
||||
borderBottomWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderBottomLeftRadius: 6
|
||||
},
|
||||
cornerBR: {
|
||||
bottom: '30%',
|
||||
right: '20%',
|
||||
borderBottomWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderBottomRightRadius: 6
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
connectingText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, ChevronRight, Info, Bell, Wrench } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Settings</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/notifications')}
|
||||
>
|
||||
<Bell size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Notifications</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/troubleshoot')}
|
||||
>
|
||||
<Wrench size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Troubleshooting</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/about')}
|
||||
>
|
||||
<Info size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>About</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowLabel: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,455 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Platform
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
WifiOff,
|
||||
Shield,
|
||||
Monitor,
|
||||
Clock,
|
||||
Globe,
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle
|
||||
} from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
|
||||
type DiagnosticStatus = 'idle' | 'running' | 'done'
|
||||
|
||||
type CheckResult = {
|
||||
label: string
|
||||
status: 'pass' | 'fail' | 'warn'
|
||||
detail: string
|
||||
}
|
||||
|
||||
type TroubleshootSection = {
|
||||
id: string
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
steps: string[]
|
||||
}
|
||||
|
||||
const sections: TroubleshootSection[] = [
|
||||
{
|
||||
id: 'wifi',
|
||||
icon: <WifiOff size={16} color={colors.textSecondary} />,
|
||||
title: 'Different WiFi Networks',
|
||||
steps: [
|
||||
'Both devices must be on the same local network.',
|
||||
'Ethernet and WiFi must share the same subnet.',
|
||||
'Try reconnecting WiFi on both devices.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'firewall',
|
||||
icon: <Shield size={16} color={colors.textSecondary} />,
|
||||
title: 'Firewall Blocking Port 6768',
|
||||
steps: [
|
||||
'macOS: System Settings → Network → Firewall — allow Orca.',
|
||||
'Windows: Defender Firewall → Allow app — enable Orca for Private networks.',
|
||||
'Linux: sudo ufw allow 6768',
|
||||
'Corporate/school networks may block P2P — try a personal hotspot.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'desktop',
|
||||
icon: <Monitor size={16} color={colors.textSecondary} />,
|
||||
title: 'Desktop App Not Running',
|
||||
steps: [
|
||||
'Orca must be open on your desktop to accept connections.',
|
||||
'Try restarting Orca — the companion server starts on launch.',
|
||||
'After an update, you may need to re-pair via QR code.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'timeout',
|
||||
icon: <Clock size={16} color={colors.textSecondary} />,
|
||||
title: 'Connection Timeout',
|
||||
steps: [
|
||||
'Check WiFi signal strength on your phone.',
|
||||
'Go back to the host list and tap your host to retry.',
|
||||
'Restart both apps if timeouts persist.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'vpn',
|
||||
icon: <Globe size={16} color={colors.textSecondary} />,
|
||||
title: 'VPN Interference',
|
||||
steps: [
|
||||
'VPNs can route local traffic through a remote server.',
|
||||
'Disable the VPN or enable split tunneling / "Allow LAN".'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
function StatusIcon({ status }: { status: CheckResult['status'] }) {
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return <CheckCircle2 size={14} color={colors.statusGreen} />
|
||||
case 'fail':
|
||||
return <XCircle size={14} color={colors.statusRed} />
|
||||
case 'warn':
|
||||
return <AlertTriangle size={14} color={colors.textMuted} />
|
||||
}
|
||||
}
|
||||
|
||||
export default function TroubleshootScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [diagnosticStatus, setDiagnosticStatus] = useState<DiagnosticStatus>('idle')
|
||||
const [checks, setChecks] = useState<CheckResult[]>([])
|
||||
const abortRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleSection = useCallback((id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id))
|
||||
}, [])
|
||||
|
||||
const runDiagnostics = useCallback(async () => {
|
||||
abortRef.current = false
|
||||
setDiagnosticStatus('running')
|
||||
setChecks([])
|
||||
|
||||
const results: CheckResult[] = []
|
||||
|
||||
try {
|
||||
const hosts = await loadHosts()
|
||||
results.push(
|
||||
hosts.length > 0
|
||||
? { label: 'Paired hosts', status: 'pass', detail: `${hosts.length} paired` }
|
||||
: { label: 'Paired hosts', status: 'fail', detail: 'None — scan a QR to pair' }
|
||||
)
|
||||
} catch {
|
||||
results.push({ label: 'Paired hosts', status: 'warn', detail: 'Could not read host data' })
|
||||
}
|
||||
|
||||
if (abortRef.current) return
|
||||
setChecks([...results])
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
const resp = await fetch('https://dns.google/resolve?name=example.com&type=A', {
|
||||
signal: controller.signal
|
||||
})
|
||||
clearTimeout(timeout)
|
||||
results.push(
|
||||
resp.ok
|
||||
? { label: 'Internet', status: 'pass', detail: 'Connected' }
|
||||
: { label: 'Internet', status: 'warn', detail: 'Unexpected response' }
|
||||
)
|
||||
} catch {
|
||||
results.push({ label: 'Internet', status: 'fail', detail: 'No connection' })
|
||||
}
|
||||
|
||||
if (abortRef.current) return
|
||||
setChecks([...results])
|
||||
|
||||
try {
|
||||
const hosts = await loadHosts()
|
||||
for (const host of hosts) {
|
||||
if (abortRef.current) return
|
||||
const reachable = await testHostReachability(host.endpoint)
|
||||
results.push({
|
||||
label: host.name,
|
||||
status: reachable ? 'pass' : 'fail',
|
||||
detail: reachable
|
||||
? `Reachable at ${formatEndpoint(host.endpoint)}`
|
||||
: `Cannot reach ${formatEndpoint(host.endpoint)}`
|
||||
})
|
||||
setChecks([...results])
|
||||
}
|
||||
} catch {
|
||||
results.push({ label: 'Hosts', status: 'warn', detail: 'Could not test' })
|
||||
}
|
||||
|
||||
if (abortRef.current) return
|
||||
|
||||
results.push({
|
||||
label: 'Platform',
|
||||
status: 'pass',
|
||||
detail: `${Platform.OS} ${Platform.Version ?? ''}`
|
||||
})
|
||||
|
||||
setChecks([...results])
|
||||
setDiagnosticStatus('done')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Troubleshooting</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.diagnosticButton,
|
||||
pressed && styles.diagnosticButtonPressed,
|
||||
diagnosticStatus === 'running' && styles.diagnosticButtonDisabled
|
||||
]}
|
||||
onPress={runDiagnostics}
|
||||
disabled={diagnosticStatus === 'running'}
|
||||
>
|
||||
{diagnosticStatus === 'running' ? (
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
) : (
|
||||
<Activity size={16} color={colors.textPrimary} />
|
||||
)}
|
||||
<Text style={styles.diagnosticButtonLabel}>
|
||||
{diagnosticStatus === 'running'
|
||||
? 'Running…'
|
||||
: diagnosticStatus === 'done'
|
||||
? 'Run again'
|
||||
: 'Run diagnostics'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{checks.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
{checks.map((check, i) => (
|
||||
<View key={i}>
|
||||
{i > 0 && <View style={styles.separator} />}
|
||||
<View style={styles.checkRow}>
|
||||
<StatusIcon status={check.status} />
|
||||
<Text style={styles.checkLabel}>{check.label}</Text>
|
||||
<Text
|
||||
style={[styles.checkDetail, check.status === 'fail' && styles.checkDetailFail]}
|
||||
>
|
||||
{check.detail}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.sectionHeading}>Common issues</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
{sections.map((section, i) => (
|
||||
<View key={section.id}>
|
||||
{i > 0 && <View style={styles.separator} />}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.accordionHeader, pressed && styles.rowPressed]}
|
||||
onPress={() => toggleSection(section.id)}
|
||||
>
|
||||
{section.icon}
|
||||
<Text style={styles.accordionTitle}>{section.title}</Text>
|
||||
{expandedId === section.id ? (
|
||||
<ChevronUp size={16} color={colors.textMuted} />
|
||||
) : (
|
||||
<ChevronDown size={16} color={colors.textMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
{expandedId === section.id && (
|
||||
<View style={styles.accordionBody}>
|
||||
{section.steps.map((step, j) => (
|
||||
<View key={j} style={styles.stepRow}>
|
||||
<Text style={styles.bullet}>•</Text>
|
||||
<Text style={styles.stepText}>{step}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={{ height: spacing.xl }} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// Why: WebSocket reachability is tested by opening a connection and waiting for
|
||||
// the server to respond with any frame, or timing out after 4 seconds.
|
||||
// We don't complete the E2EE handshake — just verify the endpoint is listening.
|
||||
async function testHostReachability(endpoint: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
resolve(false)
|
||||
}, 4000)
|
||||
|
||||
const ws = new WebSocket(endpoint)
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
resolve(true)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatEndpoint(endpoint: string): string {
|
||||
try {
|
||||
const url = new URL(endpoint)
|
||||
return url.host
|
||||
} catch {
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
scroll: {
|
||||
flex: 1
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: spacing.xl
|
||||
},
|
||||
diagnosticButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: 10,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
diagnosticButtonPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
diagnosticButtonDisabled: {
|
||||
opacity: 0.5
|
||||
},
|
||||
diagnosticButtonLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
checkRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
checkLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
checkDetail: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted
|
||||
},
|
||||
checkDetailFail: {
|
||||
color: colors.statusRed
|
||||
},
|
||||
sectionHeading: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.sm,
|
||||
marginTop: spacing.sm,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
accordionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
accordionTitle: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
accordionBody: {
|
||||
paddingHorizontal: spacing.md + 2,
|
||||
paddingBottom: spacing.md,
|
||||
gap: spacing.xs + 2
|
||||
},
|
||||
stepRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
bullet: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 18
|
||||
},
|
||||
stepText: {
|
||||
flex: 1,
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 18
|
||||
}
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
Binary file not shown.
|
After Width: | Height: | Size: 702 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,808 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Orca Mobile – Homepage Redesign</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
:root {
|
||||
--bg-base: #111111;
|
||||
--bg-panel: #1a1a1a;
|
||||
--bg-raised: #242424;
|
||||
--border-subtle: #2a2a2a;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #888888;
|
||||
--text-muted: #555555;
|
||||
--accent-blue: #3b82f6;
|
||||
--status-green: #22c55e;
|
||||
--status-amber: #f59e0b;
|
||||
--status-red: #ef4444;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
max-width: 430px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ─── Top bar ─── */
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px 8px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.brand svg { width: 20px; height: 20px; }
|
||||
.brand-name { font-size: 17px; font-weight: 700; }
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.icon-btn:hover { background: var(--bg-raised); }
|
||||
|
||||
/* ─── Greeting ─── */
|
||||
.greeting {
|
||||
padding: 8px 20px 4px;
|
||||
}
|
||||
.greeting-time {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.greeting-title {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
/* ─── Stats row — desktop-matching minimal style ─── */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 16px 20px 8px;
|
||||
}
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: rgba(26, 26, 26, 0.6);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.stat-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
background: rgba(255,255,255, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── Section headings ─── */
|
||||
.section-heading {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
padding: 20px 20px 8px;
|
||||
}
|
||||
|
||||
/* ─── Host cards ─── */
|
||||
.host-list {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.host-card {
|
||||
background: var(--bg-panel);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
.host-card:hover { background: var(--bg-raised); }
|
||||
.host-card:active { transform: scale(0.985); }
|
||||
.host-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
.host-icon svg { color: var(--text-primary); }
|
||||
.host-status-ring {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
border: 2px solid var(--bg-panel);
|
||||
}
|
||||
.host-card.connected .host-status-ring { background: var(--status-green); }
|
||||
.host-card.disconnected .host-status-ring { background: var(--text-muted); }
|
||||
.host-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.host-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.host-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.host-meta-item {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.host-meta-item svg { width: 12px; height: 12px; }
|
||||
.host-meta-dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.host-chevron {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Quick actions ─── */
|
||||
.quick-actions {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.quick-action {
|
||||
flex: 1;
|
||||
background: var(--bg-panel);
|
||||
border-radius: 14px;
|
||||
padding: 16px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border: 1px solid var(--border-subtle);
|
||||
text-decoration: none;
|
||||
}
|
||||
.quick-action:hover { background: var(--bg-raised); }
|
||||
.quick-action-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255,255,255, 0.04);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.quick-action-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Resume session card ─── */
|
||||
.resume-card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin: 0 16px;
|
||||
}
|
||||
.resume-card:hover { background: var(--bg-raised); }
|
||||
.resume-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.resume-main { flex: 1; min-width: 0; }
|
||||
.resume-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.resume-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.resume-sub .repo-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.resume-arrow {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Recent activity ─── */
|
||||
.activity-list {
|
||||
padding: 0 16px;
|
||||
}
|
||||
.activity-card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.activity-item {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.activity-item + .activity-item {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.activity-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.activity-dot.working { background: var(--status-green); }
|
||||
.activity-dot.pr { background: #38bdf8; }
|
||||
.activity-dot.done { background: var(--text-muted); }
|
||||
.activity-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.activity-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.activity-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Spacer */
|
||||
.bottom-spacer { height: 40px; }
|
||||
|
||||
/* ─── Comparison toggle ─── */
|
||||
.compare-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 430px;
|
||||
background: rgba(26, 26, 26, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
gap: 4px;
|
||||
}
|
||||
.compare-bar button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 6px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.compare-bar button.active {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.version { display: none; }
|
||||
.version.active { display: block; }
|
||||
|
||||
/* ─── Empty state (onboarding) ─── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 44px);
|
||||
padding-top: 44px;
|
||||
}
|
||||
.empty-hero {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 32px 40px;
|
||||
text-align: center;
|
||||
max-width: 340px;
|
||||
}
|
||||
.empty-glyph {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255,255,255, 0.04);
|
||||
border: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.empty-glyph svg {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.3px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.empty-body {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.empty-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-base);
|
||||
border: none;
|
||||
padding: 14px 28px;
|
||||
border-radius: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.empty-cta:active { transform: scale(0.97); opacity: 0.9; }
|
||||
.empty-cta svg { color: var(--bg-base); }
|
||||
|
||||
.empty-steps {
|
||||
width: 100%;
|
||||
padding: 0 24px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.empty-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.empty-step + .empty-step {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.step-num {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255, 0.04);
|
||||
border: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.step-text {
|
||||
flex: 1;
|
||||
}
|
||||
.step-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.step-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="compare-bar">
|
||||
<button class="active" onclick="toggle('current')">Current</button>
|
||||
<button onclick="toggle('proposed')">Proposed</button>
|
||||
<button onclick="toggle('empty')">Empty State</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════ CURRENT VERSION ═══════════════ -->
|
||||
<div id="version-current" class="version active" style="padding-top: 44px;">
|
||||
<div class="top-bar">
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 100 60" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round"><path d="M10 45 Q25 15 40 35 Q55 55 70 25 L90 15" /></svg>
|
||||
<span class="brand-name">Orca</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="greeting">
|
||||
<div class="greeting-title">Welcome back</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-card" style="background: var(--bg-panel); border: none;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
<div class="stat-value" style="margin-top: 8px;">294</div>
|
||||
<div class="stat-label">Agents</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: var(--bg-panel); border: none;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<div class="stat-value" style="margin-top: 8px;">1d 14h</div>
|
||||
<div class="stat-label">Agent time</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: var(--bg-panel); border: none;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 012 2v7"/><path d="M6 9v12"/></svg>
|
||||
<div class="stat-value" style="margin-top: 8px;">277</div>
|
||||
<div class="stat-label">PRs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-heading">Desktops</div>
|
||||
<div class="host-list">
|
||||
<div class="host-card" style="border: none;">
|
||||
<div class="host-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<div class="host-main">
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<div style="width:8px; height:8px; border-radius:4px; background: var(--status-green);"></div>
|
||||
<div class="host-name">Host 1</div>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: var(--text-muted); margin-top: 2px;">Connected</div>
|
||||
</div>
|
||||
<div style="color: var(--text-secondary);">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="host-card" style="border: none;">
|
||||
<div class="host-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
</div>
|
||||
<div class="host-main">
|
||||
<div class="host-name">Pair another desktop</div>
|
||||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">Scan a QR code from Orca desktop</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 300px;"></div>
|
||||
|
||||
<div style="display: flex; justify-content: center; padding: 16px 0 32px; gap: 6px; align-items: center;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
<span style="font-size: 13px; color: var(--text-muted); font-weight: 500;">Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════ PROPOSED VERSION ═══════════════ -->
|
||||
<div id="version-proposed" class="version" style="padding-top: 44px;">
|
||||
<div class="top-bar">
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 100 60" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round"><path d="M10 45 Q25 15 40 35 Q55 55 70 25 L90 15" /></svg>
|
||||
<span class="brand-name">Orca</span>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button class="icon-btn" title="Settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="greeting">
|
||||
|
||||
<div class="greeting-title">Welcome back</div>
|
||||
</div>
|
||||
|
||||
<!-- Stat cards — desktop-matching: border-border/50, bg-card/60, muted icon box -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
</div>
|
||||
<div class="stat-value">294</div>
|
||||
<div class="stat-label">Agents</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</div>
|
||||
<div class="stat-value">1d 14h</div>
|
||||
<div class="stat-label">Agent time</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 012 2v7"/><path d="M6 9v12"/></svg>
|
||||
</div>
|
||||
<div class="stat-value">277</div>
|
||||
<div class="stat-label">PRs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Host cards with richer metadata -->
|
||||
<div class="section-heading">Desktops</div>
|
||||
<div class="host-list">
|
||||
<div class="host-card connected">
|
||||
<div class="host-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
<div class="host-status-ring"></div>
|
||||
</div>
|
||||
<div class="host-main">
|
||||
<div class="host-name">Host 1</div>
|
||||
<div class="host-meta">
|
||||
<span class="host-meta-item">12 worktrees</span>
|
||||
<div class="host-meta-dot"></div>
|
||||
<span class="host-meta-item" style="color: var(--status-green);">3 active</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-chevron">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="host-card disconnected">
|
||||
<div class="host-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
<div class="host-status-ring"></div>
|
||||
</div>
|
||||
<div class="host-main">
|
||||
<div class="host-name" style="color: var(--text-secondary);">Work Laptop</div>
|
||||
<div class="host-meta">
|
||||
<span class="host-meta-item" style="color: var(--text-muted);">Disconnected</span>
|
||||
<div class="host-meta-dot"></div>
|
||||
<span class="host-meta-item" style="color: var(--text-muted);">Last seen 2h ago</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-chevron">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resume last session -->
|
||||
<div class="section-heading">Resume</div>
|
||||
<div class="resume-card">
|
||||
<div class="resume-icon">
|
||||
<!-- terminal icon -->
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
|
||||
</div>
|
||||
<div class="resume-main">
|
||||
<div class="resume-title">fix-auth-middleware</div>
|
||||
<div class="resume-sub">
|
||||
<span class="repo-dot" style="background: #8b5cf6;"></span>
|
||||
orca · feat/auth-v2
|
||||
</div>
|
||||
</div>
|
||||
<div class="resume-arrow">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick actions -->
|
||||
<div class="section-heading">Quick Actions</div>
|
||||
<div class="quick-actions">
|
||||
<a class="quick-action">
|
||||
<div class="quick-action-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
</div>
|
||||
<span class="quick-action-label">Pair Desktop</span>
|
||||
</a>
|
||||
<a class="quick-action">
|
||||
<div class="quick-action-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</div>
|
||||
<span class="quick-action-label">New Worktree</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Recent activity -->
|
||||
<div class="section-heading">Recent Activity</div>
|
||||
<div class="activity-list">
|
||||
<div class="activity-card">
|
||||
<div class="activity-item">
|
||||
<div class="activity-dot working"></div>
|
||||
<div class="activity-main">
|
||||
<div class="activity-text">Agent completed: fix login validation</div>
|
||||
<div class="activity-time">Host 1 · 12 min ago</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-item">
|
||||
<div class="activity-dot pr"></div>
|
||||
<div class="activity-main">
|
||||
<div class="activity-text">PR #284 merged: update auth middleware</div>
|
||||
<div class="activity-time">Host 1 · 1h ago</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-item">
|
||||
<div class="activity-dot done"></div>
|
||||
<div class="activity-main">
|
||||
<div class="activity-text">Agent started: refactor payment flow</div>
|
||||
<div class="activity-time">Host 1 · 2h ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom-spacer"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════ EMPTY STATE (no desktops paired) ═══════════════ -->
|
||||
<div id="version-empty" class="version" style="padding-top: 44px;">
|
||||
<div class="top-bar">
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 100 60" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round"><path d="M10 45 Q25 15 40 35 Q55 55 70 25 L90 15" /></svg>
|
||||
<span class="brand-name">Orca</span>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button class="icon-btn" title="Settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="greeting" style="padding-bottom: 0;">
|
||||
|
||||
<div class="greeting-title">Welcome to Orca</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" style="min-height: auto;">
|
||||
<div class="empty-hero" style="padding-top: 48px;">
|
||||
<div class="empty-glyph">
|
||||
<!-- monitor + link icon -->
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="empty-title">Connect your desktop</div>
|
||||
<div class="empty-body">
|
||||
Pair with Orca on your computer to monitor worktrees, watch agents work, and manage terminals — all from your phone.
|
||||
</div>
|
||||
<button class="empty-cta">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
Scan Pairing Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="empty-steps">
|
||||
<div class="section-heading" style="padding-left: 0; padding-right: 0;">How it works</div>
|
||||
<div class="empty-step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-text">
|
||||
<div class="step-title">Open Orca desktop</div>
|
||||
<div class="step-desc">Go to Settings → Mobile and generate a pairing QR code.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-text">
|
||||
<div class="step-title">Scan the code</div>
|
||||
<div class="step-desc">Tap the button above to open the scanner. Point at the QR code on your screen.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-text">
|
||||
<div class="step-title">You're connected</div>
|
||||
<div class="step-desc">Your desktop will appear here. Everything is encrypted end-to-end.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggle(version) {
|
||||
document.getElementById('version-current').classList.toggle('active', version === 'current');
|
||||
document.getElementById('version-proposed').classList.toggle('active', version === 'proposed');
|
||||
document.getElementById('version-empty').classList.toggle('active', version === 'empty');
|
||||
document.querySelectorAll('.compare-bar button').forEach(btn => {
|
||||
btn.classList.toggle('active',
|
||||
(version === 'current' && btn.textContent === 'Current') ||
|
||||
(version === 'proposed' && btn.textContent === 'Proposed') ||
|
||||
(version === 'empty' && btn.textContent === 'Empty State')
|
||||
);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "orca-mobile",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt --write .",
|
||||
"mock-server": "npx tsx scripts/mock-server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"expo": "^55.0.17",
|
||||
"expo-build-properties": "^55.0.13",
|
||||
"expo-camera": "^55.0.16",
|
||||
"expo-constants": "^55.0.15",
|
||||
"expo-crypto": "^55.0.14",
|
||||
"expo-haptics": "^55.0.14",
|
||||
"expo-linking": "^55.0.14",
|
||||
"expo-notifications": "^55.0.21",
|
||||
"expo-router": "^55.0.13",
|
||||
"expo-splash-screen": "^55.0.19",
|
||||
"expo-status-bar": "^55.0.5",
|
||||
"lucide-react-native": "^1.11.0",
|
||||
"react": "^19.2.0",
|
||||
"react-native": "^0.83.6",
|
||||
"react-native-gesture-handler": "^2.30.1",
|
||||
"react-native-reanimated": "^4.2.1",
|
||||
"react-native-safe-area-context": "^5.6.2",
|
||||
"react-native-screens": "^4.23.0",
|
||||
"react-native-svg": "^15.15.4",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-webview": "^13.16.1",
|
||||
"react-native-worklets": "^0.7.4",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-native": "^0.73.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"expo-module-scripts": "^55.0.2",
|
||||
"oxfmt": "^0.47.0",
|
||||
"oxlint": "^1.62.0",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
Generated
+12571
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
// Why: standalone mock WebSocket server for developing the mobile app without
|
||||
// a running Orca desktop instance. Responds to the same RPC methods the real
|
||||
// runtime exposes, with realistic fake data. Supports E2EE handshake.
|
||||
import { WebSocketServer, type WebSocket } from 'ws'
|
||||
import nacl from 'tweetnacl'
|
||||
|
||||
const PORT = Number(process.env.PORT) || 6768
|
||||
const AUTH_TOKEN = 'mock-device-token'
|
||||
|
||||
// Why: generate a persistent server keypair for this mock session.
|
||||
// The public key is printed at startup so it can be used in pairing QR data.
|
||||
const serverKeyPair = nacl.box.keyPair()
|
||||
const serverPublicKeyB64 = Buffer.from(serverKeyPair.publicKey).toString('base64')
|
||||
|
||||
type E2EEState = {
|
||||
sharedKey: Uint8Array
|
||||
deviceToken: string | null
|
||||
authenticated: boolean
|
||||
}
|
||||
|
||||
function deriveSharedKey(ourSecret: Uint8Array, peerPublic: Uint8Array): Uint8Array {
|
||||
return nacl.box.before(peerPublic, ourSecret)
|
||||
}
|
||||
|
||||
function e2eeEncrypt(plaintext: string, sharedKey: Uint8Array): string {
|
||||
const nonce = nacl.randomBytes(nacl.box.nonceLength)
|
||||
const msg = new TextEncoder().encode(plaintext)
|
||||
const ciphertext = nacl.box.after(msg, nonce, sharedKey)
|
||||
const bundle = new Uint8Array(nonce.length + ciphertext.length)
|
||||
bundle.set(nonce)
|
||||
bundle.set(ciphertext, nonce.length)
|
||||
return Buffer.from(bundle).toString('base64')
|
||||
}
|
||||
|
||||
function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null {
|
||||
const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64'))
|
||||
if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) return null
|
||||
const nonce = bundle.slice(0, nacl.box.nonceLength)
|
||||
const ciphertext = bundle.slice(nacl.box.nonceLength)
|
||||
const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey)
|
||||
if (!plaintext) return null
|
||||
return new TextDecoder().decode(plaintext)
|
||||
}
|
||||
|
||||
const FAKE_WORKTREES = [
|
||||
{
|
||||
worktreeId: 'repo-1::/home/user/projects/acme-api',
|
||||
repoId: 'repo-1',
|
||||
repo: 'acme-api',
|
||||
path: '/home/user/projects/acme-api',
|
||||
branch: 'feature/auth-refactor',
|
||||
linkedIssue: 42,
|
||||
unread: true,
|
||||
liveTerminalCount: 2,
|
||||
hasAttachedPty: true,
|
||||
lastOutputAt: Date.now() - 5000,
|
||||
preview: '$ claude "refactor the auth module"'
|
||||
},
|
||||
{
|
||||
worktreeId: 'repo-1::/home/user/projects/acme-web',
|
||||
repoId: 'repo-1',
|
||||
repo: 'acme-web',
|
||||
path: '/home/user/projects/acme-web',
|
||||
branch: 'main',
|
||||
linkedIssue: null,
|
||||
unread: false,
|
||||
liveTerminalCount: 1,
|
||||
hasAttachedPty: true,
|
||||
lastOutputAt: Date.now() - 60000,
|
||||
preview: '$ npm test\nAll tests passed.'
|
||||
}
|
||||
]
|
||||
|
||||
const FAKE_TERMINALS = [
|
||||
{
|
||||
handle: 'term-1',
|
||||
worktreeId: 'repo-1::/home/user/projects/acme-api',
|
||||
title: 'Claude — auth refactor',
|
||||
isActive: true,
|
||||
hasRunningProcess: true
|
||||
},
|
||||
{
|
||||
handle: 'term-2',
|
||||
worktreeId: 'repo-1::/home/user/projects/acme-api',
|
||||
title: 'zsh',
|
||||
isActive: false,
|
||||
hasRunningProcess: false
|
||||
}
|
||||
]
|
||||
|
||||
const FAKE_SCROLLBACK = [
|
||||
'$ claude "refactor the auth module to use JWT tokens"',
|
||||
'',
|
||||
'⏳ Working on it...',
|
||||
'',
|
||||
"I'll refactor the auth module. Here's my plan:",
|
||||
'1. Replace session-based auth with JWT',
|
||||
'2. Add token refresh endpoint',
|
||||
'3. Update middleware',
|
||||
'',
|
||||
'Let me start by reading the current auth module...',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
const STREAMING_CHUNKS = [
|
||||
'Reading src/auth/middleware.ts...\n',
|
||||
'Reading src/auth/session.ts...\n',
|
||||
'\nI see the current implementation uses express-session.\n',
|
||||
"I'll replace it with jsonwebtoken.\n",
|
||||
'\nUpdating src/auth/middleware.ts...\n'
|
||||
]
|
||||
|
||||
type RpcRequest = {
|
||||
id: string
|
||||
method: string
|
||||
deviceToken?: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type RpcResponse = {
|
||||
id: string
|
||||
ok: boolean
|
||||
result?: unknown
|
||||
error?: { code: string; message: string }
|
||||
streaming?: true
|
||||
_meta: { runtimeId: string }
|
||||
}
|
||||
|
||||
function success(id: string, result: unknown, streaming?: boolean): RpcResponse {
|
||||
const resp: RpcResponse = { id, ok: true, result, _meta: { runtimeId: 'mock-runtime' } }
|
||||
if (streaming) {
|
||||
resp.streaming = true
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
function error(id: string, code: string, message: string): RpcResponse {
|
||||
return { id, ok: false, error: { code, message }, _meta: { runtimeId: 'mock-runtime' } }
|
||||
}
|
||||
|
||||
function handleRequest(
|
||||
request: RpcRequest,
|
||||
send: (response: RpcResponse) => void,
|
||||
ws: WebSocket
|
||||
): void {
|
||||
switch (request.method) {
|
||||
case 'status.get':
|
||||
send(
|
||||
success(request.id, {
|
||||
runtimeId: 'mock-runtime',
|
||||
graphStatus: 'ready',
|
||||
windowCount: 1,
|
||||
tabCount: 2,
|
||||
terminalCount: 2
|
||||
})
|
||||
)
|
||||
break
|
||||
|
||||
case 'worktree.ps':
|
||||
send(
|
||||
success(request.id, {
|
||||
worktrees: FAKE_WORKTREES,
|
||||
totalCount: FAKE_WORKTREES.length,
|
||||
truncated: false
|
||||
})
|
||||
)
|
||||
break
|
||||
|
||||
case 'terminal.list':
|
||||
send(
|
||||
success(request.id, {
|
||||
terminals: FAKE_TERMINALS,
|
||||
totalCount: FAKE_TERMINALS.length,
|
||||
truncated: false
|
||||
})
|
||||
)
|
||||
break
|
||||
|
||||
case 'terminal.subscribe': {
|
||||
send(success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false }))
|
||||
|
||||
let chunkIndex = 0
|
||||
const interval = setInterval(() => {
|
||||
if (chunkIndex >= STREAMING_CHUNKS.length || ws.readyState !== ws.OPEN) {
|
||||
clearInterval(interval)
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
send(success(request.id, { type: 'end' }))
|
||||
}
|
||||
return
|
||||
}
|
||||
send(success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true))
|
||||
chunkIndex++
|
||||
}, 500)
|
||||
break
|
||||
}
|
||||
|
||||
case 'terminal.send':
|
||||
send(success(request.id, { send: { handle: 'term-1', ok: true } }))
|
||||
break
|
||||
|
||||
case 'terminal.unsubscribe':
|
||||
send(success(request.id, { unsubscribed: true }))
|
||||
break
|
||||
|
||||
default:
|
||||
send(error(request.id, 'method_not_found', `Unknown method: ${request.method}`))
|
||||
}
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({ port: PORT })
|
||||
|
||||
// Why: each connection goes through an E2EE handshake before any RPC traffic.
|
||||
// The first message must be e2ee_hello (plaintext), then all subsequent
|
||||
// messages are encrypted with the derived shared key.
|
||||
const connectionState = new Map<WebSocket, E2EEState>()
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('[mock] Client connected — waiting for e2ee_hello')
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const msg = typeof data === 'string' ? data : data.toString('utf-8')
|
||||
const e2ee = connectionState.get(ws)
|
||||
|
||||
if (!e2ee) {
|
||||
// Handshake phase — expect e2ee_hello
|
||||
let hello: { type?: string; publicKeyB64?: string }
|
||||
try {
|
||||
hello = JSON.parse(msg)
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Invalid JSON' }))
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (hello.type !== 'e2ee_hello' || !hello.publicKeyB64) {
|
||||
ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Expected e2ee_hello' }))
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
const clientPublicKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64'))
|
||||
if (clientPublicKey.length !== 32) {
|
||||
ws.send(JSON.stringify({ type: 'e2ee_error', message: 'Invalid public key' }))
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
const sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey)
|
||||
connectionState.set(ws, { sharedKey, deviceToken: null, authenticated: false })
|
||||
|
||||
ws.send(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
console.log('[mock] E2EE key exchange complete — waiting for encrypted auth')
|
||||
return
|
||||
}
|
||||
|
||||
// Post-handshake — decrypt, handle, encrypt reply
|
||||
const plaintext = e2eeDecrypt(msg, e2ee.sharedKey)
|
||||
if (plaintext === null) {
|
||||
console.log('[mock] Decryption failed — dropping message')
|
||||
return
|
||||
}
|
||||
|
||||
let request: RpcRequest
|
||||
try {
|
||||
request = JSON.parse(plaintext) as RpcRequest
|
||||
} catch {
|
||||
const encrypted = e2eeEncrypt(
|
||||
JSON.stringify(error('unknown', 'bad_request', 'Invalid JSON')),
|
||||
e2ee.sharedKey
|
||||
)
|
||||
ws.send(encrypted)
|
||||
return
|
||||
}
|
||||
|
||||
if (!e2ee.authenticated) {
|
||||
const auth = request as unknown as { type?: string; deviceToken?: string }
|
||||
if (auth.type !== 'e2ee_auth' || auth.deviceToken !== AUTH_TOKEN) {
|
||||
ws.send(
|
||||
e2eeEncrypt(
|
||||
JSON.stringify({ type: 'e2ee_error', error: { code: 'unauthorized' } }),
|
||||
e2ee.sharedKey
|
||||
)
|
||||
)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
e2ee.deviceToken = auth.deviceToken
|
||||
e2ee.authenticated = true
|
||||
ws.send(e2eeEncrypt(JSON.stringify({ type: 'e2ee_authenticated' }), e2ee.sharedKey))
|
||||
console.log('[mock] E2EE authentication complete')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[mock] ${request.method} (id: ${request.id})`)
|
||||
handleRequest(
|
||||
request,
|
||||
(response) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(e2eeEncrypt(JSON.stringify(response), e2ee.sharedKey))
|
||||
}
|
||||
},
|
||||
ws
|
||||
)
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
connectionState.delete(ws)
|
||||
console.log('[mock] Client disconnected')
|
||||
})
|
||||
|
||||
ws.on('error', () => {
|
||||
connectionState.delete(ws)
|
||||
ws.close()
|
||||
})
|
||||
})
|
||||
|
||||
console.log(`[mock] Orca mock server listening on ws://localhost:${PORT}`)
|
||||
console.log(`[mock] Auth token: ${AUTH_TOKEN}`)
|
||||
console.log(`[mock] Server public key (base64): ${serverPublicKeyB64}`)
|
||||
console.log(`[mock] E2EE enabled — clients must send e2ee_hello before RPC`)
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Minimal mobile terminal color repro.
|
||||
*
|
||||
* Captures terminal.subscribe snapshots in the same sequence that tab switching
|
||||
* uses: terminal A, terminal B, terminal A again. The report tells us whether
|
||||
* ANSI SGR color attributes disappeared in the desktop serialized scrollback
|
||||
* or are still present and therefore being lost during mobile WebView replay.
|
||||
*
|
||||
* Usage:
|
||||
* ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \
|
||||
* pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import nacl from 'tweetnacl'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
|
||||
const token = process.argv[2]
|
||||
const serverPublicKeyB64 = process.argv[3]
|
||||
const worktreeSelector = process.argv[4]
|
||||
const explicitHandleA = process.argv[5]
|
||||
const explicitHandleB = process.argv[6]
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
type RpcResponse = {
|
||||
id: string
|
||||
ok: boolean
|
||||
streaming?: true
|
||||
result?: Record<string, unknown>
|
||||
error?: { code: string; message: string }
|
||||
}
|
||||
|
||||
type PendingRequest = {
|
||||
method: string
|
||||
resolve: (response: RpcResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
type Snapshot = {
|
||||
label: string
|
||||
handle: string
|
||||
cols: number | null
|
||||
rows: number | null
|
||||
serialized: string
|
||||
lines: string
|
||||
}
|
||||
|
||||
if (!token || !serverPublicKeyB64 || !worktreeSelector) {
|
||||
console.error(
|
||||
'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let reqId = 0
|
||||
const pending = new Map<string, PendingRequest>()
|
||||
const streamListeners = new Map<string, (result: Record<string, unknown>) => void>()
|
||||
const clientKeys = nacl.box.keyPair()
|
||||
const serverPublicKey = Buffer.from(serverPublicKeyB64, 'base64')
|
||||
const sharedKey = nacl.box.before(new Uint8Array(serverPublicKey), clientKeys.secretKey)
|
||||
|
||||
function nextId(): string {
|
||||
reqId += 1
|
||||
return `color-repro-${reqId}`
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
function fromBase64(value: string): Uint8Array {
|
||||
return new Uint8Array(Buffer.from(value, 'base64'))
|
||||
}
|
||||
|
||||
function encrypt(plaintext: string): string {
|
||||
const nonce = nacl.randomBytes(nacl.box.nonceLength)
|
||||
const message = new TextEncoder().encode(plaintext)
|
||||
const ciphertext = nacl.box.after(message, nonce, sharedKey)
|
||||
const bundle = new Uint8Array(nonce.length + ciphertext.length)
|
||||
bundle.set(nonce)
|
||||
bundle.set(ciphertext, nonce.length)
|
||||
return toBase64(bundle)
|
||||
}
|
||||
|
||||
function decrypt(payload: string): string | null {
|
||||
const bundle = fromBase64(payload)
|
||||
const nonce = bundle.subarray(0, nacl.box.nonceLength)
|
||||
const ciphertext = bundle.subarray(nacl.box.nonceLength)
|
||||
const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey)
|
||||
return plaintext ? new TextDecoder().decode(plaintext) : null
|
||||
}
|
||||
|
||||
function sendRaw(ws: WebSocket, payload: unknown): void {
|
||||
ws.send(encrypt(JSON.stringify(payload)))
|
||||
}
|
||||
|
||||
function send(ws: WebSocket, method: string, params?: unknown): Promise<RpcResponse> {
|
||||
const id = nextId()
|
||||
sendRaw(ws, { id, deviceToken: token, method, params })
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
streamListeners.delete(id)
|
||||
reject(new Error(`Timed out waiting for ${method}`))
|
||||
}, 10_000)
|
||||
pending.set(id, {
|
||||
method,
|
||||
resolve: (response) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(response)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function formatError(response: RpcResponse): string {
|
||||
return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000)
|
||||
}
|
||||
|
||||
async function listHandles(
|
||||
ws: WebSocket
|
||||
): Promise<Array<{ handle: string; title: string | null }>> {
|
||||
const response = await send(ws, 'terminal.list', { worktree: worktreeSelector })
|
||||
if (!response.ok) {
|
||||
throw new Error(`terminal.list failed: ${formatError(response)}`)
|
||||
}
|
||||
return (
|
||||
(response.result?.terminals ?? []) as Array<{ handle: string; title?: string | null }>
|
||||
).map((terminal) => ({
|
||||
handle: terminal.handle,
|
||||
title: terminal.title ?? null
|
||||
}))
|
||||
}
|
||||
|
||||
async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise<string> {
|
||||
const terminals = await listHandles(ws)
|
||||
const existing = terminals.find((terminal) => terminal.handle !== handleA)
|
||||
if (existing) return existing.handle
|
||||
|
||||
const created = await send(ws, 'terminal.create', {
|
||||
worktree: worktreeSelector,
|
||||
title: 'color-repro-switch-target'
|
||||
})
|
||||
if (!created.ok) {
|
||||
throw new Error(`terminal.create failed: ${formatError(created)}`)
|
||||
}
|
||||
const handle = (created.result?.terminal as { handle?: string } | undefined)?.handle
|
||||
if (!handle) {
|
||||
throw new Error(`terminal.create returned no handle: ${formatError(created)}`)
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
async function captureSnapshot(ws: WebSocket, label: string, handle: string): Promise<Snapshot> {
|
||||
const id = nextId()
|
||||
const snapshot = await new Promise<Snapshot>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
streamListeners.delete(id)
|
||||
reject(new Error(`Timed out waiting for scrollback snapshot ${label}`))
|
||||
}, 10_000)
|
||||
|
||||
pending.set(id, {
|
||||
method: 'terminal.subscribe',
|
||||
resolve: () => {},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
streamListeners.set(id, (result) => {
|
||||
if (result.type !== 'scrollback') return
|
||||
clearTimeout(timeout)
|
||||
pending.delete(id)
|
||||
streamListeners.delete(id)
|
||||
const serialized = typeof result.serialized === 'string' ? result.serialized : ''
|
||||
const rawLines = result.lines
|
||||
const lines = Array.isArray(rawLines) ? rawLines.join('\n') : String(rawLines ?? '')
|
||||
resolve({
|
||||
label,
|
||||
handle,
|
||||
cols: typeof result.cols === 'number' ? result.cols : null,
|
||||
rows: typeof result.rows === 'number' ? result.rows : null,
|
||||
serialized,
|
||||
lines
|
||||
})
|
||||
})
|
||||
|
||||
sendRaw(ws, {
|
||||
id,
|
||||
deviceToken: token,
|
||||
method: 'terminal.subscribe',
|
||||
params: { terminal: handle }
|
||||
})
|
||||
})
|
||||
|
||||
await send(ws, 'terminal.unsubscribe', { subscriptionId: handle }).catch(() => null)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function countMatches(value: string, pattern: RegExp): number {
|
||||
return value.match(pattern)?.length ?? 0
|
||||
}
|
||||
|
||||
function summarize(snapshot: Snapshot): Record<string, string | number | null> {
|
||||
const data = snapshot.serialized
|
||||
return {
|
||||
label: snapshot.label,
|
||||
handle: snapshot.handle,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
serializedBytes: Buffer.byteLength(data),
|
||||
sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
|
||||
sgrColor: countMatches(
|
||||
data,
|
||||
new RegExp(
|
||||
`${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
|
||||
'g'
|
||||
)
|
||||
),
|
||||
sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
|
||||
altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
|
||||
containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
|
||||
containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
|
||||
}
|
||||
}
|
||||
|
||||
function saveSnapshots(snapshots: Snapshot[]): string {
|
||||
const dir = join(process.cwd(), 'terminal-color-repro')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const snapshot of snapshots) {
|
||||
const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
|
||||
writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
|
||||
writeFileSync(
|
||||
join(dir, `${base}.escaped.txt`),
|
||||
JSON.stringify(snapshot.serialized || snapshot.lines)
|
||||
)
|
||||
}
|
||||
writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function run(ws: WebSocket): Promise<void> {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: toBase64(clientKeys.publicKey)
|
||||
})
|
||||
)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for e2ee_ready')), 5000)
|
||||
ws.once('message', (data) => {
|
||||
clearTimeout(timeout)
|
||||
const msg = JSON.parse(data.toString()) as { type?: string }
|
||||
if (msg.type !== 'e2ee_ready') {
|
||||
reject(new Error(`Unexpected handshake response: ${data.toString()}`))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
sendRaw(ws, { type: 'e2ee_auth', deviceToken: token })
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error('Timed out waiting for e2ee_authenticated')),
|
||||
5000
|
||||
)
|
||||
ws.once('message', (data) => {
|
||||
clearTimeout(timeout)
|
||||
const plaintext = decrypt(data.toString())
|
||||
const msg = plaintext ? (JSON.parse(plaintext) as { type?: string }) : null
|
||||
if (msg?.type !== 'e2ee_authenticated') {
|
||||
reject(new Error(`Unexpected auth response: ${data.toString()}`))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const terminals = await listHandles(ws)
|
||||
if (terminals.length === 0 && !explicitHandleA) {
|
||||
throw new Error('No terminals found. Open a Claude Code terminal first, then rerun.')
|
||||
}
|
||||
|
||||
const handleA = explicitHandleA ?? terminals[0]!.handle
|
||||
const handleB = explicitHandleB ?? (await ensureSecondHandle(ws, handleA))
|
||||
|
||||
console.log(`A: ${handleA}`)
|
||||
console.log(`B: ${handleB}`)
|
||||
|
||||
const firstA = await captureSnapshot(ws, 'a-before-switch', handleA)
|
||||
const firstB = await captureSnapshot(ws, 'b-during-switch', handleB)
|
||||
const secondA = await captureSnapshot(ws, 'a-after-switch', handleA)
|
||||
const snapshots = [firstA, firstB, secondA]
|
||||
const dir = saveSnapshots(snapshots)
|
||||
|
||||
console.table(snapshots.map(summarize))
|
||||
console.log(`saved: ${dir}`)
|
||||
if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) {
|
||||
console.log(
|
||||
'color SGR count changed between A snapshots: serialization/state changed on desktop'
|
||||
)
|
||||
} else {
|
||||
console.log('A snapshots have the same color SGR count: likely mobile replay/render path')
|
||||
}
|
||||
}
|
||||
|
||||
const ws = new WebSocket(WS_URL)
|
||||
|
||||
ws.on('open', () => {
|
||||
run(ws)
|
||||
.then(() => {
|
||||
ws.close()
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error.message)
|
||||
ws.close()
|
||||
process.exit(1)
|
||||
})
|
||||
})
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const raw = data.toString()
|
||||
if (raw.startsWith('{')) {
|
||||
return
|
||||
}
|
||||
const plaintext = decrypt(raw)
|
||||
if (!plaintext) return
|
||||
|
||||
const response = JSON.parse(plaintext) as RpcResponse
|
||||
const result = response.result
|
||||
const streamListener = streamListeners.get(response.id)
|
||||
if (streamListener && response.ok && (response.streaming || result?.type === 'scrollback')) {
|
||||
streamListener(result ?? {})
|
||||
return
|
||||
}
|
||||
|
||||
const request = pending.get(response.id)
|
||||
if (!request || request.method === 'terminal.subscribe') return
|
||||
pending.delete(response.id)
|
||||
request.resolve(response)
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
for (const request of pending.values()) {
|
||||
request.reject(new Error('WebSocket closed'))
|
||||
}
|
||||
pending.clear()
|
||||
})
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error(`WebSocket error: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Captures the mobile WebSocket stream during worktree startup.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import nacl from 'tweetnacl'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
|
||||
const USER_DATA =
|
||||
process.env.ORCA_USER_DATA ?? `${process.env.HOME}/Library/Application Support/orca-dev`
|
||||
const repoSelector = process.argv[2]
|
||||
const worktreeName = process.argv[3]
|
||||
const startupCommand = process.argv[4] || 'claude'
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
type RpcResponse = {
|
||||
id: string
|
||||
ok: boolean
|
||||
streaming?: true
|
||||
result?: Record<string, unknown>
|
||||
error?: { code: string; message: string }
|
||||
}
|
||||
|
||||
type PendingRequest = {
|
||||
method: string
|
||||
resolve: (response: RpcResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
type TerminalInfo = {
|
||||
handle: string
|
||||
title: string | null
|
||||
}
|
||||
|
||||
type Capture = {
|
||||
handle: string
|
||||
title: string | null
|
||||
scrollback: Record<string, unknown> | null
|
||||
chunks: string[]
|
||||
}
|
||||
|
||||
if (!repoSelector || !worktreeName) {
|
||||
console.error(
|
||||
'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function readJson<T>(path: string): T {
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`Missing ${path}`)
|
||||
}
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as T
|
||||
}
|
||||
|
||||
const devices = readJson<Array<{ token: string }>>(join(USER_DATA, 'orca-devices.json'))
|
||||
const token = devices[0]?.token
|
||||
const keypair = readJson<{ publicKeyB64: string }>(join(USER_DATA, 'orca-e2ee-keypair.json'))
|
||||
|
||||
if (!token || !keypair.publicKeyB64) {
|
||||
throw new Error(`Missing mobile token or E2EE public key in ${USER_DATA}`)
|
||||
}
|
||||
|
||||
let reqId = 0
|
||||
const pending = new Map<string, PendingRequest>()
|
||||
const streamListeners = new Map<string, (result: Record<string, unknown>) => void>()
|
||||
const clientKeys = nacl.box.keyPair()
|
||||
const serverPublicKey = Buffer.from(keypair.publicKeyB64, 'base64')
|
||||
const sharedKey = nacl.box.before(new Uint8Array(serverPublicKey), clientKeys.secretKey)
|
||||
|
||||
function nextId(): string {
|
||||
reqId += 1
|
||||
return `startup-repro-${reqId}`
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
function fromBase64(value: string): Uint8Array {
|
||||
return new Uint8Array(Buffer.from(value, 'base64'))
|
||||
}
|
||||
|
||||
function encrypt(plaintext: string): string {
|
||||
const nonce = nacl.randomBytes(nacl.box.nonceLength)
|
||||
const message = new TextEncoder().encode(plaintext)
|
||||
const ciphertext = nacl.box.after(message, nonce, sharedKey)
|
||||
const bundle = new Uint8Array(nonce.length + ciphertext.length)
|
||||
bundle.set(nonce)
|
||||
bundle.set(ciphertext, nonce.length)
|
||||
return toBase64(bundle)
|
||||
}
|
||||
|
||||
function decrypt(payload: string): string | null {
|
||||
const bundle = fromBase64(payload)
|
||||
if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) {
|
||||
return null
|
||||
}
|
||||
const nonce = bundle.subarray(0, nacl.box.nonceLength)
|
||||
const ciphertext = bundle.subarray(nacl.box.nonceLength)
|
||||
const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey)
|
||||
return plaintext ? new TextDecoder().decode(plaintext) : null
|
||||
}
|
||||
|
||||
function sendRaw(ws: WebSocket, payload: unknown): void {
|
||||
ws.send(encrypt(JSON.stringify(payload)))
|
||||
}
|
||||
|
||||
function send(
|
||||
ws: WebSocket,
|
||||
method: string,
|
||||
params?: unknown,
|
||||
timeoutMs = 30_000
|
||||
): Promise<RpcResponse> {
|
||||
const id = nextId()
|
||||
sendRaw(ws, { id, deviceToken: token, method, params })
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
reject(new Error(`Timed out waiting for ${method}`))
|
||||
}, timeoutMs)
|
||||
pending.set(id, {
|
||||
method,
|
||||
resolve: (response) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(response)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function formatError(response: RpcResponse): string {
|
||||
return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000)
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return (
|
||||
value
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\r/g, '\n')
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePreview(value: string): string {
|
||||
return stripAnsi(value)
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise<TerminalInfo[]> {
|
||||
const deadline = Date.now() + 60_000
|
||||
while (Date.now() < deadline) {
|
||||
const response = await send(ws, 'terminal.list', { worktree: worktreeId })
|
||||
if (!response.ok) {
|
||||
throw new Error(`terminal.list failed: ${formatError(response)}`)
|
||||
}
|
||||
const terminals = (response.result?.terminals ?? []) as Array<{
|
||||
handle?: string
|
||||
title?: string | null
|
||||
}>
|
||||
const handles = terminals
|
||||
.filter((terminal): terminal is { handle: string; title?: string | null } =>
|
||||
Boolean(terminal.handle)
|
||||
)
|
||||
.map((terminal) => ({ handle: terminal.handle, title: terminal.title ?? null }))
|
||||
if (handles.length > 0) {
|
||||
return handles
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
throw new Error('Timed out waiting for startup terminals')
|
||||
}
|
||||
|
||||
async function subscribe(ws: WebSocket, capture: Capture): Promise<void> {
|
||||
const id = nextId()
|
||||
streamListeners.set(id, (result) => {
|
||||
if (result.type === 'scrollback') {
|
||||
capture.scrollback = result
|
||||
return
|
||||
}
|
||||
if (result.type === 'data' && typeof result.chunk === 'string') {
|
||||
capture.chunks.push(result.chunk)
|
||||
}
|
||||
})
|
||||
sendRaw(ws, {
|
||||
id,
|
||||
deviceToken: token,
|
||||
method: 'terminal.subscribe',
|
||||
params: { terminal: capture.handle }
|
||||
})
|
||||
}
|
||||
|
||||
function summarize(capture: Capture): Record<string, unknown> {
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
const live = capture.chunks.join('')
|
||||
const serializedPreview = normalizePreview(serialized)
|
||||
const livePreview = normalizePreview(live)
|
||||
return {
|
||||
handle: capture.handle,
|
||||
title: capture.title,
|
||||
cols: capture.scrollback?.cols ?? null,
|
||||
rows: capture.scrollback?.rows ?? null,
|
||||
serializedBytes: Buffer.byteLength(serialized),
|
||||
liveBytes: Buffer.byteLength(live),
|
||||
liveChunks: capture.chunks.length,
|
||||
serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
livePreviewContainedInSerialized:
|
||||
livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
|
||||
serializedPreview,
|
||||
livePreview
|
||||
}
|
||||
}
|
||||
|
||||
function save(captures: Capture[], worktreeName: string): string {
|
||||
const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const capture of captures) {
|
||||
const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
|
||||
writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
|
||||
writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
|
||||
}
|
||||
writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function run(ws: WebSocket): Promise<void> {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: toBase64(clientKeys.publicKey)
|
||||
})
|
||||
)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for e2ee_ready')), 5000)
|
||||
ws.once('message', (data) => {
|
||||
clearTimeout(timeout)
|
||||
const msg = JSON.parse(data.toString()) as { type?: string }
|
||||
if (msg.type !== 'e2ee_ready') {
|
||||
reject(new Error(`Unexpected handshake response: ${data.toString()}`))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
sendRaw(ws, { type: 'e2ee_auth', deviceToken: token })
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error('Timed out waiting for e2ee_authenticated')),
|
||||
5000
|
||||
)
|
||||
ws.once('message', (data) => {
|
||||
clearTimeout(timeout)
|
||||
const plaintext = decrypt(data.toString())
|
||||
const msg = plaintext ? (JSON.parse(plaintext) as { type?: string }) : null
|
||||
if (msg?.type !== 'e2ee_authenticated') {
|
||||
reject(new Error(`Unexpected auth response: ${data.toString()}`))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const created = await send(
|
||||
ws,
|
||||
'worktree.create',
|
||||
{ repo: repoSelector, name: worktreeName, startupCommand },
|
||||
120_000
|
||||
)
|
||||
if (!created.ok) {
|
||||
throw new Error(`worktree.create failed: ${formatError(created)}`)
|
||||
}
|
||||
const worktree = created.result?.worktree as { id?: string; path?: string } | undefined
|
||||
if (!worktree?.id) {
|
||||
throw new Error(`worktree.create returned no worktree id: ${formatError(created)}`)
|
||||
}
|
||||
|
||||
console.log(`worktree: ${worktree.id}`)
|
||||
const terminals = await waitForTerminals(ws, worktree.id)
|
||||
console.log(`terminals: ${terminals.map((terminal) => terminal.handle).join(', ')}`)
|
||||
const captures = terminals.map((terminal) => ({
|
||||
handle: terminal.handle,
|
||||
title: terminal.title,
|
||||
scrollback: null,
|
||||
chunks: []
|
||||
}))
|
||||
for (const capture of captures) {
|
||||
await subscribe(ws, capture)
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 15_000))
|
||||
for (const capture of captures) {
|
||||
await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null)
|
||||
}
|
||||
const dir = save(captures, worktreeName)
|
||||
console.table(captures.map(summarize))
|
||||
console.log(`saved: ${dir}`)
|
||||
}
|
||||
|
||||
const ws = new WebSocket(WS_URL)
|
||||
|
||||
ws.on('open', () => {
|
||||
run(ws)
|
||||
.then(() => {
|
||||
ws.close()
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error.message)
|
||||
ws.close()
|
||||
process.exit(1)
|
||||
})
|
||||
})
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const raw = data.toString()
|
||||
if (raw.startsWith('{')) {
|
||||
return
|
||||
}
|
||||
const plaintext = decrypt(raw)
|
||||
if (!plaintext) return
|
||||
|
||||
const response = JSON.parse(plaintext) as RpcResponse
|
||||
const result = response.result
|
||||
const streamListener = streamListeners.get(response.id)
|
||||
if (streamListener && response.ok && (response.streaming || result?.type)) {
|
||||
streamListener(result ?? {})
|
||||
return
|
||||
}
|
||||
|
||||
const request = pending.get(response.id)
|
||||
if (!request || request.method === 'terminal.subscribe') return
|
||||
pending.delete(response.id)
|
||||
request.resolve(response)
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
for (const request of pending.values()) {
|
||||
request.reject(new Error('WebSocket closed'))
|
||||
}
|
||||
pending.clear()
|
||||
})
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error(`WebSocket error: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Lightweight terminal streaming repro for the mobile WebSocket RPC.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> [worktreeSelector]
|
||||
*
|
||||
* Example:
|
||||
* pnpm exec tsx scripts/test-subscribe.ts "$TOKEN" "$SERVER_PUBLIC_KEY" \
|
||||
* "id:repo-id::/path/to/worktree"
|
||||
*/
|
||||
import nacl from 'tweetnacl'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
|
||||
const token = process.argv[2]
|
||||
const serverPublicKeyB64 = process.argv[3]
|
||||
const worktreeSelector = process.argv[4]
|
||||
const marker = `MOBILE_STREAM_${Date.now()}`
|
||||
|
||||
type RpcResponse = {
|
||||
id: string
|
||||
ok: boolean
|
||||
streaming?: true
|
||||
result?: Record<string, unknown>
|
||||
error?: { code: string; message: string }
|
||||
_meta?: { runtimeId: string }
|
||||
}
|
||||
|
||||
type PendingRequest = {
|
||||
method: string
|
||||
resolve: (response: RpcResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
if (!token || !serverPublicKeyB64) {
|
||||
console.error(
|
||||
'Usage: pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> [worktreeSelector]'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let reqId = 0
|
||||
const pending = new Map<string, PendingRequest>()
|
||||
let streamSawMarker = false
|
||||
let readSawMarker = false
|
||||
let activeHandle: string | null = null
|
||||
let runtimeId = ''
|
||||
let scrollbackCols: number | null = null
|
||||
let scrollbackRows: number | null = null
|
||||
let serializedLength = 0
|
||||
const clientKeys = nacl.box.keyPair()
|
||||
const serverPublicKey = Buffer.from(serverPublicKeyB64, 'base64')
|
||||
const sharedKey = nacl.box.before(new Uint8Array(serverPublicKey), clientKeys.secretKey)
|
||||
|
||||
function nextId(): string {
|
||||
reqId += 1
|
||||
return `test-${reqId}`
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
function fromBase64(value: string): Uint8Array {
|
||||
return new Uint8Array(Buffer.from(value, 'base64'))
|
||||
}
|
||||
|
||||
function encrypt(plaintext: string): string {
|
||||
const nonce = nacl.randomBytes(nacl.box.nonceLength)
|
||||
const message = new TextEncoder().encode(plaintext)
|
||||
const ciphertext = nacl.box.after(message, nonce, sharedKey)
|
||||
const bundle = new Uint8Array(nonce.length + ciphertext.length)
|
||||
bundle.set(nonce)
|
||||
bundle.set(ciphertext, nonce.length)
|
||||
return toBase64(bundle)
|
||||
}
|
||||
|
||||
function decrypt(payload: string): string | null {
|
||||
const bundle = fromBase64(payload)
|
||||
const nonce = bundle.subarray(0, nacl.box.nonceLength)
|
||||
const ciphertext = bundle.subarray(nacl.box.nonceLength)
|
||||
const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey)
|
||||
return plaintext ? new TextDecoder().decode(plaintext) : null
|
||||
}
|
||||
|
||||
function sendRaw(ws: WebSocket, payload: unknown): void {
|
||||
ws.send(encrypt(JSON.stringify(payload)))
|
||||
}
|
||||
|
||||
function send(ws: WebSocket, method: string, params?: unknown): Promise<RpcResponse> {
|
||||
const id = nextId()
|
||||
sendRaw(ws, { id, deviceToken: token, method, params })
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
reject(new Error(`Timed out waiting for ${method}`))
|
||||
}, 10_000)
|
||||
pending.set(id, {
|
||||
method,
|
||||
resolve: (response) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(response)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function formatResponse(response: RpcResponse): string {
|
||||
return JSON.stringify(response.result ?? response.error ?? response).slice(0, 1000)
|
||||
}
|
||||
|
||||
async function chooseWorktree(ws: WebSocket): Promise<string> {
|
||||
if (worktreeSelector) return worktreeSelector
|
||||
|
||||
const response = await send(ws, 'worktree.ps')
|
||||
if (!response.ok) {
|
||||
throw new Error(`worktree.ps failed: ${formatResponse(response)}`)
|
||||
}
|
||||
|
||||
const worktrees = (response.result?.worktrees ?? []) as Array<{
|
||||
worktreeId: string
|
||||
liveTerminalCount: number
|
||||
branch: string
|
||||
path: string
|
||||
}>
|
||||
const selected = worktrees.find((w) => w.liveTerminalCount > 0) ?? worktrees[0]
|
||||
if (!selected) {
|
||||
throw new Error('No worktrees returned by worktree.ps')
|
||||
}
|
||||
|
||||
console.log(`worktree: ${selected.branch || '(no branch)'} ${selected.path}`)
|
||||
return `id:${selected.worktreeId}`
|
||||
}
|
||||
|
||||
async function chooseTerminal(ws: WebSocket, worktree: string): Promise<string> {
|
||||
const list = await send(ws, 'terminal.list', { worktree })
|
||||
if (!list.ok) {
|
||||
throw new Error(`terminal.list failed: ${formatResponse(list)}`)
|
||||
}
|
||||
|
||||
const terminals = (list.result?.terminals ?? []) as Array<{
|
||||
handle: string
|
||||
title: string
|
||||
preview: string
|
||||
lastOutputAt: number | null
|
||||
}>
|
||||
|
||||
if (terminals.length > 0) {
|
||||
const selected = terminals[0]!
|
||||
console.log(`terminal: ${selected.title || selected.handle} ${selected.handle}`)
|
||||
return selected.handle
|
||||
}
|
||||
|
||||
const created = await send(ws, 'terminal.create', {
|
||||
worktree,
|
||||
title: 'mobile-stream-repro'
|
||||
})
|
||||
if (!created.ok) {
|
||||
throw new Error(`terminal.create failed: ${formatResponse(created)}`)
|
||||
}
|
||||
|
||||
const terminal = created.result?.terminal as { handle?: string; title?: string } | undefined
|
||||
if (!terminal?.handle) {
|
||||
throw new Error(`terminal.create returned no handle: ${formatResponse(created)}`)
|
||||
}
|
||||
console.log(`terminal: ${terminal.title || terminal.handle} ${terminal.handle}`)
|
||||
return terminal.handle
|
||||
}
|
||||
|
||||
async function run(ws: WebSocket): Promise<void> {
|
||||
console.log(`connected: ${WS_URL}`)
|
||||
ws.send(JSON.stringify({ type: 'e2ee_hello', publicKeyB64: toBase64(clientKeys.publicKey) }))
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for e2ee_ready')), 5000)
|
||||
ws.once('message', (data) => {
|
||||
clearTimeout(timeout)
|
||||
const msg = JSON.parse(data.toString()) as { type?: string }
|
||||
if (msg.type !== 'e2ee_ready') {
|
||||
reject(new Error(`Unexpected handshake response: ${data.toString()}`))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
sendRaw(ws, { type: 'e2ee_auth', deviceToken: token })
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error('Timed out waiting for e2ee_authenticated')),
|
||||
5000
|
||||
)
|
||||
ws.once('message', (data) => {
|
||||
clearTimeout(timeout)
|
||||
const plaintext = decrypt(data.toString())
|
||||
const msg = plaintext ? (JSON.parse(plaintext) as { type?: string }) : null
|
||||
if (msg?.type !== 'e2ee_authenticated') {
|
||||
reject(new Error(`Unexpected auth response: ${data.toString()}`))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const worktree = await chooseWorktree(ws)
|
||||
const handle = await chooseTerminal(ws, worktree)
|
||||
activeHandle = handle
|
||||
|
||||
void send(ws, 'terminal.subscribe', { terminal: handle }).catch((error) => {
|
||||
console.error(`subscribe failed: ${error.message}`)
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
const sendResponse = await send(ws, 'terminal.send', {
|
||||
terminal: handle,
|
||||
text: `echo ${marker}`,
|
||||
enter: true
|
||||
})
|
||||
if (!sendResponse.ok) {
|
||||
throw new Error(`terminal.send failed: ${formatResponse(sendResponse)}`)
|
||||
}
|
||||
console.log(`sent marker: ${marker}`)
|
||||
|
||||
const deadline = Date.now() + 5_000
|
||||
while (Date.now() < deadline && !streamSawMarker && !readSawMarker) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
const read = await send(ws, 'terminal.read', { terminal: handle })
|
||||
if (!read.ok) {
|
||||
throw new Error(`terminal.read failed: ${formatResponse(read)}`)
|
||||
}
|
||||
const terminal = read.result?.terminal as { tail?: string[]; preview?: string } | undefined
|
||||
const text = [...(terminal?.tail ?? []), terminal?.preview ?? ''].join('\n')
|
||||
if (text.includes(marker)) {
|
||||
readSawMarker = true
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`runtime: ${runtimeId || '(unknown)'}`)
|
||||
console.log(`scrollbackCols: ${scrollbackCols ?? '(none)'}`)
|
||||
console.log(`scrollbackRows: ${scrollbackRows ?? '(none)'}`)
|
||||
console.log(`serializedLength: ${serializedLength}`)
|
||||
console.log(`streamSawMarker: ${streamSawMarker}`)
|
||||
console.log(`readSawMarker: ${readSawMarker}`)
|
||||
|
||||
if (!streamSawMarker && !readSawMarker) {
|
||||
throw new Error('terminal output did not reach stream or terminal.read')
|
||||
}
|
||||
}
|
||||
|
||||
const ws = new WebSocket(WS_URL)
|
||||
|
||||
ws.on('open', () => {
|
||||
run(ws)
|
||||
.then(() => {
|
||||
ws.close()
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error.message)
|
||||
ws.close()
|
||||
process.exit(1)
|
||||
})
|
||||
})
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const plaintext = decrypt(data.toString())
|
||||
if (!plaintext) return
|
||||
const response = JSON.parse(plaintext) as RpcResponse
|
||||
if (response._meta?.runtimeId) {
|
||||
runtimeId = response._meta.runtimeId
|
||||
}
|
||||
|
||||
const result = response.result
|
||||
if (response.streaming && result?.type === 'data') {
|
||||
const chunk = typeof result.chunk === 'string' ? result.chunk : ''
|
||||
if (chunk.includes(marker)) {
|
||||
streamSawMarker = true
|
||||
}
|
||||
}
|
||||
|
||||
if (response.streaming && result?.type === 'scrollback') {
|
||||
const lines = Array.isArray(result.lines) ? result.lines.join('\n') : String(result.lines ?? '')
|
||||
scrollbackCols = typeof result.cols === 'number' ? result.cols : null
|
||||
scrollbackRows = typeof result.rows === 'number' ? result.rows : null
|
||||
serializedLength = typeof result.serialized === 'string' ? result.serialized.length : 0
|
||||
if (lines.includes(marker)) {
|
||||
streamSawMarker = true
|
||||
}
|
||||
if (typeof result.serialized === 'string' && result.serialized.includes(marker)) {
|
||||
streamSawMarker = true
|
||||
}
|
||||
}
|
||||
|
||||
const pendingRequest = pending.get(response.id)
|
||||
if (!pendingRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
if (response.streaming && pendingRequest.method === 'terminal.subscribe') {
|
||||
return
|
||||
}
|
||||
|
||||
pending.delete(response.id)
|
||||
pendingRequest.resolve(response)
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
for (const request of pending.values()) {
|
||||
request.reject(new Error('WebSocket closed'))
|
||||
}
|
||||
pending.clear()
|
||||
})
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error(`WebSocket error: ${error.message}`)
|
||||
if (activeHandle) {
|
||||
console.error(`active terminal: ${activeHandle}`)
|
||||
}
|
||||
process.exit(1)
|
||||
})
|
||||
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
// Why: module-level cache lets the home screen pre-populate worktree data
|
||||
// so the host detail page can render instantly on navigation instead of
|
||||
// waiting for a fresh RPC connection + fetch cycle.
|
||||
|
||||
type CachedWorktrees = {
|
||||
worktrees: unknown[]
|
||||
at: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, CachedWorktrees>()
|
||||
|
||||
const MAX_AGE_MS = 30_000
|
||||
const MAX_ENTRIES = 20
|
||||
|
||||
export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void {
|
||||
// Why: Map.set on an existing key does not move it to the end of iteration
|
||||
// order. Delete first so the re-inserted key becomes the newest entry,
|
||||
// giving us true LRU eviction when the cap is hit.
|
||||
cache.delete(hostId)
|
||||
cache.set(hostId, { worktrees, at: Date.now() })
|
||||
if (cache.size > MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value
|
||||
if (oldest) cache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedWorktrees(hostId: string): unknown[] | null {
|
||||
const entry = cache.get(hostId)
|
||||
if (!entry) return null
|
||||
if (Date.now() - entry.at > MAX_AGE_MS) {
|
||||
cache.delete(hostId)
|
||||
return null
|
||||
}
|
||||
return entry.worktrees
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native'
|
||||
import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
|
||||
export type ActionSheetAction = {
|
||||
label: string
|
||||
icon?: LucideIcon
|
||||
destructive?: boolean
|
||||
skipAutoClose?: boolean
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
title?: string
|
||||
message?: string
|
||||
actions: ActionSheetAction[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function iconForAction(label: string, destructive?: boolean, icon?: LucideIcon): LucideIcon {
|
||||
if (icon) return icon
|
||||
if (destructive || /delete|remove/i.test(label)) return Trash2
|
||||
return Edit3
|
||||
}
|
||||
|
||||
type ContentProps = {
|
||||
title?: string
|
||||
message?: string
|
||||
actions: ActionSheetAction[]
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function ActionSheetContent({ title, message, actions, onClose }: ContentProps) {
|
||||
return (
|
||||
<>
|
||||
{(title || message) && (
|
||||
<View style={styles.header}>
|
||||
{title ? (
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
) : null}
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.actionGroup}>
|
||||
{actions.map((action, i) => {
|
||||
const Icon = iconForAction(action.label, action.destructive, action.icon)
|
||||
return (
|
||||
<View key={action.label}>
|
||||
{i > 0 && <View style={styles.separator} />}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
|
||||
onPress={() => {
|
||||
action.onPress()
|
||||
if (!action.skipAutoClose && onClose) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
color={action.destructive ? colors.statusRed : colors.textSecondary}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.actionText, action.destructive && styles.actionTextDestructive]}
|
||||
>
|
||||
{action.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
<ActionSheetContent title={title} message={message} actions={actions} onClose={onClose} />
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingBottom: spacing.sm
|
||||
},
|
||||
title: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textMuted
|
||||
},
|
||||
message: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
marginTop: 2
|
||||
},
|
||||
actionGroup: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
action: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
actionPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
actionText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
actionTextDestructive: {
|
||||
color: colors.statusRed
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Animated, Easing, StyleSheet, View } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
|
||||
const STATUS_COLORS: Record<WorktreeStatus, string> = {
|
||||
working: colors.statusGreen,
|
||||
active: colors.statusGreen,
|
||||
permission: colors.statusRed,
|
||||
done: '#38bdf8',
|
||||
inactive: '#666666'
|
||||
}
|
||||
|
||||
export function AgentSpinner({ status }: { status: WorktreeStatus }) {
|
||||
const spinValue = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'working') {
|
||||
const animation = Animated.loop(
|
||||
Animated.timing(spinValue, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true
|
||||
})
|
||||
)
|
||||
animation.start()
|
||||
return () => animation.stop()
|
||||
}
|
||||
spinValue.setValue(0)
|
||||
}, [status, spinValue])
|
||||
|
||||
const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive
|
||||
|
||||
if (status === 'working') {
|
||||
const rotate = spinValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg']
|
||||
})
|
||||
return (
|
||||
<Animated.View style={[styles.spinner, { borderColor: color, transform: [{ rotate }] }]} />
|
||||
)
|
||||
}
|
||||
|
||||
return <View style={[styles.dot, { backgroundColor: color }]} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4
|
||||
},
|
||||
spinner: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
borderWidth: 2,
|
||||
borderTopColor: 'transparent'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
ScrollView,
|
||||
Keyboard,
|
||||
BackHandler
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
withTiming,
|
||||
runOnJS,
|
||||
interpolate,
|
||||
Extrapolation
|
||||
} from 'react-native-reanimated'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
|
||||
const DISMISS_THRESHOLD = 80
|
||||
const SPRING_CONFIG = { damping: 28, stiffness: 400 }
|
||||
// Why: negative translateY (pulling up) is damped with a rubber-band factor
|
||||
// so the drawer resists upward dragging — a subtle polish touch that signals
|
||||
// the drawer cannot expand further.
|
||||
const RUBBER_BAND_FACTOR = 0.25
|
||||
const SHOW_DURATION = 180
|
||||
const HIDE_DURATION = 150
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function BottomDrawer({ visible, onClose, children }: Props) {
|
||||
const [mounted, setMounted] = useState(visible)
|
||||
const translateY = useSharedValue(0)
|
||||
const progress = useSharedValue(0)
|
||||
const keyboardOffset = useSharedValue(0)
|
||||
const { height: screenHeight } = useWindowDimensions()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setMounted(true)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
|
||||
if (visible) {
|
||||
translateY.value = 0
|
||||
progress.value = withTiming(1, { duration: SHOW_DURATION })
|
||||
} else {
|
||||
Keyboard.dismiss()
|
||||
progress.value = withTiming(0, { duration: HIDE_DURATION }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(setMounted)(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [mounted, visible])
|
||||
|
||||
// Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable
|
||||
// inside Modal (iOS ignores KAV; Android needs adjustNothing for
|
||||
// useAnimatedKeyboard). Keyboard event listeners work on both platforms
|
||||
// and give us the exact height to shift the drawer by.
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
|
||||
|
||||
const onShow = Keyboard.addListener(showEvent, (e) => {
|
||||
const height = e.endCoordinates.height - insets.bottom
|
||||
keyboardOffset.value = withTiming(Math.max(height, 0), { duration: e.duration || 250 })
|
||||
})
|
||||
const onHide = Keyboard.addListener(hideEvent, (e) => {
|
||||
keyboardOffset.value = withTiming(0, { duration: e.duration || 250 })
|
||||
})
|
||||
|
||||
return () => {
|
||||
onShow.remove()
|
||||
onHide.remove()
|
||||
keyboardOffset.value = 0
|
||||
}
|
||||
}, [visible, insets.bottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
onClose()
|
||||
return true
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [visible, onClose])
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.onUpdate((e) => {
|
||||
if (e.translationY > 0) {
|
||||
translateY.value = e.translationY
|
||||
} else {
|
||||
translateY.value = e.translationY * RUBBER_BAND_FACTOR
|
||||
}
|
||||
})
|
||||
.onEnd((e) => {
|
||||
if (e.translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
|
||||
const velocity = Math.max(e.velocityY, 800)
|
||||
const remaining = screenHeight - e.translationY
|
||||
const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
|
||||
translateY.value = withTiming(screenHeight, { duration })
|
||||
progress.value = withTiming(0, { duration }, () => {
|
||||
runOnJS(dismiss)()
|
||||
})
|
||||
} else {
|
||||
translateY.value = withSpring(0, SPRING_CONFIG)
|
||||
}
|
||||
})
|
||||
|
||||
const drawerStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateY:
|
||||
interpolate(progress.value, [0, 1], [screenHeight, 0], Extrapolation.CLAMP) +
|
||||
translateY.value -
|
||||
keyboardOffset.value
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const backdropStyle = useAnimatedStyle(() => {
|
||||
const dragFade = interpolate(translateY.value, [0, 300], [1, 0], Extrapolation.CLAMP)
|
||||
return { opacity: progress.value * dragFade }
|
||||
})
|
||||
|
||||
const pointerStyle = useAnimatedStyle(
|
||||
() =>
|
||||
({
|
||||
pointerEvents: progress.value > 0 ? 'auto' : 'none'
|
||||
}) as { pointerEvents: 'auto' | 'none' }
|
||||
)
|
||||
|
||||
// Why: hidden drawers can contain auto-focused inputs; keeping them mounted
|
||||
// lets Android open the keyboard even when the drawer is offscreen.
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, pointerStyle]} accessibilityViewIsModal aria-modal>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<Animated.View style={[styles.backdrop, backdropStyle]}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.anchor} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
{
|
||||
maxHeight: screenHeight - insets.top - spacing.lg,
|
||||
paddingBottom: insets.bottom + spacing.lg
|
||||
},
|
||||
drawerStyle
|
||||
]}
|
||||
>
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
<View style={styles.bottomExtension} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 1000
|
||||
},
|
||||
root: {
|
||||
flex: 1
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)'
|
||||
},
|
||||
anchor: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end'
|
||||
},
|
||||
drawer: {
|
||||
backgroundColor: colors.bgBase,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingHorizontal: spacing.md,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 10
|
||||
},
|
||||
android: { elevation: 8 }
|
||||
})
|
||||
},
|
||||
handle: {
|
||||
alignSelf: 'center',
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.textMuted,
|
||||
opacity: 0.4
|
||||
},
|
||||
handleHitArea: {
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.md
|
||||
},
|
||||
bottomExtension: {
|
||||
position: 'absolute',
|
||||
bottom: -500,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 500,
|
||||
backgroundColor: colors.bgBase
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
title: string
|
||||
message?: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
destructive?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
visible,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
destructive = false,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: Props) {
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onCancel}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
</View>
|
||||
<View style={styles.buttons}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.button, styles.cancelButton, pressed && styles.pressed]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={styles.cancelText}>{cancelLabel}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
destructive ? styles.destructiveButton : styles.confirmButton,
|
||||
pressed && styles.pressed
|
||||
]}
|
||||
onPress={() => {
|
||||
onConfirm()
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<Text style={destructive ? styles.destructiveText : styles.confirmText}>
|
||||
{confirmLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
message: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
lineHeight: 20
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button,
|
||||
alignItems: 'center'
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
destructiveButton: {
|
||||
backgroundColor: colors.statusRed
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
confirmText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.bgBase
|
||||
},
|
||||
destructiveText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,338 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { View, Text, Pressable, TextInput, StyleSheet, ScrollView, Switch } from 'react-native'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
|
||||
const STORAGE_KEY = 'orca:custom-accessory-keys'
|
||||
|
||||
export type CustomKey = {
|
||||
id: string
|
||||
label: string
|
||||
bytes: string
|
||||
enter: boolean
|
||||
}
|
||||
|
||||
type Step = 'choose-type' | 'pick-ctrl' | 'pick-alt' | 'text-macro'
|
||||
|
||||
const ALPHA_KEYS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
|
||||
function ctrlBytes(letter: string): string {
|
||||
return String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64)
|
||||
}
|
||||
|
||||
function altBytes(letter: string): string {
|
||||
return `\x1b${letter.toLowerCase()}`
|
||||
}
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onKeysChanged: (keys: CustomKey[]) => void
|
||||
}
|
||||
|
||||
export async function loadCustomKeys(): Promise<CustomKey[]> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as CustomKey[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCustomKeys(keys: CustomKey[]): Promise<void> {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keys))
|
||||
}
|
||||
|
||||
export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
|
||||
const [step, setStep] = useState<Step>('choose-type')
|
||||
const [macroLabel, setMacroLabel] = useState('')
|
||||
const [macroText, setMacroText] = useState('')
|
||||
const [macroEnter, setMacroEnter] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setStep('choose-type')
|
||||
setMacroLabel('')
|
||||
setMacroText('')
|
||||
setMacroEnter(true)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const addKey = useCallback(
|
||||
async (key: Omit<CustomKey, 'id'>) => {
|
||||
const existing = await loadCustomKeys()
|
||||
const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` }
|
||||
const updated = [...existing, newKey]
|
||||
await saveCustomKeys(updated)
|
||||
onKeysChanged(updated)
|
||||
onClose()
|
||||
},
|
||||
[onClose, onKeysChanged]
|
||||
)
|
||||
|
||||
const handleCtrlKey = useCallback(
|
||||
(letter: string) => {
|
||||
void addKey({ label: `Ctrl+${letter}`, bytes: ctrlBytes(letter), enter: false })
|
||||
},
|
||||
[addKey]
|
||||
)
|
||||
|
||||
const handleAltKey = useCallback(
|
||||
(letter: string) => {
|
||||
void addKey({ label: `Alt+${letter}`, bytes: altBytes(letter), enter: false })
|
||||
},
|
||||
[addKey]
|
||||
)
|
||||
|
||||
const handleMacroSave = useCallback(() => {
|
||||
const label = macroLabel.trim() || macroText.trim().slice(0, 12)
|
||||
const text = macroText
|
||||
if (!label || !text) return
|
||||
const bytes = macroEnter ? `${text}\r` : text
|
||||
void addKey({ label, bytes, enter: false })
|
||||
}, [addKey, macroLabel, macroText, macroEnter])
|
||||
|
||||
const showBack = step !== 'choose-type'
|
||||
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
<View style={styles.header}>
|
||||
{showBack ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
|
||||
onPress={() => setStep('choose-type')}
|
||||
accessibilityLabel="Back"
|
||||
>
|
||||
<ChevronLeft size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.backSpacer} />
|
||||
)}
|
||||
<Text style={styles.title}>
|
||||
{step === 'choose-type' && 'Add Shortcut'}
|
||||
{step === 'pick-ctrl' && 'Ctrl + Key'}
|
||||
{step === 'pick-alt' && 'Alt + Key'}
|
||||
{step === 'text-macro' && 'Text Macro'}
|
||||
</Text>
|
||||
<View style={styles.backSpacer} />
|
||||
</View>
|
||||
|
||||
{step === 'choose-type' && (
|
||||
<View style={styles.group}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => setStep('pick-ctrl')}
|
||||
>
|
||||
<Text style={styles.rowLabel}>Ctrl + Key</Text>
|
||||
<Text style={styles.rowHint}>Control character shortcuts</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => setStep('pick-alt')}
|
||||
>
|
||||
<Text style={styles.rowLabel}>Alt + Key</Text>
|
||||
<Text style={styles.rowHint}>Alt/Option key combos</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => setStep('text-macro')}
|
||||
>
|
||||
<Text style={styles.rowLabel}>Text Macro</Text>
|
||||
<Text style={styles.rowHint}>Send custom text command</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(step === 'pick-ctrl' || step === 'pick-alt') && (
|
||||
<View style={styles.group}>
|
||||
<ScrollView style={styles.keyGridScroll} contentContainerStyle={styles.keyGrid}>
|
||||
{ALPHA_KEYS.map((letter) => (
|
||||
<Pressable
|
||||
key={letter}
|
||||
style={({ pressed }) => [styles.keyCell, pressed && styles.keyCellPressed]}
|
||||
onPress={() =>
|
||||
step === 'pick-ctrl' ? handleCtrlKey(letter) : handleAltKey(letter)
|
||||
}
|
||||
>
|
||||
<Text style={styles.keyCellText}>{letter}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{step === 'text-macro' && (
|
||||
<View style={styles.group}>
|
||||
<View style={styles.macroForm}>
|
||||
<Text style={styles.fieldLabel}>Label</Text>
|
||||
<TextInput
|
||||
style={styles.fieldInput}
|
||||
value={macroLabel}
|
||||
onChangeText={setMacroLabel}
|
||||
placeholder="e.g. Build"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={styles.fieldLabel}>Command</Text>
|
||||
<TextInput
|
||||
style={styles.fieldInput}
|
||||
value={macroText}
|
||||
onChangeText={setMacroText}
|
||||
placeholder="e.g. pnpm build"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>Press Enter</Text>
|
||||
<Switch
|
||||
value={macroEnter}
|
||||
onValueChange={setMacroEnter}
|
||||
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
style={[styles.saveButton, !macroText.trim() && styles.saveButtonDisabled]}
|
||||
disabled={!macroText.trim()}
|
||||
onPress={handleMacroSave}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>Add Shortcut</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingBottom: spacing.sm
|
||||
},
|
||||
backButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
backButtonPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
backSpacer: {
|
||||
width: 30
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center'
|
||||
},
|
||||
group: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
row: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 1
|
||||
},
|
||||
rowHint: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted
|
||||
},
|
||||
keyGridScroll: {
|
||||
maxHeight: 240
|
||||
},
|
||||
keyGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.xs,
|
||||
justifyContent: 'center',
|
||||
padding: spacing.md
|
||||
},
|
||||
keyCell: {
|
||||
width: 42,
|
||||
height: 38,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgBase,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keyCellPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
keyCellText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
macroForm: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
fieldInput: {
|
||||
backgroundColor: colors.bgBase,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
switchLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button,
|
||||
alignItems: 'center'
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.5
|
||||
},
|
||||
saveButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,756 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
Switch,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
Image
|
||||
} from 'react-native'
|
||||
import { ChevronDown, ChevronUp, Check, Terminal } from 'lucide-react-native'
|
||||
import Svg, { Path, G } from 'react-native-svg'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
|
||||
type Repo = {
|
||||
id: string
|
||||
displayName: string
|
||||
path: string
|
||||
}
|
||||
|
||||
type AgentOption = {
|
||||
id: string
|
||||
label: string
|
||||
faviconDomain?: string
|
||||
}
|
||||
|
||||
// Why: matches the AGENT_CATALOG ordering and faviconDomain values from
|
||||
// src/renderer/src/lib/agent-catalog.tsx so mobile uses the same icon sources.
|
||||
const AGENT_OPTIONS: AgentOption[] = [
|
||||
{ id: 'claude', label: 'Claude' },
|
||||
{ id: 'codex', label: 'Codex' },
|
||||
{ id: 'copilot', label: 'GitHub Copilot', faviconDomain: 'github.com' },
|
||||
{ id: 'opencode', label: 'OpenCode', faviconDomain: 'opencode.ai' },
|
||||
{ id: 'pi', label: 'Pi' },
|
||||
{ id: 'gemini', label: 'Gemini', faviconDomain: 'gemini.google.com' },
|
||||
{ id: 'aider', label: 'Aider' },
|
||||
{ id: 'goose', label: 'Goose', faviconDomain: 'goose-docs.ai' },
|
||||
{ id: 'amp', label: 'Amp', faviconDomain: 'ampcode.com' },
|
||||
{ id: 'kilo', label: 'Kilocode', faviconDomain: 'kilo.ai' },
|
||||
{ id: 'kiro', label: 'Kiro', faviconDomain: 'kiro.dev' },
|
||||
{ id: 'crush', label: 'Charm', faviconDomain: 'charm.sh' },
|
||||
{ id: 'aug', label: 'Auggie', faviconDomain: 'augmentcode.com' },
|
||||
{ id: 'cline', label: 'Cline', faviconDomain: 'cline.bot' },
|
||||
{ id: 'codebuff', label: 'Codebuff', faviconDomain: 'codebuff.com' },
|
||||
{ id: 'continue', label: 'Continue', faviconDomain: 'continue.dev' },
|
||||
{ id: 'cursor', label: 'Cursor', faviconDomain: 'cursor.com' },
|
||||
{ id: 'droid', label: 'Droid', faviconDomain: 'factory.ai' },
|
||||
{ id: 'kimi', label: 'Kimi', faviconDomain: 'moonshot.cn' },
|
||||
{ id: 'mistral-vibe', label: 'Mistral Vibe', faviconDomain: 'mistral.ai' },
|
||||
{ id: 'qwen-code', label: 'Qwen Code', faviconDomain: 'qwenlm.github.io' },
|
||||
{ id: 'rovo', label: 'Rovo Dev', faviconDomain: 'atlassian.com' },
|
||||
{ id: 'hermes', label: 'Hermes', faviconDomain: 'nousresearch.com' }
|
||||
]
|
||||
|
||||
const BLANK_TERMINAL: AgentOption = { id: '__blank__', label: 'Blank Terminal' }
|
||||
const ALL_AGENTS = [...AGENT_OPTIONS, BLANK_TERMINAL]
|
||||
|
||||
// Why: mirrors launchCmd from src/shared/tui-agent-config.ts so terminal.create
|
||||
// gets the correct binary name for each agent.
|
||||
const AGENT_COMMANDS: Record<string, string> = {
|
||||
claude: 'claude',
|
||||
codex: 'codex',
|
||||
copilot: 'copilot',
|
||||
opencode: 'opencode',
|
||||
pi: 'pi',
|
||||
gemini: 'gemini',
|
||||
aider: 'aider',
|
||||
goose: 'goose',
|
||||
amp: 'amp',
|
||||
kilo: 'kilo',
|
||||
kiro: 'kiro',
|
||||
crush: 'crush',
|
||||
aug: 'auggie',
|
||||
cline: 'cline',
|
||||
codebuff: 'codebuff',
|
||||
continue: 'continue',
|
||||
cursor: 'cursor-agent',
|
||||
droid: 'droid',
|
||||
kimi: 'kimi',
|
||||
'mistral-vibe': 'mistral-vibe',
|
||||
'qwen-code': 'qwen-code',
|
||||
rovo: 'rovo',
|
||||
hermes: 'hermes'
|
||||
}
|
||||
|
||||
// ── Agent icons ─────────────────────────────────────────────────────
|
||||
// SVG paths sourced from the desktop codebase:
|
||||
// Claude & OpenAI: src/renderer/src/components/status-bar/icons.tsx
|
||||
// Pi & Aider: src/renderer/src/lib/agent-catalog.tsx
|
||||
// Agents with a faviconDomain use Google's favicon service (same as desktop).
|
||||
|
||||
function ClaudeIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
|
||||
fill="#D97757"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
function OpenAIIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"
|
||||
fill={colors.textPrimary}
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PiIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 800 800">
|
||||
<Path
|
||||
fill={colors.textPrimary}
|
||||
fillRule="evenodd"
|
||||
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
|
||||
/>
|
||||
<Path fill={colors.textPrimary} d="M517.36 400 H634.72 V634.72 H517.36 Z" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
function AiderIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 436 436">
|
||||
<G transform="translate(0,436) scale(0.1,-0.1)" fill={colors.textPrimary} stroke="none">
|
||||
<Path d="M0 2180 l0 -2180 2180 0 2180 0 0 2180 0 2180 -2180 0 -2180 0 0 -2180z m2705 1818 c20 -20 28 -121 30 -398 l2 -305 216 -5 c118 -3 218 -8 222 -12 3 -3 10 -46 15 -95 5 -48 16 -126 25 -172 17 -86 17 -81 -17 -233 -14 -67 -13 -365 2 -438 21 -100 22 -159 5 -247 -24 -122 -24 -363 1 -458 23 -88 23 -213 1 -330 -9 -49 -17 -109 -17 -132 l0 -43 203 0 c111 0 208 -4 216 -9 10 -6 18 -51 27 -148 8 -76 16 -152 20 -168 7 -39 -23 -361 -37 -387 -10 -18 -21 -19 -214 -16 -135 2 -208 7 -215 14 -22 22 -33 301 -21 501 6 102 8 189 5 194 -8 13 -417 12 -431 -2 -12 -12 -8 -146 8 -261 8 -55 8 -95 1 -140 -6 -35 -14 -99 -17 -143 -9 -123 -14 -141 -41 -154 -18 -8 -217 -11 -679 -11 l-653 0 -11 33 c-31 97 -43 336 -27 533 5 56 6 113 2 128 l-6 26 -194 0 c-211 0 -252 4 -261 28 -12 33 -17 392 -6 522 15 186 -2 174 260 180 115 3 213 8 217 12 4 4 1 52 -5 105 -7 54 -17 130 -22 168 -7 56 -5 91 11 171 10 55 22 130 26 166 4 36 10 72 15 79 7 12 128 15 665 19 l658 5 8 30 c5 18 4 72 -3 130 -12 115 -7 346 11 454 10 61 10 75 -1 82 -8 5 -300 9 -650 9 l-636 0 -27 25 c-18 16 -26 34 -26 57 0 18 -5 87 -10 153 -10 128 5 449 22 472 5 7 26 13 46 15 78 6 1281 3 1287 -4z" />
|
||||
<Path d="M1360 1833 c0 -5 -1 -164 -3 -356 l-2 -347 625 -1 c704 -1 708 -1 722 7 5 4 7 20 4 38 -29 141 -32 491 -6 595 9 38 8 45 -7 57 -15 11 -139 13 -675 14 -362 0 -658 -3 -658 -7z" />
|
||||
</G>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: `https://www.google.com/s2/favicons?domain=${domain}&sz=64` }}
|
||||
style={{ width: size, height: size, borderRadius: 2 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentLetterIcon({ letter, size = 16 }: { letter: string; size?: number }) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.letterIcon,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size * 0.22,
|
||||
backgroundColor: colors.textMuted + '33'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.letterIconText, { fontSize: size * 0.55, color: colors.textPrimary }]}>
|
||||
{letter}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentIcon({ agentId, size = 16 }: { agentId: string; size?: number }) {
|
||||
if (agentId === 'claude') return <ClaudeIcon size={size} />
|
||||
if (agentId === 'codex') return <OpenAIIcon size={size} />
|
||||
if (agentId === 'pi') return <PiIcon size={size} />
|
||||
if (agentId === 'aider') return <AiderIcon size={size} />
|
||||
if (agentId === '__blank__') return <Terminal size={size} color={colors.textMuted} />
|
||||
|
||||
const agent = AGENT_OPTIONS.find((a) => a.id === agentId)
|
||||
if (agent?.faviconDomain) {
|
||||
return <FaviconIcon domain={agent.faviconDomain} size={size} />
|
||||
}
|
||||
const label = agent?.label ?? agentId
|
||||
return <AgentLetterIcon letter={label.charAt(0).toUpperCase()} size={size} />
|
||||
}
|
||||
|
||||
// ── Picker sub-modal ────────────────────────────────────────────────
|
||||
// Why: inline dropdowns with position:absolute + ScrollView have persistent
|
||||
// touch-conflict issues in React Native. A separate modal for the picker
|
||||
// list is the standard mobile pattern — it scrolls reliably and feels native.
|
||||
|
||||
function PickerListModal<T extends { id: string; label: string }>({
|
||||
visible,
|
||||
title,
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onClose,
|
||||
renderIcon
|
||||
}: {
|
||||
visible: boolean
|
||||
title: string
|
||||
items: T[]
|
||||
selectedId: string
|
||||
onSelect: (item: T) => void
|
||||
onClose: () => void
|
||||
renderIcon?: (item: T) => React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
<View style={styles.pickerHeader}>
|
||||
<Text style={styles.pickerTitle}>{title}</Text>
|
||||
</View>
|
||||
<View style={styles.pickerGroup}>
|
||||
{items.map((item, index) => {
|
||||
const selected = item.id === selectedId
|
||||
return (
|
||||
<View key={item.id}>
|
||||
{index > 0 && <View style={styles.pickerSeparator} />}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.pickerItem, pressed && styles.pickerItemPressed]}
|
||||
onPress={() => {
|
||||
onSelect(item)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{renderIcon?.(item)}
|
||||
<Text
|
||||
style={[styles.pickerItemText, selected && styles.pickerItemTextSelected]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{selected && <Check size={14} color={colors.textPrimary} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main modal ──────────────────────────────────────────────────────
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
client: RpcClient | null
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props) {
|
||||
const [repos, setRepos] = useState<Repo[]>([])
|
||||
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
|
||||
const [showRepoPicker, setShowRepoPicker] = useState(false)
|
||||
const [selectedAgent, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
|
||||
const [showAgentPicker, setShowAgentPicker] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [setupCommand, setSetupCommand] = useState<string | null>(null)
|
||||
const [setupSource, setSetupSource] = useState<string | null>(null)
|
||||
const [runSetup, setRunSetup] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setShowRepoPicker(false)
|
||||
setShowAgentPicker(false)
|
||||
return
|
||||
}
|
||||
if (!client) return
|
||||
let stale = false
|
||||
setName('')
|
||||
setNote('')
|
||||
setShowAdvanced(false)
|
||||
setSetupCommand(null)
|
||||
setSetupSource(null)
|
||||
setRunSetup(true)
|
||||
setError('')
|
||||
setCreating(false)
|
||||
setShowRepoPicker(false)
|
||||
setShowAgentPicker(false)
|
||||
setSelectedAgent(AGENT_OPTIONS[0]!)
|
||||
setLoading(true)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await client.sendRequest('repo.list')
|
||||
if (stale) return
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { repos: Repo[] }
|
||||
setRepos(result.repos)
|
||||
if (result.repos.length === 1) {
|
||||
setSelectedRepo(result.repos[0]!)
|
||||
} else {
|
||||
setSelectedRepo(null)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!stale) setRepos([])
|
||||
} finally {
|
||||
if (!stale) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [visible, client])
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !selectedRepo) {
|
||||
setSetupCommand(null)
|
||||
setSetupSource(null)
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await client.sendRequest('repo.hooks', {
|
||||
repo: `id:${selectedRepo.id}`
|
||||
})
|
||||
if (stale) return
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as {
|
||||
hooks: { scripts: { setup?: string } } | null
|
||||
source: string | null
|
||||
setupRunPolicy: string
|
||||
}
|
||||
const cmd = result.hooks?.scripts.setup ?? null
|
||||
setSetupCommand(cmd)
|
||||
setSetupSource(result.source)
|
||||
setRunSetup(result.setupRunPolicy !== 'skip-by-default')
|
||||
}
|
||||
} catch {
|
||||
if (!stale) {
|
||||
setSetupCommand(null)
|
||||
setSetupSource(null)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, selectedRepo])
|
||||
|
||||
async function handleCreate() {
|
||||
if (!client || !selectedRepo) return
|
||||
setCreating(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const command =
|
||||
selectedAgent.id !== '__blank__' ? AGENT_COMMANDS[selectedAgent.id] : undefined
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${selectedRepo.id}`,
|
||||
startupCommand: command,
|
||||
setupDecision: runSetup ? 'inherit' : 'skip'
|
||||
}
|
||||
if (name.trim()) params.name = name.trim()
|
||||
if (note.trim()) params.comment = note.trim()
|
||||
|
||||
const response = await client.sendRequest('worktree.create', params)
|
||||
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { worktree: { id: string } }
|
||||
const worktreeId = result.worktree.id
|
||||
|
||||
onClose()
|
||||
onCreated(worktreeId, name.trim() || 'New workspace')
|
||||
} else {
|
||||
setError(response.error.message)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to create workspace')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canCreate = selectedRepo != null && !creating
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Create Workspace</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Pick a repository and agent to spin up a new workspace.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
) : repos.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.emptyText}>No repositories found</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Repository</Text>
|
||||
<Pressable style={styles.fieldButton} onPress={() => setShowRepoPicker(true)}>
|
||||
<Text
|
||||
style={[styles.fieldButtonText, !selectedRepo && styles.fieldButtonPlaceholder]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{selectedRepo?.displayName ?? 'Select a repository'}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>
|
||||
Workspace Name <Text style={styles.labelHint}>[Optional]</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={(t) => {
|
||||
setName(t)
|
||||
setError('')
|
||||
}}
|
||||
placeholder="Workspace name"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus={repos.length <= 1}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => {
|
||||
if (canCreate) void handleCreate()
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Agent</Text>
|
||||
<Pressable style={styles.fieldButton} onPress={() => setShowAgentPicker(true)}>
|
||||
<AgentIcon agentId={selectedAgent.id} size={16} />
|
||||
<Text style={styles.fieldButtonText} numberOfLines={1}>
|
||||
{selectedAgent.label}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable style={styles.advancedToggle} onPress={() => setShowAdvanced(!showAdvanced)}>
|
||||
<Text style={styles.advancedText}>Advanced</Text>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp size={14} color={colors.textSecondary} />
|
||||
) : (
|
||||
<ChevronDown size={14} color={colors.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Note</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={note}
|
||||
onChangeText={setNote}
|
||||
placeholder="Write a note"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{setupCommand ? (
|
||||
<View style={styles.field}>
|
||||
<View style={styles.setupHeader}>
|
||||
<Text style={styles.label}>Setup script</Text>
|
||||
{setupSource && (
|
||||
<View style={styles.sourceBadge}>
|
||||
<Text style={styles.sourceBadgeText}>
|
||||
{setupSource === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.setupBox}>
|
||||
<View style={styles.setupToggleRow}>
|
||||
<Text style={styles.setupToggleLabel}>Run setup command</Text>
|
||||
<Switch
|
||||
value={runSetup}
|
||||
onValueChange={setRunSetup}
|
||||
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
style={styles.setupSwitch}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.setupCommandBlock}>
|
||||
<Text style={styles.setupCommand}>{setupCommand}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.createButton, !canCreate && styles.createButtonDisabled]}
|
||||
disabled={!canCreate}
|
||||
onPress={() => void handleCreate()}
|
||||
>
|
||||
{creating ? (
|
||||
<ActivityIndicator size="small" color={colors.bgBase} />
|
||||
) : (
|
||||
<Text style={styles.createText}>Create Workspace</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</BottomDrawer>
|
||||
|
||||
{/* Sub-modals for pickers — rendered outside the main modal so they
|
||||
layer on top and scroll without touch conflicts. */}
|
||||
<PickerListModal
|
||||
visible={visible && showRepoPicker}
|
||||
title="Repository"
|
||||
items={repos.map((r) => ({ id: r.id, label: r.displayName, _repo: r }))}
|
||||
selectedId={selectedRepo?.id ?? ''}
|
||||
onSelect={(item) => setSelectedRepo((item as { _repo: Repo })._repo)}
|
||||
onClose={() => setShowRepoPicker(false)}
|
||||
/>
|
||||
|
||||
<PickerListModal
|
||||
visible={visible && showAgentPicker}
|
||||
title="Agent"
|
||||
items={ALL_AGENTS}
|
||||
selectedId={selectedAgent.id}
|
||||
onSelect={(agent) => setSelectedAgent(agent)}
|
||||
onClose={() => setShowAgentPicker(false)}
|
||||
renderIcon={(agent) => <AgentIcon agentId={agent.id} size={18} />}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
marginTop: 2
|
||||
},
|
||||
loadingContainer: {
|
||||
paddingVertical: spacing.xl,
|
||||
alignItems: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
field: {
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
labelHint: {
|
||||
fontWeight: '400',
|
||||
color: colors.textMuted
|
||||
},
|
||||
fieldButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
fieldButtonText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
fieldButtonPlaceholder: {
|
||||
color: colors.textMuted
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
|
||||
fontSize: typography.bodySize,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
error: {
|
||||
color: colors.statusRed,
|
||||
fontSize: 13,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
advancedToggle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
advancedText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
setupHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
sourceBadge: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: spacing.xs + 2,
|
||||
paddingVertical: 2
|
||||
},
|
||||
sourceBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
setupBox: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
padding: spacing.md
|
||||
},
|
||||
setupToggleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
setupToggleLabel: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
setupSwitch: {
|
||||
transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }]
|
||||
},
|
||||
setupCommandBlock: {
|
||||
backgroundColor: colors.bgBase,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
setupCommand: {
|
||||
fontSize: 13,
|
||||
fontFamily: typography.monoFamily,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
createButton: {
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
minWidth: 160,
|
||||
alignItems: 'center'
|
||||
},
|
||||
createButtonDisabled: {
|
||||
opacity: 0.4
|
||||
},
|
||||
createText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
letterIcon: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
letterIconText: {
|
||||
fontWeight: '700'
|
||||
},
|
||||
|
||||
// Picker sub-modal styles
|
||||
pickerHeader: {
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingBottom: spacing.sm
|
||||
},
|
||||
pickerTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textMuted
|
||||
},
|
||||
pickerGroup: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
pickerSeparator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
pickerList: {
|
||||
flexGrow: 0
|
||||
},
|
||||
pickerItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
pickerItemPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
pickerItemText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
pickerItemTextSelected: {
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import Svg, { G, Path, Defs } from 'react-native-svg'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
type Props = {
|
||||
size?: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function OrcaLogo({ size = 24, color = colors.textPrimary }: Props) {
|
||||
const aspectRatio = 318.6 / 202.67
|
||||
const width = size * aspectRatio
|
||||
return (
|
||||
<Svg width={width} height={size} viewBox="0 0 318.60232 202.66667">
|
||||
<Defs />
|
||||
<G transform="translate(-6.6666669,-70.666669)">
|
||||
<Path
|
||||
fill={color}
|
||||
d="m 177.81311,248.33334 c 23.82304,-41.29793 40.54045,-66.84626 49.51207,-75.66667 6.81685,-6.70196 10.07373,-8.7374 20.07265,-12.54475 34.57822,-13.16655 61.04674,-26.78733 72.37222,-37.24295 9.62924,-8.88966 9.34286,-9.01142 -23.43671,-9.964 -35.71756,-1.03796 -43.72989,0.42119 -62.17546,11.323 -16.72118,9.88265 -34.20103,30.11225 -42.74704,49.47157 -2.57353,5.82985 -14.81294,44.3056 -27.96399,87.90747 -2.86036,9.48343 -3.02466,11.71633 -0.86213,11.71633 0.44382,0 7.29659,-11.25 15.22839,-25 z m -65.14644,-8.32267 C 120,239.3326 130.5,237.50979 136,235.95998 c 5.5,-1.5498 12.25,-3.13783 15,-3.52895 2.75,-0.39111 5,-0.95485 5,-1.25275 0,-0.29789 2.15135,-7.58487 4.78078,-16.19328 8.49209,-27.80201 12.21334,-40.41629 21.13747,-71.65166 4.81891,-16.86667 11.23502,-39.185 14.25802,-49.596301 5.12803,-17.66103 5.74763,-23.07037 2.64253,-23.07037 -1.84887,0 -4.07048,6.908293 -16.72243,52.000001 -21.78975,77.65896 -20.80806,74.74393 -26.84794,79.72251 -7.5925,6.25838 -25.03916,14.82524 -36.10856,17.73044 -17.0947,4.48656 -33.410599,3.86724 -53.116765,-2.01622 -18.569242,-5.54403 -23.142662,-5.80284 -33.639754,-1.9037 -5.875424,2.18242 -9.864152,5.04363 -16.716684,11.99127 -4.95,5.0187 -9.0000001,10.02884 -9.0000001,11.13364 0,1.75174 5.9276921,2.00299 46.3333351,1.96383 25.483334,-0.0247 52.333338,-0.59969 59.666668,-1.27777 z M 252.69513,104.63708 c 12.18267,-3.48651 15.77304,-7.895503 9.63821,-11.835773 -10.19296,-6.546726 -36.19849,-1.77301 -41.19436,7.561863 -1.2556,2.3461 -0.98698,3.2037 1.68353,5.375 2.69471,2.19098 4.59991,2.47691 12.53928,1.88189 5.14899,-0.3859 12.94899,-1.72824 17.33334,-2.98298 z"
|
||||
/>
|
||||
</G>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native'
|
||||
import { Check } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
|
||||
export type PickerOption<T extends string = string> = {
|
||||
value: T
|
||||
label: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
type Props<T extends string = string> = {
|
||||
visible: boolean
|
||||
title: string
|
||||
options: PickerOption<T>[]
|
||||
selected: T
|
||||
onSelect: (value: T) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function PickerModal<T extends string = string>({
|
||||
visible,
|
||||
title,
|
||||
options,
|
||||
selected,
|
||||
onSelect,
|
||||
onClose
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.group}>
|
||||
{options.map((opt, i) => {
|
||||
const isSelected = opt.value === selected
|
||||
return (
|
||||
<View key={opt.value}>
|
||||
{i > 0 && <View style={styles.separator} />}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => {
|
||||
onSelect(opt.value)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={[styles.rowLabel, isSelected && styles.rowLabelSelected]}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
{opt.subtitle ? <Text style={styles.rowSubtitle}>{opt.subtitle}</Text> : null}
|
||||
</View>
|
||||
{isSelected && <Check size={16} color={colors.textPrimary} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingBottom: spacing.sm
|
||||
},
|
||||
title: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textMuted
|
||||
},
|
||||
group: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
rowLabelSelected: {
|
||||
fontWeight: '600'
|
||||
},
|
||||
rowSubtitle: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginTop: 1
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
|
||||
const stateColors: Record<ConnectionState, string> = {
|
||||
connected: colors.statusGreen,
|
||||
connecting: colors.statusAmber,
|
||||
handshaking: colors.statusAmber,
|
||||
reconnecting: colors.statusAmber,
|
||||
disconnected: colors.statusRed,
|
||||
'auth-failed': colors.statusRed
|
||||
}
|
||||
|
||||
export function StatusDot({ state }: { state: ConnectionState }) {
|
||||
return <View style={[styles.dot, { backgroundColor: stateColors[state] ?? colors.textMuted }]} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginRight: 8
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { View, Text, TextInput, Pressable, StyleSheet, Platform } from 'react-native'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
title: string
|
||||
message?: string
|
||||
defaultValue?: string
|
||||
placeholder?: string
|
||||
onSubmit: (value: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function TextInputModal({
|
||||
visible,
|
||||
title,
|
||||
message,
|
||||
defaultValue = '',
|
||||
placeholder,
|
||||
onSubmit,
|
||||
onCancel
|
||||
}: Props) {
|
||||
const [value, setValue] = useState(defaultValue)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) setValue(defaultValue)
|
||||
}, [visible, defaultValue])
|
||||
|
||||
function handleSubmit() {
|
||||
if (value.trim()) {
|
||||
onSubmit(value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onCancel}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.group}>
|
||||
<View style={styles.inputWrap}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmit}
|
||||
selectionColor={colors.accentBlue}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.cancelButton, pressed && styles.buttonPressed]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.submitButton,
|
||||
pressed && styles.buttonPressed,
|
||||
!value.trim() && styles.submitButtonDisabled
|
||||
]}
|
||||
disabled={!value.trim()}
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
<Text style={styles.submitText}>Save</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingBottom: spacing.sm
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
message: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
marginTop: 2
|
||||
},
|
||||
group: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
inputWrap: {
|
||||
padding: spacing.md
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.bgBase,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
|
||||
fontSize: typography.bodySize,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.md
|
||||
},
|
||||
cancelButton: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: colors.accentBlue,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
buttonPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
opacity: 0.4
|
||||
},
|
||||
cancelText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
},
|
||||
submitText: {
|
||||
color: '#fff',
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { Platform } from 'react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
|
||||
type NotificationEvent = {
|
||||
type: 'notification'
|
||||
source: 'agent-task-complete' | 'terminal-bell' | 'test'
|
||||
title: string
|
||||
body: string
|
||||
worktreeId?: string
|
||||
}
|
||||
|
||||
type SubscribeResult = {
|
||||
type: 'ready'
|
||||
subscriptionId: string
|
||||
}
|
||||
|
||||
let permissionGranted: boolean | null = null
|
||||
|
||||
// Why: permissions must be requested before scheduling any local notification.
|
||||
// Cache the result so we only prompt once per app session.
|
||||
export async function ensureNotificationPermissions(): Promise<boolean> {
|
||||
if (permissionGranted !== null) return permissionGranted
|
||||
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync()
|
||||
if (existingStatus === 'granted') {
|
||||
permissionGranted = true
|
||||
return true
|
||||
}
|
||||
|
||||
const { status } = await Notifications.requestPermissionsAsync()
|
||||
permissionGranted = status === 'granted'
|
||||
return permissionGranted
|
||||
}
|
||||
|
||||
function configureNotificationChannel(): void {
|
||||
if (Platform.OS === 'android') {
|
||||
void Notifications.setNotificationChannelAsync('orca-desktop', {
|
||||
name: 'Desktop Notifications',
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
vibrationPattern: [0, 250],
|
||||
lightColor: '#6366f1'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function showLocalNotification(event: NotificationEvent): Promise<void> {
|
||||
const enabled = await loadPushNotificationsEnabled()
|
||||
if (!enabled) return
|
||||
|
||||
const granted = await ensureNotificationPermissions()
|
||||
if (!granted) return
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: event.title,
|
||||
body: event.body,
|
||||
data: { source: event.source, worktreeId: event.worktreeId },
|
||||
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
|
||||
},
|
||||
trigger: null
|
||||
})
|
||||
}
|
||||
|
||||
// Why: each host connection gets its own notification subscription. When the
|
||||
// connection drops, the unsubscribe function cleans up the streaming RPC.
|
||||
// Returns an unsubscribe function.
|
||||
export function subscribeToDesktopNotifications(client: RpcClient): () => void {
|
||||
configureNotificationChannel()
|
||||
|
||||
let subscriptionId: string | null = null
|
||||
let disposed = false
|
||||
function unsubscribeServer(id: string) {
|
||||
if (client.getState() === 'connected') {
|
||||
client.sendRequest('notifications.unsubscribe', { subscriptionId: id }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => {
|
||||
const event = data as NotificationEvent | SubscribeResult | { type: 'end' }
|
||||
if (event.type === 'ready') {
|
||||
subscriptionId = (event as SubscribeResult).subscriptionId
|
||||
if (disposed) {
|
||||
unsubscribeServer(subscriptionId)
|
||||
unsubscribeStream()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'end') {
|
||||
if (disposed) unsubscribeStream()
|
||||
return
|
||||
}
|
||||
if (disposed) return
|
||||
if (event.type === 'notification') {
|
||||
void showLocalNotification(event as NotificationEvent)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
// Why: the client may already be closed when this cleanup runs (component
|
||||
// unmount races with disconnect). sendRequest rejects immediately on a
|
||||
// closed client — swallow it since server-side cleanup happens via
|
||||
// connection-close anyway.
|
||||
if (subscriptionId) {
|
||||
unsubscribeStream()
|
||||
unsubscribeServer(subscriptionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Platform } from 'react-native'
|
||||
import * as Haptics from 'expo-haptics'
|
||||
|
||||
export function triggerMediumImpact(): void {
|
||||
if (Platform.OS === 'android') {
|
||||
// Why: Android's Vibrator API (used by impactAsync) is unreliable for haptic
|
||||
// feedback. performAndroidHapticsAsync uses the native HapticFeedbackConstants
|
||||
// API which works without VIBRATE permission and feels more natural.
|
||||
void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Long_Press).catch(() => {})
|
||||
} else {
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
const PINS_PREFIX = 'orca:pins:'
|
||||
const PREFS_PREFIX = 'orca:prefs:'
|
||||
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
|
||||
|
||||
export async function loadPushNotificationsEnabled(): Promise<boolean> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(NOTIF_KEY)
|
||||
if (raw === null) return true
|
||||
return raw === 'true'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePushNotificationsEnabled(enabled: boolean): Promise<void> {
|
||||
await AsyncStorage.setItem(NOTIF_KEY, String(enabled))
|
||||
}
|
||||
|
||||
export type HostPreferences = {
|
||||
sortMode: string
|
||||
filterMode: string
|
||||
groupMode: string
|
||||
collapsedGroups: string[]
|
||||
selectedRepos: string[]
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: HostPreferences = {
|
||||
sortMode: 'smart',
|
||||
filterMode: 'all',
|
||||
groupMode: 'none',
|
||||
collapsedGroups: [],
|
||||
selectedRepos: []
|
||||
}
|
||||
const SORT_MODES = new Set(['smart', 'recent', 'name'])
|
||||
const FILTER_MODES = new Set(['all', 'active'])
|
||||
const GROUP_MODES = new Set(['none', 'repo', 'prStatus'])
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: []
|
||||
}
|
||||
|
||||
function allowedString(value: unknown, allowed: Set<string>, fallback: string): string {
|
||||
return typeof value === 'string' && allowed.has(value) ? value : fallback
|
||||
}
|
||||
|
||||
export async function loadPinnedIds(hostId: string): Promise<Set<string>> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(PINS_PREFIX + hostId)
|
||||
if (!raw) return new Set()
|
||||
return new Set(stringArray(JSON.parse(raw)))
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePinnedIds(hostId: string, ids: Set<string>): Promise<void> {
|
||||
await AsyncStorage.setItem(PINS_PREFIX + hostId, JSON.stringify([...ids]))
|
||||
}
|
||||
|
||||
export async function loadPreferences(hostId: string): Promise<HostPreferences> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(PREFS_PREFIX + hostId)
|
||||
if (!raw) return DEFAULT_PREFS
|
||||
const parsed = JSON.parse(raw) as Partial<HostPreferences>
|
||||
return {
|
||||
sortMode: allowedString(parsed.sortMode, SORT_MODES, DEFAULT_PREFS.sortMode),
|
||||
filterMode: allowedString(parsed.filterMode, FILTER_MODES, DEFAULT_PREFS.filterMode),
|
||||
groupMode: allowedString(parsed.groupMode, GROUP_MODES, DEFAULT_PREFS.groupMode),
|
||||
collapsedGroups: stringArray(parsed.collapsedGroups),
|
||||
selectedRepos: stringArray(parsed.selectedRepos)
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_PREFS
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePreferences(
|
||||
hostId: string,
|
||||
prefs: Partial<HostPreferences>
|
||||
): Promise<void> {
|
||||
const current = await loadPreferences(hostId)
|
||||
const merged = { ...current, ...prefs }
|
||||
await AsyncStorage.setItem(PREFS_PREFIX + hostId, JSON.stringify(merged))
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
import { useRef, useCallback, forwardRef, useImperativeHandle } from 'react'
|
||||
import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native'
|
||||
import { WebView } from 'react-native-webview'
|
||||
import type { WebViewMessageEvent } from 'react-native-webview'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
export type TerminalWebViewHandle = {
|
||||
write: (data: string) => void
|
||||
init: (cols: number, rows: number, initialData?: string) => void
|
||||
clear: () => void
|
||||
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
|
||||
resetZoom: () => void
|
||||
}
|
||||
|
||||
type Props = {
|
||||
style?: StyleProp<ViewStyle>
|
||||
onWebReady?: () => void
|
||||
}
|
||||
|
||||
type TerminalMessage =
|
||||
| { type: 'write'; id?: number; data: string }
|
||||
| { type: 'init'; id?: number; cols: number; rows: number; initialData?: string }
|
||||
| { type: 'clear'; id?: number }
|
||||
| { type: 'measure'; id?: number; containerHeight?: number }
|
||||
| { type: 'reset-zoom'; id?: number }
|
||||
|
||||
// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor
|
||||
// positioning designed for the desktop's terminal dimensions (~150+ cols).
|
||||
// We initialize xterm at the desktop's exact cols/rows so those escape codes
|
||||
// render correctly, then use a measured CSS transform: scale() to fit the
|
||||
// canvas into the phone viewport. The scale is computed after xterm opens
|
||||
// by measuring the rendered surface width, not hardcoded, so it adapts to
|
||||
// any terminal column count (80, 150, 200+). All touch gestures (scroll,
|
||||
// pinch-to-zoom, pan) are handled by custom JS rather than native WebView
|
||||
// behavior, so they work correctly with the CSS scale transform.
|
||||
const XTERM_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.198/css/xterm.min.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
background: ${colors.terminalBg};
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#terminal-container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#terminal-surface {
|
||||
transform-origin: top left;
|
||||
display: inline-block;
|
||||
}
|
||||
.xterm { -webkit-user-select: none; user-select: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal-container">
|
||||
<div id="terminal-surface"></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.198/lib/xterm.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var surface = document.getElementById('terminal-surface');
|
||||
var ESC = String.fromCharCode(27);
|
||||
var term = null;
|
||||
var writeQueue = [];
|
||||
var writesDraining = false;
|
||||
var afterDrainCallbacks = [];
|
||||
var ready = false;
|
||||
var currentScale = 1;
|
||||
var userScale = 1;
|
||||
var panX = 0;
|
||||
var panY = 0;
|
||||
var initRows = 24;
|
||||
var terminalGeneration = 0;
|
||||
var activeAltScreenSnapshot = false;
|
||||
var handledMessageIds = [];
|
||||
|
||||
function computeFitScale() {
|
||||
if (!term) return 1;
|
||||
var el = term.element;
|
||||
if (!el) return 1;
|
||||
var termWidth = el.scrollWidth;
|
||||
var vpWidth = window.innerWidth;
|
||||
if (termWidth <= 0) return 1;
|
||||
return Math.min(1, vpWidth / termWidth);
|
||||
}
|
||||
|
||||
function getTotalScale() { return currentScale * userScale; }
|
||||
|
||||
function updateTransform() {
|
||||
surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')';
|
||||
}
|
||||
|
||||
function getCellHeight() {
|
||||
if (!term || !term._core) return 15;
|
||||
var core = term._core;
|
||||
if (core._renderService && core._renderService.dimensions) {
|
||||
return core._renderService.dimensions.css.cell.height || 15;
|
||||
}
|
||||
return 15;
|
||||
}
|
||||
|
||||
// Why: clamp pan so the terminal content always covers the viewport
|
||||
// when zoomed in. When content is smaller than viewport in a
|
||||
// dimension, pin to top-left (no floating in the middle).
|
||||
function clampPan() {
|
||||
if (!term || !term.element) return;
|
||||
var ts = getTotalScale();
|
||||
var cw = term.element.scrollWidth * ts;
|
||||
var ch = term.element.scrollHeight * ts;
|
||||
var vpW = window.innerWidth;
|
||||
var vpH = window.innerHeight;
|
||||
if (cw > vpW) {
|
||||
panX = Math.min(0, Math.max(vpW - cw, panX));
|
||||
} else {
|
||||
panX = 0;
|
||||
}
|
||||
if (ch > vpH) {
|
||||
panY = Math.min(0, Math.max(vpH - ch, panY));
|
||||
} else {
|
||||
panY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the desktop terminal may have fewer rows than needed to fill
|
||||
// the phone's WebView at the current scale (e.g. 40 desktop rows
|
||||
// scaled to 0.3x only covers ~40% of the viewport). Resize xterm's
|
||||
// viewport to fill the available height so there's no blank gap
|
||||
// below the last terminal line. This is display-only — the PTY is
|
||||
// not resized — so the extra rows just show empty terminal background
|
||||
// managed by xterm, not a separate HTML gap. Never shrink below the
|
||||
// original init row count to avoid clipping active terminal content.
|
||||
function adjustRowsForViewport() {
|
||||
// Why: mobile replays a live PTY snapshot and then applies live cursor-
|
||||
// relative chunks from that same PTY. Resizing only the WebView xterm
|
||||
// changes cursor coordinates and makes TUI repaint chunks duplicate or
|
||||
// overlap existing frames. Keep xterm rows identical to the PTY.
|
||||
return;
|
||||
if (!term || !term.element) return;
|
||||
// Why: active alternate-screen TUIs (Claude Code, vim, etc.) are exact
|
||||
// screen snapshots. Locally resizing the mobile xterm after replay can
|
||||
// mutate the alt buffer and drop cell attributes, which shows as white text.
|
||||
if (activeAltScreenSnapshot) return;
|
||||
var cellHeight = getCellHeight();
|
||||
if (cellHeight > 0 && currentScale > 0) {
|
||||
var vpHeight = window.innerHeight;
|
||||
var neededRows = Math.floor(vpHeight / (cellHeight * currentScale));
|
||||
if (neededRows >= initRows && neededRows !== term.rows) {
|
||||
term.resize(term.cols, neededRows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyFitScale() {
|
||||
if (!term || !term.element) return;
|
||||
currentScale = computeFitScale();
|
||||
// Why: when the scale is very close to 1 (e.g. 0.97 due to xterm
|
||||
// scrollbar width), snap to 1.0 to avoid sub-pixel shrinkage.
|
||||
if (currentScale >= 0.95) currentScale = 1;
|
||||
userScale = 1;
|
||||
panX = 0;
|
||||
panY = 0;
|
||||
updateTransform();
|
||||
adjustRowsForViewport();
|
||||
}
|
||||
|
||||
function isAltScreenActive(data) {
|
||||
if (typeof data !== 'string') return false;
|
||||
var on = data.lastIndexOf(ESC + '[?1049h');
|
||||
var off = data.lastIndexOf(ESC + '[?1049l');
|
||||
return on !== -1 && on > off;
|
||||
}
|
||||
|
||||
function normalizeInitialData(data) {
|
||||
if (!isAltScreenActive(data)) return data;
|
||||
var on = data.lastIndexOf(ESC + '[?1049h');
|
||||
// Why: SerializeAddon can include normal-buffer scrollback before the
|
||||
// active alternate-screen snapshot. Replaying both into a fresh mobile
|
||||
// xterm duplicates TUI frames and can flatten SGR attributes.
|
||||
return on > 0 ? data.slice(on) : data;
|
||||
}
|
||||
|
||||
function pumpWrites(gen) {
|
||||
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
|
||||
var next = writeQueue.shift();
|
||||
if (typeof next !== 'string') {
|
||||
var callbacks = afterDrainCallbacks;
|
||||
afterDrainCallbacks = [];
|
||||
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
|
||||
return;
|
||||
}
|
||||
writesDraining = true;
|
||||
// Why: xterm.write() parses asynchronously. Row adjustment/resizing must
|
||||
// wait until replayed SGR attributes have landed in the buffer.
|
||||
term.write(next, function() {
|
||||
if (gen !== terminalGeneration) return;
|
||||
writesDraining = false;
|
||||
pumpWrites(gen);
|
||||
});
|
||||
}
|
||||
|
||||
function afterWritesDrained(callback) {
|
||||
afterDrainCallbacks.push(callback);
|
||||
pumpWrites(terminalGeneration);
|
||||
}
|
||||
|
||||
function init(cols, rows, initialData) {
|
||||
terminalGeneration++;
|
||||
var gen = terminalGeneration;
|
||||
ready = false;
|
||||
writeQueue = [];
|
||||
writesDraining = false;
|
||||
afterDrainCallbacks = [];
|
||||
initRows = rows || 24;
|
||||
var replayData = normalizeInitialData(initialData);
|
||||
activeAltScreenSnapshot = isAltScreenActive(replayData);
|
||||
if (term) term.dispose();
|
||||
|
||||
term = new Terminal({
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
theme: {
|
||||
background: '${colors.terminalBg}',
|
||||
foreground: '#c0caf5',
|
||||
cursor: '#c0caf5',
|
||||
cursorAccent: '${colors.terminalBg}',
|
||||
selectionBackground: '#33467c',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5'
|
||||
},
|
||||
fontFamily: '"Menlo", "Consolas", "DejaVu Sans Mono", monospace',
|
||||
fontSize: 13,
|
||||
scrollback: 5000,
|
||||
disableStdin: true,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'bar',
|
||||
cursorInactiveStyle: 'none',
|
||||
convertEol: false,
|
||||
allowProposedApi: true
|
||||
});
|
||||
term.open(surface);
|
||||
if (typeof replayData === 'string' && replayData.length > 0) {
|
||||
writeQueue.push(replayData);
|
||||
}
|
||||
|
||||
requestAnimationFrame(function() {
|
||||
if (gen !== terminalGeneration) return;
|
||||
ready = true;
|
||||
afterWritesDrained(function() {
|
||||
if (gen !== terminalGeneration) return;
|
||||
applyFitScale();
|
||||
notify({ type: 'ready', cols: cols, rows: rows });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function write(data) {
|
||||
writeQueue.push(data);
|
||||
pumpWrites(terminalGeneration);
|
||||
}
|
||||
|
||||
function notify(msg) {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
function measureFitDimensions(containerHeightPx) {
|
||||
if (!term || !term.element) {
|
||||
notify({ type: 'measure-result', cols: null, rows: null });
|
||||
return;
|
||||
}
|
||||
// Why: measure actual xterm cell dimensions from the renderer, not from
|
||||
// font metrics alone. This accounts for the exact font, size, and line
|
||||
// height that xterm is using.
|
||||
var core = term._core;
|
||||
var cellWidth = 0;
|
||||
var cellHeight = 0;
|
||||
if (core && core._renderService && core._renderService.dimensions) {
|
||||
cellWidth = core._renderService.dimensions.css.cell.width;
|
||||
cellHeight = core._renderService.dimensions.css.cell.height;
|
||||
}
|
||||
if (cellWidth <= 0 || cellHeight <= 0) {
|
||||
notify({ type: 'measure-result', cols: null, rows: null });
|
||||
return;
|
||||
}
|
||||
var vpWidth = window.innerWidth;
|
||||
// Why: prefer the container height passed from React Native over
|
||||
// window.innerHeight. The RN layout system knows the exact pixel
|
||||
// height of the terminal frame after the accessory/input bars are
|
||||
// subtracted, whereas innerHeight can overstate the visible area
|
||||
// due to layout timing or safe-area insets.
|
||||
var vpHeight = (typeof containerHeightPx === 'number' && containerHeightPx > 0)
|
||||
? containerHeightPx
|
||||
: window.innerHeight;
|
||||
var cols = Math.floor(vpWidth / cellWidth);
|
||||
// Why: subtract 2 rows after dividing. The WebView's reported height
|
||||
// can slightly overstate the usable area (layout timing, subpixel
|
||||
// rounding, safe-area insets). Subtracting 2 guarantees the last
|
||||
// visible row plus the shell prompt (which often wraps on narrow
|
||||
// screens) stays fully above the accessory bar.
|
||||
var rows = Math.max(8, Math.floor(vpHeight / cellHeight) - 2);
|
||||
notify({ type: 'measure-result', cols: cols, rows: rows });
|
||||
}
|
||||
|
||||
function handleMsg(msg) {
|
||||
if (typeof msg.id === 'number') {
|
||||
if (handledMessageIds.indexOf(msg.id) !== -1) return;
|
||||
handledMessageIds.push(msg.id);
|
||||
if (handledMessageIds.length > 256) handledMessageIds.shift();
|
||||
}
|
||||
if (msg.type === 'init') {
|
||||
init(msg.cols, msg.rows, msg.initialData);
|
||||
} else if (msg.type === 'write') {
|
||||
write(msg.data);
|
||||
} else if (msg.type === 'clear') {
|
||||
terminalGeneration++;
|
||||
writeQueue = [];
|
||||
afterDrainCallbacks = [];
|
||||
writesDraining = false;
|
||||
if (term) { term.clear(); term.reset(); }
|
||||
} else if (msg.type === 'measure') {
|
||||
measureFitDimensions(msg.containerHeight);
|
||||
} else if (msg.type === 'reset-zoom') {
|
||||
applyFitScale();
|
||||
}
|
||||
}
|
||||
|
||||
// Why: event listeners are registered once here (not inside init()) so
|
||||
// they don't accumulate on re-init. They close over the mutable 'term'
|
||||
// variable, so they always reference the current terminal instance.
|
||||
surface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
|
||||
surface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
|
||||
|
||||
var ts = {
|
||||
lastX: 0, lastY: 0, lastTime: 0, velY: 0,
|
||||
accumDelta: 0, momentumId: null, isPinching: false,
|
||||
pinchDist: 0, pinchScale: 0, pinchSurfX: 0, pinchSurfY: 0
|
||||
};
|
||||
|
||||
function getDistance(a, b) {
|
||||
var dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
surface.addEventListener('touchstart', function(e) {
|
||||
if (ts.momentumId) {
|
||||
cancelAnimationFrame(ts.momentumId);
|
||||
ts.momentumId = null;
|
||||
}
|
||||
if (e.touches.length === 2) {
|
||||
ts.isPinching = true;
|
||||
ts.pinchDist = getDistance(e.touches[0], e.touches[1]);
|
||||
ts.pinchScale = userScale;
|
||||
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
||||
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
||||
var total = getTotalScale();
|
||||
ts.pinchSurfX = (mx - panX) / total;
|
||||
ts.pinchSurfY = (my - panY) / total;
|
||||
} else if (e.touches.length === 1) {
|
||||
ts.isPinching = false;
|
||||
ts.lastX = e.touches[0].clientX;
|
||||
ts.lastY = e.touches[0].clientY;
|
||||
ts.lastTime = Date.now();
|
||||
ts.velY = 0;
|
||||
ts.accumDelta = 0;
|
||||
}
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
surface.addEventListener('touchmove', function(e) {
|
||||
if (!term) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
ts.isPinching = true;
|
||||
var dist = getDistance(e.touches[0], e.touches[1]);
|
||||
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
||||
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
||||
|
||||
var ratio = dist / ts.pinchDist;
|
||||
userScale = Math.max(1, Math.min(5, ts.pinchScale * ratio));
|
||||
|
||||
var total = getTotalScale();
|
||||
panX = mx - ts.pinchSurfX * total;
|
||||
panY = my - ts.pinchSurfY * total;
|
||||
clampPan();
|
||||
updateTransform();
|
||||
|
||||
} else if (e.touches.length === 1 && !ts.isPinching) {
|
||||
var x = e.touches[0].clientX;
|
||||
var y = e.touches[0].clientY;
|
||||
var now = Date.now();
|
||||
var dt = now - ts.lastTime;
|
||||
|
||||
if (userScale > 1.05) {
|
||||
panX += x - ts.lastX;
|
||||
panY += y - ts.lastY;
|
||||
clampPan();
|
||||
updateTransform();
|
||||
} else {
|
||||
var deltaY = ts.lastY - y;
|
||||
if (dt > 0) ts.velY = deltaY / dt;
|
||||
ts.lastTime = now;
|
||||
var effectiveCellH = getCellHeight() * currentScale;
|
||||
ts.accumDelta += deltaY;
|
||||
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
|
||||
if (lines !== 0) {
|
||||
ts.accumDelta -= lines * effectiveCellH;
|
||||
term.scrollLines(lines);
|
||||
}
|
||||
}
|
||||
ts.lastX = x;
|
||||
ts.lastY = y;
|
||||
}
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
surface.addEventListener('touchend', function(e) {
|
||||
if (!term) return;
|
||||
|
||||
if (ts.isPinching && e.touches.length < 2) {
|
||||
ts.isPinching = false;
|
||||
if (userScale < 1.15) {
|
||||
userScale = 1; panX = 0; panY = 0;
|
||||
updateTransform();
|
||||
}
|
||||
if (e.touches.length === 1) {
|
||||
ts.lastX = e.touches[0].clientX;
|
||||
ts.lastY = e.touches[0].clientY;
|
||||
ts.lastTime = Date.now();
|
||||
ts.velY = 0;
|
||||
ts.accumDelta = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.touches.length === 0 && userScale <= 1.05) {
|
||||
var vel = ts.velY;
|
||||
var FRICTION = 0.95;
|
||||
var MIN_VEL = 0.02;
|
||||
function momentumStep() {
|
||||
vel *= FRICTION;
|
||||
if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; }
|
||||
var effectiveCellH = getCellHeight() * currentScale;
|
||||
ts.accumDelta += vel * 16;
|
||||
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
|
||||
if (lines !== 0) {
|
||||
ts.accumDelta -= lines * effectiveCellH;
|
||||
term.scrollLines(lines);
|
||||
}
|
||||
ts.momentumId = requestAnimationFrame(momentumStep);
|
||||
}
|
||||
if (Math.abs(vel) > MIN_VEL) {
|
||||
ts.momentumId = requestAnimationFrame(momentumStep);
|
||||
}
|
||||
}
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
window.addEventListener('message', function(e) {
|
||||
try {
|
||||
handleMsg(typeof e.data === 'string' ? JSON.parse(e.data) : e.data);
|
||||
} catch(ex) {}
|
||||
});
|
||||
|
||||
document.addEventListener('message', function(e) {
|
||||
try {
|
||||
handleMsg(typeof e.data === 'string' ? JSON.parse(e.data) : e.data);
|
||||
} catch(ex) {}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', function() {
|
||||
adjustRowsForViewport();
|
||||
clampPan();
|
||||
updateTransform();
|
||||
});
|
||||
|
||||
if (window.Terminal) {
|
||||
notify({ type: 'web-ready' });
|
||||
} else {
|
||||
notify({ type: 'error', message: 'xterm failed to load' });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
|
||||
{ style, onWebReady },
|
||||
ref
|
||||
) {
|
||||
const webViewRef = useRef<WebView>(null)
|
||||
const isWebReadyRef = useRef(false)
|
||||
const pendingMessagesRef = useRef<TerminalMessage[]>([])
|
||||
const messageIdRef = useRef(0)
|
||||
const measureResolveRef = useRef<
|
||||
((result: { cols: number; rows: number } | null) => void) | null
|
||||
>(null)
|
||||
|
||||
const sendToWebView = useCallback((msg: TerminalMessage) => {
|
||||
messageIdRef.current += 1
|
||||
webViewRef.current?.postMessage(JSON.stringify({ ...msg, id: messageIdRef.current }))
|
||||
}, [])
|
||||
|
||||
const flushPendingMessages = useCallback(() => {
|
||||
const pending = pendingMessagesRef.current
|
||||
pendingMessagesRef.current = []
|
||||
for (const msg of pending) {
|
||||
sendToWebView(msg)
|
||||
}
|
||||
}, [sendToWebView])
|
||||
|
||||
const postMessage = useCallback(
|
||||
(msg: TerminalMessage) => {
|
||||
if (!isWebReadyRef.current) {
|
||||
pendingMessagesRef.current.push(msg)
|
||||
return
|
||||
}
|
||||
sendToWebView(msg)
|
||||
},
|
||||
[sendToWebView]
|
||||
)
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: WebViewMessageEvent) => {
|
||||
let msg: Record<string, unknown>
|
||||
try {
|
||||
msg = JSON.parse(event.nativeEvent.data) as Record<string, unknown>
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'web-ready') {
|
||||
isWebReadyRef.current = true
|
||||
onWebReady?.()
|
||||
flushPendingMessages()
|
||||
} else if (msg.type === 'measure-result') {
|
||||
const resolve = measureResolveRef.current
|
||||
measureResolveRef.current = null
|
||||
if (resolve) {
|
||||
const cols = typeof msg.cols === 'number' ? msg.cols : null
|
||||
const rows = typeof msg.rows === 'number' ? msg.rows : null
|
||||
resolve(cols && rows && cols >= 20 && rows >= 8 ? { cols, rows } : null)
|
||||
}
|
||||
}
|
||||
},
|
||||
[flushPendingMessages, onWebReady]
|
||||
)
|
||||
|
||||
const handleLoadStart = useCallback(() => {
|
||||
isWebReadyRef.current = false
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
write(data: string) {
|
||||
postMessage({ type: 'write', data })
|
||||
},
|
||||
init(cols: number, rows: number, initialData?: string) {
|
||||
postMessage({ type: 'init', cols, rows, initialData })
|
||||
},
|
||||
clear() {
|
||||
postMessage({ type: 'clear' })
|
||||
},
|
||||
measureFitDimensions(
|
||||
containerHeight?: number
|
||||
): Promise<{ cols: number; rows: number } | null> {
|
||||
if (!isWebReadyRef.current) return Promise.resolve(null)
|
||||
return new Promise((resolve) => {
|
||||
measureResolveRef.current?.(null)
|
||||
measureResolveRef.current = resolve
|
||||
sendToWebView({ type: 'measure', containerHeight })
|
||||
// Why: if the WebView doesn't respond within 2s (e.g., xterm
|
||||
// failed to load), resolve null so the caller can disable
|
||||
// Fit to Phone rather than hanging indefinitely.
|
||||
setTimeout(() => {
|
||||
if (measureResolveRef.current === resolve) {
|
||||
measureResolveRef.current = null
|
||||
resolve(null)
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
},
|
||||
resetZoom() {
|
||||
postMessage({ type: 'reset-zoom' })
|
||||
}
|
||||
}),
|
||||
[postMessage, sendToWebView]
|
||||
)
|
||||
|
||||
return (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ html: XTERM_HTML }}
|
||||
style={[styles.webview, style]}
|
||||
originWhitelist={['*']}
|
||||
javaScriptEnabled
|
||||
scrollEnabled={true}
|
||||
scalesPageToFit={false}
|
||||
onLoadStart={handleLoadStart}
|
||||
onMessage={handleMessage}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
webview: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.terminalBg
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
// Orca mobile design tokens — matches desktop graphite/dark palette.
|
||||
// All screen files should import from here instead of using inline hex values.
|
||||
|
||||
export const colors = {
|
||||
bgBase: '#111111',
|
||||
bgPanel: '#1a1a1a',
|
||||
bgRaised: '#242424',
|
||||
borderSubtle: '#2a2a2a',
|
||||
|
||||
textPrimary: '#e0e0e0',
|
||||
textSecondary: '#888888',
|
||||
textMuted: '#555555',
|
||||
|
||||
accentBlue: '#3b82f6',
|
||||
|
||||
statusGreen: '#22c55e',
|
||||
statusAmber: '#f59e0b',
|
||||
statusRed: '#ef4444',
|
||||
|
||||
// Terminal WebView background (Tokyonight) — separate from app chrome
|
||||
terminalBg: '#1a1b26'
|
||||
} as const
|
||||
|
||||
export const spacing = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 24
|
||||
} as const
|
||||
|
||||
export const radii = {
|
||||
row: 6,
|
||||
card: 14,
|
||||
button: 6,
|
||||
input: 6,
|
||||
camera: 8
|
||||
} as const
|
||||
|
||||
export const typography = {
|
||||
titleSize: 18,
|
||||
bodySize: 14,
|
||||
metaSize: 12,
|
||||
monoFamily: 'monospace' as const
|
||||
} as const
|
||||
@@ -0,0 +1,91 @@
|
||||
// Why: E2EE primitives for the mobile side. Uses tweetnacl for Curve25519 ECDH
|
||||
// key exchange and XSalsa20-Poly1305 authenticated encryption. Wire format is
|
||||
// base64([24-byte nonce][ciphertext]) over WebSocket text frames.
|
||||
import nacl from 'tweetnacl'
|
||||
import * as ExpoCrypto from 'expo-crypto'
|
||||
|
||||
// Why: Hermes (React Native's JS engine) lacks crypto.getRandomValues,
|
||||
// which tweetnacl requires. expo-crypto provides a native secure RNG
|
||||
// that works in Expo Go without a custom dev build.
|
||||
nacl.setPRNG((_x: Uint8Array, n: number) => {
|
||||
const bytes = ExpoCrypto.getRandomBytes(n)
|
||||
_x.set(bytes)
|
||||
})
|
||||
|
||||
// Why: tweetnacl uses `instanceof Uint8Array` checks internally. On Hermes,
|
||||
// values from native modules (expo-crypto), TextEncoder, or typed array
|
||||
// operations can return objects that fail this check despite being
|
||||
// functionally identical. Wrapping with `new Uint8Array(x)` guarantees
|
||||
// the correct prototype chain.
|
||||
function u8(x: Uint8Array): Uint8Array {
|
||||
return new Uint8Array(x)
|
||||
}
|
||||
|
||||
export function generateKeyPair(): { publicKey: Uint8Array; secretKey: Uint8Array } {
|
||||
const kp = nacl.box.keyPair()
|
||||
return { publicKey: u8(kp.publicKey), secretKey: u8(kp.secretKey) }
|
||||
}
|
||||
|
||||
export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array {
|
||||
return u8(nacl.box.before(u8(peerPublicKey), u8(ourSecretKey)))
|
||||
}
|
||||
|
||||
function uint8ToBase64(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]!)
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function base64ToUint8(b64: string): Uint8Array {
|
||||
const binary = atob(b64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export function publicKeyFromBase64(b64: string): Uint8Array {
|
||||
const key = base64ToUint8(b64)
|
||||
if (key.length !== 32) {
|
||||
throw new Error(
|
||||
`Invalid public key: expected 32 bytes, got ${key.length} from "${b64.slice(0, 20)}..."`
|
||||
)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
export function publicKeyToBase64(key: Uint8Array): string {
|
||||
return uint8ToBase64(key)
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string, sharedKey: Uint8Array): string {
|
||||
const nonce = u8(nacl.randomBytes(nacl.box.nonceLength))
|
||||
const messageBytes = u8(new TextEncoder().encode(plaintext))
|
||||
const ciphertext = nacl.box.after(messageBytes, nonce, u8(sharedKey))
|
||||
|
||||
const bundle = new Uint8Array(nonce.length + ciphertext.length)
|
||||
bundle.set(nonce)
|
||||
bundle.set(ciphertext, nonce.length)
|
||||
|
||||
return uint8ToBase64(bundle)
|
||||
}
|
||||
|
||||
export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null {
|
||||
const bundle = base64ToUint8(encrypted)
|
||||
if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nonce = u8(bundle.subarray(0, nacl.box.nonceLength))
|
||||
const ciphertext = u8(bundle.subarray(nacl.box.nonceLength))
|
||||
const plaintext = nacl.box.open.after(ciphertext, nonce, u8(sharedKey))
|
||||
|
||||
if (!plaintext) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(plaintext)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { HostProfileSchema, type HostProfile } from './types'
|
||||
|
||||
const STORAGE_KEY = 'orca:hosts'
|
||||
|
||||
export async function loadHosts(): Promise<HostProfile[]> {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.flatMap((item) => {
|
||||
const result = HostProfileSchema.safeParse(item)
|
||||
return result.success ? [result.data] : []
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHost(host: HostProfile): Promise<void> {
|
||||
const hosts = await loadHosts()
|
||||
const index = hosts.findIndex((h) => h.id === host.id)
|
||||
if (index >= 0) {
|
||||
hosts[index] = host
|
||||
} else {
|
||||
hosts.push(host)
|
||||
}
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
|
||||
}
|
||||
|
||||
export async function removeHost(hostId: string): Promise<void> {
|
||||
const hosts = await loadHosts()
|
||||
const filtered = hosts.filter((h) => h.id !== hostId)
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
|
||||
}
|
||||
|
||||
export async function renameHost(hostId: string, newName: string): Promise<void> {
|
||||
const hosts = await loadHosts()
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (host) {
|
||||
host.name = newName
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNextHostName(): Promise<string> {
|
||||
const hosts = await loadHosts()
|
||||
const existingNumbers = hosts
|
||||
.map((h) => {
|
||||
const match = h.name.match(/^Host (\d+)$/)
|
||||
return match ? parseInt(match[1]!, 10) : 0
|
||||
})
|
||||
.filter((n) => n > 0)
|
||||
const next = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
|
||||
return `Host ${next}`
|
||||
}
|
||||
|
||||
export async function updateLastConnected(hostId: string): Promise<void> {
|
||||
const hosts = await loadHosts()
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (host) {
|
||||
host.lastConnected = Date.now()
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export { connect, type RpcClient } from './rpc-client'
|
||||
export {
|
||||
loadHosts,
|
||||
saveHost,
|
||||
removeHost,
|
||||
renameHost,
|
||||
getNextHostName,
|
||||
updateLastConnected
|
||||
} from './host-store'
|
||||
export type {
|
||||
RpcRequest,
|
||||
RpcResponse,
|
||||
RpcSuccess,
|
||||
RpcFailure,
|
||||
ConnectionState,
|
||||
HostProfile,
|
||||
PairingOffer
|
||||
} from './types'
|
||||
export { PairingOfferSchema, PAIRING_OFFER_VERSION } from './types'
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PairingOfferSchema, type PairingOffer } from './types'
|
||||
|
||||
export function decodePairingUrl(url: string): PairingOffer | null {
|
||||
try {
|
||||
const hashIndex = url.indexOf('#')
|
||||
if (!url.startsWith('orca://pair') || hashIndex === -1) return null
|
||||
|
||||
const base64url = url.slice(hashIndex + 1)
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const json = atob(base64)
|
||||
const parsed = JSON.parse(json)
|
||||
return PairingOfferSchema.parse(parsed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import type { RpcResponse, RpcSuccess, ConnectionState } from './types'
|
||||
import {
|
||||
generateKeyPair,
|
||||
deriveSharedKey,
|
||||
publicKeyFromBase64,
|
||||
publicKeyToBase64,
|
||||
encrypt,
|
||||
decrypt
|
||||
} from './e2ee'
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (response: RpcResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
type StreamingListener = (result: unknown) => void
|
||||
|
||||
type StreamRequest = {
|
||||
method: string
|
||||
params: unknown
|
||||
listener: StreamingListener
|
||||
}
|
||||
|
||||
export type RpcClient = {
|
||||
sendRequest: (method: string, params?: unknown) => Promise<RpcResponse>
|
||||
subscribe: (method: string, params: unknown, onData: StreamingListener) => () => void
|
||||
getState: () => ConnectionState
|
||||
onStateChange: (listener: (state: ConnectionState) => void) => () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const HANDSHAKE_TIMEOUT_MS = 5_000
|
||||
|
||||
export function connect(
|
||||
endpoint: string,
|
||||
deviceToken: string,
|
||||
serverPublicKeyB64: string,
|
||||
onStateChange?: (state: ConnectionState) => void
|
||||
): RpcClient {
|
||||
let ws: WebSocket | null = null
|
||||
let state: ConnectionState = 'disconnected'
|
||||
let requestCounter = 0
|
||||
let reconnectAttempt = 0
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let intentionallyClosed = false
|
||||
|
||||
// Why: fresh ephemeral keypair per connection provides forward secrecy.
|
||||
// The shared key is derived from our ephemeral secret + server's static public key.
|
||||
let sharedKey: Uint8Array | null = null
|
||||
const serverPublicKey = publicKeyFromBase64(serverPublicKeyB64)
|
||||
|
||||
const pending = new Map<string, PendingRequest>()
|
||||
const streamListeners = new Map<string, StreamRequest>()
|
||||
const stateListeners = new Set<(state: ConnectionState) => void>()
|
||||
const connectWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = []
|
||||
|
||||
if (onStateChange) {
|
||||
stateListeners.add(onStateChange)
|
||||
}
|
||||
|
||||
function setState(next: ConnectionState) {
|
||||
if (state === next) return
|
||||
state = next
|
||||
if (next === 'connected') {
|
||||
for (const w of connectWaiters.splice(0)) w.resolve()
|
||||
} else if (next === 'disconnected' || next === 'auth-failed') {
|
||||
const reason =
|
||||
next === 'auth-failed' ? 'Unauthorized — pairing may be revoked' : 'Connection closed'
|
||||
for (const w of connectWaiters.splice(0)) w.reject(new Error(reason))
|
||||
}
|
||||
for (const listener of stateListeners) {
|
||||
listener(next)
|
||||
}
|
||||
}
|
||||
|
||||
function waitForConnected(): Promise<void> {
|
||||
if (state === 'connected') return Promise.resolve()
|
||||
if (intentionallyClosed) return Promise.reject(new Error('Client closed'))
|
||||
return new Promise((resolve, reject) => {
|
||||
connectWaiters.push({ resolve, reject })
|
||||
})
|
||||
}
|
||||
|
||||
function nextId(): string {
|
||||
return `rpc-${++requestCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
function openConnection() {
|
||||
if (intentionallyClosed) return
|
||||
|
||||
setState('connecting')
|
||||
sharedKey = null
|
||||
|
||||
ws = new WebSocket(endpoint)
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempt = 0
|
||||
setState('handshaking')
|
||||
|
||||
// Why: generate a fresh ephemeral keypair for each connection.
|
||||
// This provides forward secrecy — compromising one session's key
|
||||
// doesn't compromise past or future sessions.
|
||||
const ephemeral = generateKeyPair()
|
||||
const hello = JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: publicKeyToBase64(ephemeral.publicKey)
|
||||
})
|
||||
ws?.send(hello)
|
||||
|
||||
sharedKey = deriveSharedKey(ephemeral.secretKey, serverPublicKey)
|
||||
|
||||
handshakeTimer = setTimeout(() => {
|
||||
handshakeTimer = null
|
||||
ws?.close()
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const raw = typeof event.data === 'string' ? event.data : String(event.data)
|
||||
|
||||
// Why: during handshaking, e2ee_ready is plaintext because it precedes
|
||||
// encrypted auth; e2ee_authenticated/e2ee_error are encrypted.
|
||||
if (state === 'handshaking') {
|
||||
try {
|
||||
const msg = JSON.parse(raw)
|
||||
if (msg.type === 'e2ee_ready') {
|
||||
sendEncrypted({ type: 'e2ee_auth', deviceToken })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Not plaintext JSON — fall through and try encrypted handshake messages.
|
||||
}
|
||||
|
||||
if (!sharedKey || sharedKey.length !== 32) {
|
||||
return
|
||||
}
|
||||
|
||||
const plaintext = decrypt(raw, sharedKey)
|
||||
if (plaintext === null) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(plaintext)
|
||||
if (msg.type === 'e2ee_authenticated') {
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
}
|
||||
setState('connected')
|
||||
for (const [id, stream] of streamListeners) {
|
||||
sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params })
|
||||
}
|
||||
} else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) {
|
||||
intentionallyClosed = true
|
||||
ws?.close()
|
||||
ws = null
|
||||
setState('auth-failed')
|
||||
rejectAllPending('Unauthorized — pairing may be revoked')
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — ignore during handshake.
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Why: guard against decrypt with an invalid key — sharedKey can be null
|
||||
// after destroy() or if a message arrives during a reconnect race.
|
||||
if (!sharedKey || sharedKey.length !== 32) {
|
||||
return
|
||||
}
|
||||
|
||||
const plaintext = decrypt(raw, sharedKey)
|
||||
if (plaintext === null) {
|
||||
return
|
||||
}
|
||||
|
||||
let response: RpcResponse
|
||||
try {
|
||||
response = JSON.parse(plaintext)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: auth failure is distinct from transient disconnect — retrying
|
||||
// with a rejected token causes infinite reconnect churn.
|
||||
if (!response.ok && response.error.code === 'unauthorized') {
|
||||
intentionallyClosed = true
|
||||
ws?.close()
|
||||
ws = null
|
||||
setState('auth-failed')
|
||||
rejectAllPending('Unauthorized — pairing may be revoked')
|
||||
return
|
||||
}
|
||||
|
||||
const isStreaming = response.ok && (response as RpcSuccess).streaming === true
|
||||
|
||||
if (isStreaming) {
|
||||
const stream = streamListeners.get(response.id)
|
||||
if (stream && response.ok) {
|
||||
stream.listener((response as RpcSuccess).result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as Record<string, unknown> | null
|
||||
if (result && result.type === 'end') {
|
||||
const stream = streamListeners.get(response.id)
|
||||
if (stream) {
|
||||
stream.listener(result)
|
||||
streamListeners.delete(response.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (result && result.type === 'scrollback') {
|
||||
const stream = streamListeners.get(response.id)
|
||||
if (stream) {
|
||||
stream.listener(result)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const req = pending.get(response.id)
|
||||
if (req) {
|
||||
pending.delete(response.id)
|
||||
req.resolve(response)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
ws = null
|
||||
sharedKey = null
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
}
|
||||
if (intentionallyClosed) {
|
||||
setState('disconnected')
|
||||
rejectAllPending('Connection closed')
|
||||
return
|
||||
}
|
||||
rejectAllPending('Connection interrupted')
|
||||
setState('reconnecting')
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after this
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]!
|
||||
reconnectAttempt++
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
openConnection()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function rejectAllPending(reason: string) {
|
||||
const error = new Error(reason)
|
||||
for (const [id, req] of pending) {
|
||||
pending.delete(id)
|
||||
queueMicrotask(() => req.reject(error))
|
||||
}
|
||||
}
|
||||
|
||||
function sendEncrypted(request: unknown): boolean {
|
||||
if (ws && ws.readyState === WebSocket.OPEN && sharedKey) {
|
||||
ws.send(encrypt(JSON.stringify(request), sharedKey))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
openConnection()
|
||||
|
||||
return {
|
||||
async sendRequest(method: string, params?: unknown): Promise<RpcResponse> {
|
||||
await waitForConnected()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextId()
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
reject(new Error(`Request timed out: ${method}`))
|
||||
}, REQUEST_TIMEOUT_MS)
|
||||
|
||||
pending.set(id, {
|
||||
resolve: (response) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(response)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
if (!sendEncrypted({ id, deviceToken, method, params })) {
|
||||
pending.delete(id)
|
||||
clearTimeout(timeout)
|
||||
reject(new Error('Connection interrupted'))
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
subscribe(method: string, params: unknown, onData: StreamingListener): () => void {
|
||||
const id = nextId()
|
||||
streamListeners.set(id, { method, params, listener: onData })
|
||||
|
||||
if (state === 'connected') {
|
||||
sendEncrypted({ id, deviceToken, method, params })
|
||||
}
|
||||
|
||||
return () => {
|
||||
const stream = streamListeners.get(id)
|
||||
streamListeners.delete(id)
|
||||
if (
|
||||
stream?.method === 'terminal.subscribe' &&
|
||||
stream.params &&
|
||||
typeof stream.params === 'object' &&
|
||||
typeof (stream.params as { terminal?: unknown }).terminal === 'string'
|
||||
) {
|
||||
sendEncrypted({
|
||||
id: nextId(),
|
||||
deviceToken,
|
||||
method: 'terminal.unsubscribe',
|
||||
// Why: the runtime keys terminal subscription cleanup by terminal
|
||||
// handle, not by the RPC request id used to open the stream.
|
||||
params: { subscriptionId: (stream.params as { terminal: string }).terminal }
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getState(): ConnectionState {
|
||||
return state
|
||||
},
|
||||
|
||||
onStateChange(listener: (state: ConnectionState) => void): () => void {
|
||||
stateListeners.add(listener)
|
||||
return () => stateListeners.delete(listener)
|
||||
},
|
||||
|
||||
close() {
|
||||
intentionallyClosed = true
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
}
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
sharedKey = null
|
||||
setState('disconnected')
|
||||
rejectAllPending('Client closed')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export type RpcRequest = {
|
||||
id: string
|
||||
deviceToken: string
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
export type RpcSuccess = {
|
||||
id: string
|
||||
ok: true
|
||||
result: unknown
|
||||
streaming?: true
|
||||
_meta: { runtimeId: string }
|
||||
}
|
||||
|
||||
export type RpcFailure = {
|
||||
id: string
|
||||
ok: false
|
||||
error: { code: string; message: string; data?: unknown }
|
||||
_meta: { runtimeId: string }
|
||||
}
|
||||
|
||||
export type RpcResponse = RpcSuccess | RpcFailure
|
||||
|
||||
export const PAIRING_OFFER_VERSION = 2
|
||||
|
||||
export const PairingOfferSchema = z.object({
|
||||
v: z.literal(PAIRING_OFFER_VERSION),
|
||||
endpoint: z.string().min(1),
|
||||
deviceToken: z.string().min(1),
|
||||
publicKeyB64: z.string().min(1)
|
||||
})
|
||||
|
||||
export type PairingOffer = z.infer<typeof PairingOfferSchema>
|
||||
|
||||
export type ConnectionState =
|
||||
| 'connecting'
|
||||
| 'handshaking'
|
||||
| 'connected'
|
||||
| 'disconnected'
|
||||
| 'reconnecting'
|
||||
| 'auth-failed'
|
||||
|
||||
export type HostProfile = {
|
||||
id: string
|
||||
name: string
|
||||
endpoint: string
|
||||
deviceToken: string
|
||||
publicKeyB64: string
|
||||
lastConnected: number
|
||||
}
|
||||
|
||||
export const HostProfileSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
endpoint: z.string().min(1),
|
||||
deviceToken: z.string().min(1),
|
||||
publicKeyB64: z.string().min(1),
|
||||
lastConnected: z.number().finite()
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
# Mobile Terminal Output Streaming Findings
|
||||
|
||||
Date: 2026-04-28
|
||||
|
||||
## Scope
|
||||
|
||||
The investigation was limited to `mobile/`. Server-side files under `src/main/` were read for context only and were not modified.
|
||||
|
||||
## Verified Findings
|
||||
|
||||
- The phone can connect to `ws://192.168.0.179:6768`, list worktrees, list terminals, and call `terminal.send`.
|
||||
- The phone's saved host token is valid; a direct WebSocket probe from the desktop using the same token can call `worktree.ps`, `terminal.list`, `terminal.subscribe`, `terminal.send`, and `terminal.read`.
|
||||
- `TerminalWebView` can render text when the React Native side writes to it after xterm initializes. A temporary marker written after `init()` appeared visibly in the WebView.
|
||||
- Messages posted to the WebView before its page installs message handlers can be dropped. The mobile fix queues `init`, `write`, and `clear` until the WebView reports `web-ready`.
|
||||
- The WebView also has an internal queue for writes that arrive after `web-ready` but before xterm finishes `init()`.
|
||||
- The selected physical-phone test worktree was `refs/heads/tasks-improvements` at `/Users/jinwoohong/orca/workspaces/orca/pr-1172-review`.
|
||||
- For that test worktree, `terminal.subscribe` produced an initial `scrollback` event with an empty `lines` array and no serialized buffer.
|
||||
- Sending commands to that test terminal returned `ok:true`, but a direct WebSocket `terminal.read` for the same handle still returned an empty tail and no live `data` chunks were observed.
|
||||
- Creating a fresh terminal with `terminal.create` in that same worktree also returned a writable handle, but `terminal.send` followed by delayed `terminal.read` still returned an empty tail.
|
||||
- Adding a mobile-side `terminal.read` fallback did not make the current physical-phone test terminal display output, because the direct WebSocket `terminal.read` response for `mobile-output-test` remained `tail: []` and `lastOutputAt: null` after `echo hi`.
|
||||
- Calling `terminal.show` before `terminal.send` on `mobile-output-test` returned a connected/writable terminal with a `ptyId`, but a delayed `terminal.read` still returned an empty tail.
|
||||
- Root cause found after building a no-phone repro: daemon-backed PTYs were forwarding provider data to the desktop renderer, but not into `runtime.onPtyData`. The runtime tail buffer and `terminal.subscribe` listeners are fed by `runtime.onPtyData`, so the phone saw accepted sends with no output.
|
||||
- Fix: `src/main/ipc/pty.ts` now forwards `provider.onData` into `runtime.onPtyData` for non-`LocalPtyProvider` providers. Local PTYs already use the LocalPtyProvider configure hook, so the guard avoids duplicate local output.
|
||||
- After restarting Electron, `pnpm exec tsx mobile/scripts/test-subscribe.ts <deviceToken>` passed: both `streamSawMarker` and `readSawMarker` were `true`.
|
||||
- After the backend fix, the physical phone rendered the repro marker in `TerminalWebView`.
|
||||
- Because of the empty tail/no live stream on that specific desktop terminal, the physical `ls` test did not prove the WebView display path after real PTY output.
|
||||
|
||||
## Changes Made
|
||||
|
||||
- `mobile/src/terminal/TerminalWebView.tsx`
|
||||
- Added a native-side queue so WebView messages are not sent until the HTML reports `web-ready`.
|
||||
- Added a WebView-side queue so writes wait until xterm finishes `init()`.
|
||||
- `mobile/src/transport/rpc-client.ts`
|
||||
- Stored full stream request metadata so active streams can be replayed after reconnect.
|
||||
- Avoided sending a subscription before the socket reaches `connected`.
|
||||
- Fixed `terminal.unsubscribe` to use the terminal handle as `subscriptionId`, matching runtime cleanup behavior.
|
||||
- `mobile/app/h/[hostId]/session/[worktreeId].tsx`
|
||||
- Clears and resubscribes when the worktree changes.
|
||||
- Tracks the active terminal handle in a ref so stale handles are replaced when `terminal.list` changes.
|
||||
- Routes scrollback and live data into `TerminalWebView`.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
agent-device devices --json
|
||||
agent-device snapshot --platform android --serial R3CX105QXRH --json
|
||||
agent-device fill @e33 "ls" --platform android --serial R3CX105QXRH --json
|
||||
agent-device click @e34 --platform android --serial R3CX105QXRH --json
|
||||
agent-device screenshot /tmp/mobile-terminal.png --platform android --serial R3CX105QXRH --json
|
||||
orca terminal send --terminal term_926b8898-f843-461a-acd2-482f741327ad --text r --json
|
||||
orca terminal read --terminal term_926b8898-f843-461a-acd2-482f741327ad --json
|
||||
```
|
||||
|
||||
## Next Debug Boundary
|
||||
|
||||
Do not repeat WebView readiness tests unless the WebView changes again; the WebView can render post-init writes. For future regressions, run the no-phone repro first. If it fails, debug the WebSocket/runtime/PTY bridge before touching the mobile UI. If it passes but the phone is blank, debug `TerminalWebView` or session-screen routing.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
@@ -91,6 +91,7 @@
|
||||
"node-pty": "^1.1.0",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"posthog-node": "^5.33.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
@@ -108,6 +109,7 @@
|
||||
"ssh2": "^1.17.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
@@ -120,6 +122,7 @@
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
|
||||
Generated
+156
@@ -166,6 +166,9 @@ importers:
|
||||
posthog-node:
|
||||
specifier: ^5.33.0
|
||||
version: 5.33.0
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
radix-ui:
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -217,6 +220,9 @@ importers:
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
tweetnacl:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
ws:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
@@ -248,6 +254,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^25.5.0
|
||||
version: 25.5.0
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
'@types/react':
|
||||
specifier: ^19.2.14
|
||||
version: 19.2.14
|
||||
@@ -2837,6 +2846,9 @@ packages:
|
||||
'@types/plist@3.0.5':
|
||||
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
@@ -3242,6 +3254,10 @@ packages:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-lite@1.0.30001780:
|
||||
resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
|
||||
|
||||
@@ -3326,6 +3342,9 @@ packages:
|
||||
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -3640,6 +3659,10 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
||||
|
||||
@@ -3722,6 +3745,9 @@ packages:
|
||||
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
dir-compare@4.2.0:
|
||||
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
|
||||
|
||||
@@ -4020,6 +4046,10 @@ packages:
|
||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
|
||||
find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -4655,6 +4685,10 @@ packages:
|
||||
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
lodash-es@4.18.1:
|
||||
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
|
||||
|
||||
@@ -5175,10 +5209,18 @@ packages:
|
||||
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-locate@4.1.0:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-map@7.0.4:
|
||||
resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5187,6 +5229,10 @@ packages:
|
||||
resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
@@ -5221,6 +5267,10 @@ packages:
|
||||
path-data-parser@0.1.0:
|
||||
resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==}
|
||||
|
||||
path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-is-absolute@1.0.1:
|
||||
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5289,6 +5339,10 @@ packages:
|
||||
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
|
||||
engines: {node: '>=10.4.0'}
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
pngjs@7.0.0:
|
||||
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
|
||||
engines: {node: '>=14.19.0'}
|
||||
@@ -5404,6 +5458,11 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
qs@6.15.0:
|
||||
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -5554,6 +5613,9 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
resedit@1.7.2:
|
||||
resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
@@ -5685,6 +5747,9 @@ packages:
|
||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
@@ -6020,6 +6085,9 @@ packages:
|
||||
tweetnacl@0.14.5:
|
||||
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
||||
|
||||
tweetnacl@1.0.3:
|
||||
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||
|
||||
type-fest@0.13.1:
|
||||
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -6273,6 +6341,9 @@ packages:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -6332,6 +6403,9 @@ packages:
|
||||
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -6351,10 +6425,18 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs@15.4.1:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -8774,6 +8856,10 @@ snapshots:
|
||||
xmlbuilder: 15.1.1
|
||||
optional: true
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
dependencies:
|
||||
'@types/node': 25.5.0
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
@@ -9238,6 +9324,8 @@ snapshots:
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001780: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
@@ -9309,6 +9397,12 @@ snapshots:
|
||||
|
||||
cli-width@4.1.0: {}
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
@@ -9623,6 +9717,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
@@ -9689,6 +9785,8 @@ snapshots:
|
||||
|
||||
diff@8.0.3: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
dir-compare@4.2.0:
|
||||
dependencies:
|
||||
minimatch: 3.1.5
|
||||
@@ -10128,6 +10226,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
find-up@4.1.0:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
@@ -10765,6 +10868,10 @@ snapshots:
|
||||
rfdc: 1.4.1
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
lodash-es@4.18.1: {}
|
||||
|
||||
lodash.escaperegexp@4.1.2: {}
|
||||
@@ -11590,10 +11697,18 @@ snapshots:
|
||||
|
||||
p-cancelable@2.1.1: {}
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
|
||||
p-limit@3.1.0:
|
||||
dependencies:
|
||||
yocto-queue: 0.1.0
|
||||
|
||||
p-locate@4.1.0:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
p-map@7.0.4: {}
|
||||
|
||||
p-retry@4.6.2:
|
||||
@@ -11601,6 +11716,8 @@ snapshots:
|
||||
'@types/retry': 0.12.0
|
||||
retry: 0.13.1
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
package-manager-detector@1.6.0: {}
|
||||
@@ -11638,6 +11755,8 @@ snapshots:
|
||||
|
||||
path-data-parser@0.1.0: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
@@ -11692,6 +11811,8 @@ snapshots:
|
||||
base64-js: 1.5.1
|
||||
xmlbuilder: 15.1.1
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
pngjs@7.0.0: {}
|
||||
|
||||
points-on-curve@0.2.0: {}
|
||||
@@ -11845,6 +11966,12 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
qs@6.15.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
@@ -12116,6 +12243,8 @@ snapshots:
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
resedit@1.7.2:
|
||||
dependencies:
|
||||
pe-library: 0.4.1
|
||||
@@ -12279,6 +12408,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
shadcn@4.1.0(@types/node@25.5.0)(typescript@5.9.3):
|
||||
@@ -12664,6 +12795,8 @@ snapshots:
|
||||
|
||||
tweetnacl@0.14.5: {}
|
||||
|
||||
tweetnacl@1.0.3: {}
|
||||
|
||||
type-fest@0.13.1:
|
||||
optional: true
|
||||
|
||||
@@ -12878,6 +13011,8 @@ snapshots:
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -12930,6 +13065,8 @@ snapshots:
|
||||
|
||||
xmlbuilder@15.1.1: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
@@ -12940,8 +13077,27 @@ snapshots:
|
||||
|
||||
yaml@2.8.3: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@15.4.1:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
---
|
||||
name: mobile-fit-debug
|
||||
description: Debug mobile terminal display-mode toggle and PTY resize issues. Use when investigating why a terminal restores to the wrong width after toggling between phone and desktop mode on mobile, or when PTY sizes are corrupted across tab switches.
|
||||
---
|
||||
|
||||
# Mobile Fit Debug
|
||||
|
||||
Use this skill when debugging mobile terminal display-mode toggle issues — wrong restore width, corrupted previousCols, stale PTY sizes, or collateral safeFit cascades.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The mobile display-mode toggle involves **3 processes** communicating asynchronously:
|
||||
|
||||
```
|
||||
Mobile App (React Native) Server (Electron main) Desktop Renderer (Electron renderer)
|
||||
───────────────────────── ────────────────────── ────────────────────────────────────
|
||||
terminal.setDisplayMode ──RPC──► applyMobileDisplayMode
|
||||
├─ resize PTY (authoritative)
|
||||
├─ suppressResizesForMs(500)
|
||||
├─ terminalFitOverrideChanged ──IPC──► setOverrideTick (React re-render)
|
||||
│ ├─ safeFit on ALL panes
|
||||
│ └─ pty:resize IPC ──────► SUPPRESSED
|
||||
└─ notifyTerminalResize ──stream──► mobile client updates
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/main/runtime/orca-runtime.ts` | Server-side state: `mobileSubscribers`, `mobileDisplayModes`, `lastRendererSizes`, suppress window. Core handlers: `handleMobileSubscribe`, `handleMobileUnsubscribe`, `applyMobileDisplayMode`, `onExternalPtyResize` |
|
||||
| `src/main/ipc/pty.ts` | `pty:resize` IPC handler. Checks `isResizeSuppressed()` before processing. Calls `onExternalPtyResize` to update server state |
|
||||
| `src/main/runtime/rpc/methods/terminal.ts` | RPC methods: `terminal.subscribe` (mobile subscription lifecycle), `terminal.setDisplayMode`, `terminal.getDisplayMode` |
|
||||
| `src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts` | Desktop renderer's override cache. `setFitOverride('desktop-fit')` clears the override. `notifyChange` fires `onOverrideChange` listeners |
|
||||
| `src/renderer/src/lib/pane-manager/pane-tree-ops.ts` | `safeFit()` — measures pane DOM and resizes xterm. When a mobile-fit override exists, uses override dims instead of measuring |
|
||||
| `src/renderer/src/components/terminal-pane/TerminalPane.tsx` | `onOverrideChange` handler triggers `setOverrideTick` re-render cascade. This is what causes safeFit to run on ALL panes |
|
||||
| `mobile/app/h/[hostId]/session/[worktreeId].tsx` | Mobile session screen — tab switching, `toggleDisplayMode`, terminal subscription management |
|
||||
| `src/main/runtime/mobile-subscribe-integration.test.ts` | Integration tests for the full subscribe/unsubscribe/toggle lifecycle |
|
||||
|
||||
## Adding Debug Logging
|
||||
|
||||
Production code has no debug logging. To investigate issues, temporarily add file-based logging to the key files. Here's a pattern that works well:
|
||||
|
||||
### 1. Add mfLog helper to each file you want to instrument
|
||||
|
||||
```typescript
|
||||
// In orca-runtime.ts (no prefix):
|
||||
import { appendFileSync } from 'fs'
|
||||
function mfLog(msg: string): void {
|
||||
try { appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] ${msg}\n`) } catch {}
|
||||
}
|
||||
|
||||
// In pty.ts (prefix [pty-ipc]):
|
||||
function mfLog(msg: string): void {
|
||||
try { appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] [pty-ipc] ${msg}\n`) } catch {}
|
||||
}
|
||||
|
||||
// In terminal.ts RPC methods (prefix [rpc]):
|
||||
function mfLog(msg: string): void {
|
||||
try { appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] [rpc] ${msg}\n`) } catch {}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Useful log points
|
||||
|
||||
**orca-runtime.ts — `handleMobileSubscribe`:**
|
||||
```typescript
|
||||
mfLog(`handleMobileSubscribe ptyId=${ptyId} mode=${mode} viewport=${viewport?.cols}x${viewport?.rows}`)
|
||||
mfLog(` existing.prev=${existing?.previousCols}x${existing?.previousRows} currentSize=${currentSize?.cols}x${currentSize?.rows} rendererSize=${rendererSize?.cols}x${rendererSize?.rows}`)
|
||||
mfLog(` → previousCols=${previousCols}`)
|
||||
```
|
||||
|
||||
**orca-runtime.ts — `applyMobileDisplayMode`:**
|
||||
```typescript
|
||||
mfLog(`applyMobileDisplayMode ptyId=${ptyId} mode=${mode} sub.prev=${subscriber?.previousCols}x${subscriber?.previousRows}`)
|
||||
mfLog(` DESKTOP RESTORE: previousCols=${previousCols}`)
|
||||
```
|
||||
|
||||
**orca-runtime.ts — `onExternalPtyResize`:**
|
||||
```typescript
|
||||
mfLog(`onExternalPtyResize ptyId=${ptyId} cols=${cols} rows=${rows}`)
|
||||
```
|
||||
|
||||
**pty.ts — `pty:resize` handler:**
|
||||
```typescript
|
||||
if (runtime?.isResizeSuppressed()) {
|
||||
mfLog(`pty:resize SUPPRESSED id=${args.id} cols=${args.cols} rows=${args.rows}`)
|
||||
return
|
||||
}
|
||||
mfLog(`pty:resize id=${args.id} cols=${args.cols} rows=${args.rows}`)
|
||||
```
|
||||
|
||||
**terminal.ts — `terminal.subscribe`:**
|
||||
```typescript
|
||||
mfLog(`\n========== MOBILE SUBSCRIBE ==========`)
|
||||
mfLog(`terminal.subscribe handle=${params.terminal} ptyId=${leaf?.ptyId} client=${params.client?.type}`)
|
||||
```
|
||||
|
||||
### 3. Reading the log
|
||||
|
||||
```bash
|
||||
> /tmp/mobile-fit-debug.log # clear
|
||||
# ... reproduce issue on mobile ...
|
||||
cat /tmp/mobile-fit-debug.log # read
|
||||
```
|
||||
|
||||
### 4. Remember to remove logging before committing
|
||||
|
||||
Remove all `mfLog` functions, calls, and `appendFileSync` imports.
|
||||
|
||||
## What to Look For in Logs
|
||||
|
||||
**Wrong previousCols captured:**
|
||||
```
|
||||
handleMobileSubscribe ptyId=...@@abc123 ...
|
||||
existing.prev=null currentSize=105x72 rendererSize=214x72 <-- rendererSize is WRONG
|
||||
→ previousCols=214 <-- captured wrong value
|
||||
```
|
||||
This means `lastRendererSizes` was polluted by a collateral safeFit cascade.
|
||||
|
||||
**Suppress window too short:**
|
||||
```
|
||||
[pty-ipc] pty:resize id=...@@abc123 cols=105 rows=72 <-- NOT suppressed, arrived after window
|
||||
```
|
||||
If you see unsuppressed `pty:resize` events for terminals that weren't the one being toggled, the 500ms suppress window may be too short. Check the timestamp gap between `applyMobileDisplayMode` and the stale resize.
|
||||
|
||||
**Suppress working correctly:**
|
||||
```
|
||||
[pty-ipc] pty:resize SUPPRESSED id=...@@abc123 cols=214 rows=72
|
||||
```
|
||||
|
||||
**Collateral cascade pattern** (the root cause of most bugs):
|
||||
```
|
||||
applyMobileDisplayMode ptyId=...@@split mode=desktop
|
||||
DESKTOP RESTORE: previousCols=105
|
||||
[pty-ipc] pty:resize SUPPRESSED id=...@@split cols=105 <-- redundant, suppressed OK
|
||||
[pty-ipc] pty:resize SUPPRESSED id=...@@other cols=214 <-- collateral, suppressed OK
|
||||
```
|
||||
If the collateral line says `pty:resize id=` instead of `SUPPRESSED`, the cascade leaked through.
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
### 1. The Collateral safeFit Cascade
|
||||
When `terminalFitOverrideChanged` fires for ONE pane, the desktop renderer's `setOverrideTick` React state change triggers a re-render that runs `safeFit` on ALL panes across ALL tabs. Background-tab panes get measured at wrong widths because they may not be visible or may be in different split configurations than the active tab.
|
||||
|
||||
### 2. Cascade Timing is ~360ms
|
||||
The full cascade path: server sends IPC -> desktop renderer receives -> React re-render -> requestAnimationFrame -> DOM measurement -> `pty:resize` IPC back to server. This takes ~360ms, which is why the suppress window must be >360ms (currently 500ms).
|
||||
|
||||
### 3. lastRendererSizes Persistence
|
||||
`lastRendererSizes` stores every `pty:resize` from the desktop renderer. Stale values persist across mobile subscribe/unsubscribe cycles. The `lastRendererSizes.delete(ptyId)` in desktop restore is critical — without it, a stale 214 from a previous cascade gets used as `previousCols` on the next subscribe.
|
||||
|
||||
### 4. Daemon Doesn't Hot-Reload
|
||||
The daemon process (`out/main/daemon-entry.js`) loads compiled code once at startup. Changes to `orca-runtime.ts` or `pty.ts` require:
|
||||
1. `npx electron-vite build --outDir out` (or wait for watcher)
|
||||
2. **Restart the dev server** (`pnpm dev`) — the daemon subprocess won't pick up new builds
|
||||
|
||||
Verify your code is live: `grep "your_unique_string" out/main/index.js`
|
||||
|
||||
### 5. previousCols Priority Chain
|
||||
```typescript
|
||||
previousCols = existing?.previousCols ?? rendererSize?.cols ?? currentSize?.cols
|
||||
```
|
||||
- `existing?.previousCols` — re-subscribe case (tab switch back), most trusted
|
||||
- `rendererSize?.cols` — from `lastRendererSizes`, the desktop renderer's last `pty:resize`
|
||||
- `currentSize?.cols` — from `getTerminalSize()`, the server-side PTY size
|
||||
|
||||
If `rendererSize` is polluted (e.g. 214 for a split pane), it takes priority over the correct `currentSize`. That's why clearing `lastRendererSizes` on restore is essential.
|
||||
|
||||
### 6. agent-device for Automated Testing
|
||||
Use `/opt/homebrew/bin/agent-device` to automate mobile UI testing:
|
||||
```bash
|
||||
agent-device snapshot # accessibility tree
|
||||
agent-device click @e45 # click element by ref
|
||||
agent-device screenshot --out /tmp/s.png # capture screenshot
|
||||
agent-device wait 2 # wait 2 seconds
|
||||
```
|
||||
Typical test flow: click tab -> click "Switch to desktop mode" -> wait 3s -> click "Switch to phone mode" -> switch tabs -> repeat -> read `/tmp/mobile-fit-debug.log`.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
npx vitest run src/main/runtime/mobile-subscribe-integration.test.ts
|
||||
```
|
||||
@@ -35,10 +35,12 @@ function writeMetadata(
|
||||
JSON.stringify({
|
||||
runtimeId: 'runtime-1',
|
||||
pid,
|
||||
transport: {
|
||||
kind: 'unix',
|
||||
endpoint
|
||||
},
|
||||
transports: [
|
||||
{
|
||||
kind: 'unix',
|
||||
endpoint
|
||||
}
|
||||
],
|
||||
authToken,
|
||||
startedAt: 1
|
||||
}),
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { readFileSync } from 'fs'
|
||||
import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
import {
|
||||
findTransport,
|
||||
getRuntimeMetadataPath,
|
||||
type RuntimeMetadata
|
||||
} from '../../shared/runtime-bootstrap'
|
||||
import { RuntimeClientError } from './types'
|
||||
|
||||
export function readMetadata(userDataPath: string): RuntimeMetadata {
|
||||
const metadataPath = getRuntimeMetadataPath(userDataPath)
|
||||
try {
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null
|
||||
if (!metadata?.transport || !metadata.authToken) {
|
||||
if (!metadata || !findTransport(metadata, 'unix', 'named-pipe') || !metadata.authToken) {
|
||||
throw new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
`Orca runtime metadata is incomplete at ${metadataPath}`
|
||||
|
||||
@@ -7,7 +7,7 @@ export async function getCliStatus(
|
||||
userDataPath: string
|
||||
): Promise<RuntimeRpcSuccess<CliStatusResult>> {
|
||||
const metadata = tryReadMetadata(userDataPath)
|
||||
if (!metadata?.transport || !metadata.authToken) {
|
||||
if (!metadata?.transports?.length || !metadata.authToken) {
|
||||
return buildCliStatusResponse({
|
||||
app: {
|
||||
running: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createConnection } from 'net'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap'
|
||||
import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema'
|
||||
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
|
||||
|
||||
@@ -11,7 +11,17 @@ export async function sendRequest<TResult>(
|
||||
timeoutMs: number
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = createConnection(getTransportEndpoint(metadata.transport!))
|
||||
const transport = findTransport(metadata, 'unix', 'named-pipe')
|
||||
if (!transport) {
|
||||
reject(
|
||||
new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'No compatible transport found in Orca runtime metadata.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
const socket = createConnection(transport.endpoint)
|
||||
let buffer = ''
|
||||
let settled = false
|
||||
const requestId = randomUUID()
|
||||
@@ -159,7 +169,3 @@ export async function sendRequest<TResult>(
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getTransportEndpoint(transport: RuntimeTransportMetadata): string {
|
||||
return transport.endpoint
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ describe('Browser automation pipeline (integration)', () => {
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)!
|
||||
endpoint = metadata.transport!.endpoint
|
||||
endpoint = metadata.transports[0]!.endpoint
|
||||
authToken = metadata.authToken!
|
||||
})
|
||||
|
||||
@@ -521,7 +521,7 @@ describe('Browser automation pipeline (integration)', () => {
|
||||
await server2.start()
|
||||
|
||||
const metadata2 = readRuntimeMetadata(userDataPath2)!
|
||||
const res = await sendRequest(metadata2.transport!.endpoint, {
|
||||
const res = await sendRequest(metadata2.transports[0]!.endpoint, {
|
||||
id: 'req_no_tab',
|
||||
authToken: metadata2.authToken,
|
||||
method: 'browser.snapshot'
|
||||
|
||||
@@ -94,6 +94,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalMacOptionAsAlt: 'false',
|
||||
terminalMacOptionAsAltMigrated: true,
|
||||
experimentalAgentDashboard: false,
|
||||
experimentalMobile: false,
|
||||
experimentalSidekick: false,
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
terminalWindowsPowerShellImplementation: 'powershell.exe',
|
||||
|
||||
@@ -88,6 +88,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalMacOptionAsAlt: 'false',
|
||||
terminalMacOptionAsAltMigrated: true,
|
||||
experimentalAgentDashboard: false,
|
||||
experimentalMobile: false,
|
||||
experimentalSidekick: false,
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
terminalWindowsPowerShellImplementation: 'powershell.exe',
|
||||
|
||||
@@ -9,6 +9,10 @@ export type HeadlessEmulatorOptions = {
|
||||
scrollback?: number
|
||||
}
|
||||
|
||||
export type HeadlessSnapshotOptions = {
|
||||
scrollbackRows?: number
|
||||
}
|
||||
|
||||
const DEFAULT_SCROLLBACK = 5000
|
||||
|
||||
function parseFileUriPath(uri: string): string | null {
|
||||
@@ -87,10 +91,10 @@ export class HeadlessEmulator {
|
||||
this.terminal.resize(cols, rows)
|
||||
}
|
||||
|
||||
getSnapshot(): TerminalSnapshot {
|
||||
getSnapshot(opts: HeadlessSnapshotOptions = {}): TerminalSnapshot {
|
||||
const modes = this.getModes()
|
||||
return {
|
||||
snapshotAnsi: this.serializer.serialize(),
|
||||
snapshotAnsi: this.serializer.serialize({ scrollback: opts.scrollbackRows }),
|
||||
scrollbackAnsi: '',
|
||||
rehydrateSequences: this.buildRehydrateSequences(modes),
|
||||
cwd: this.cwd,
|
||||
|
||||
@@ -134,7 +134,11 @@ if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then
|
||||
__orca_prompt_mark() {
|
||||
printf "${SHELL_READY_MARKER}"
|
||||
}
|
||||
precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark)
|
||||
# Why: zsh precmd fires before zle switches the PTY into line-editing mode,
|
||||
# so writing startup input there can be echoed once outside the prompt.
|
||||
autoload -Uz add-zle-hook-widget
|
||||
zle -N __orca_prompt_mark
|
||||
add-zle-hook-widget line-init __orca_prompt_mark
|
||||
fi
|
||||
`
|
||||
const bashRc = `# Orca daemon bash shell-ready wrapper
|
||||
|
||||
+10
-1
@@ -15,6 +15,7 @@ import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init'
|
||||
import { setAppRuntimeFlags } from './ipc/app'
|
||||
import { closeAllWatchers } from './ipc/filesystem-watcher'
|
||||
import { registerCoreHandlers } from './ipc/register-core-handlers'
|
||||
import { registerMobileHandlers } from './ipc/mobile'
|
||||
import { initTelemetry, shutdownTelemetry } from './telemetry/client'
|
||||
import { triggerStartupNotificationRegistration } from './ipc/notifications'
|
||||
import { OrcaRuntimeService } from './runtime/orca-runtime'
|
||||
@@ -498,10 +499,18 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
// Why: E2E tests launch parallel Electron instances that would all race to
|
||||
// bind the default fixed port, crashing on EADDRINUSE. Port 0 lets the OS
|
||||
// assign a random available port per instance while still exercising the
|
||||
// full WebSocket startup path.
|
||||
const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR)
|
||||
runtimeRpc = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath: app.getPath('userData')
|
||||
userDataPath: app.getPath('userData'),
|
||||
enableWebSocket: true,
|
||||
...(isE2E ? { wsPort: 0 } : {})
|
||||
})
|
||||
registerMobileHandlers(runtimeRpc)
|
||||
|
||||
// Why: the persistent-terminal daemon is always started. If it fails, the
|
||||
// LocalPtyProvider (initialized at module load in ipc/pty.ts) remains as the
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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'
|
||||
|
||||
export type NetworkInterface = {
|
||||
name: string
|
||||
address: string
|
||||
}
|
||||
|
||||
// Why: the WebSocket transport advertises 0.0.0.0 as its endpoint, which isn't
|
||||
// connectable from a mobile device. We enumerate all non-internal IPv4
|
||||
// addresses so the user can choose which one to advertise in the QR code
|
||||
// (e.g. LAN vs Tailscale).
|
||||
function getNetworkInterfaces(): NetworkInterface[] {
|
||||
const result: NetworkInterface[] = []
|
||||
const interfaces = networkInterfaces()
|
||||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||
if (!addrs) {
|
||||
continue
|
||||
}
|
||||
for (const addr of addrs) {
|
||||
if (addr.family === 'IPv4' && !addr.internal) {
|
||||
result.push({ name, address: addr.address })
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function getLanAddress(): string | null {
|
||||
const ifaces = getNetworkInterfaces()
|
||||
return ifaces.length > 0 ? ifaces[0]!.address : 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:listNetworkInterfaces', (): { interfaces: NetworkInterface[] } => ({
|
||||
interfaces: getNetworkInterfaces()
|
||||
}))
|
||||
|
||||
ipcMain.handle('mobile:getPairingQR', async (_event, args?: { address?: string }) => {
|
||||
const rawEndpoint = rpcServer.getWebSocketEndpoint()
|
||||
const registry = rpcServer.getDeviceRegistry()
|
||||
if (!rawEndpoint || !registry) {
|
||||
return { available: false as const }
|
||||
}
|
||||
|
||||
// Why: allow the caller to specify which network interface address to
|
||||
// embed in the QR code. This supports overlay networks (Tailscale,
|
||||
// ZeroTier) where the default LAN IP isn't reachable from the phone.
|
||||
const ip = args?.address ?? getLanAddress()
|
||||
if (!ip) {
|
||||
return { available: false as const }
|
||||
}
|
||||
const endpoint = rawEndpoint.replace('0.0.0.0', ip)
|
||||
|
||||
const device = registry.addDevice(`Mobile ${new Date().toLocaleDateString()}`)
|
||||
|
||||
const publicKeyB64 = rpcServer.getE2EEPublicKey()
|
||||
if (!publicKeyB64) {
|
||||
return { available: false as const }
|
||||
}
|
||||
|
||||
const url = encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint,
|
||||
deviceToken: device.token,
|
||||
publicKeyB64
|
||||
})
|
||||
|
||||
const qrDataUrl = await QRCode.toDataURL(url, {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 2,
|
||||
width: 256
|
||||
})
|
||||
|
||||
return {
|
||||
available: true as const,
|
||||
qrDataUrl,
|
||||
endpoint,
|
||||
deviceId: device.deviceId
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('mobile:listDevices', () => {
|
||||
const registry = rpcServer.getDeviceRegistry()
|
||||
if (!registry) {
|
||||
return { devices: [] }
|
||||
}
|
||||
// Why: devices with lastSeenAt === 0 were created during QR generation
|
||||
// but never actually scanned/connected. Showing them as "paired" is
|
||||
// misleading, so we filter them out.
|
||||
return {
|
||||
devices: registry
|
||||
.listDevices()
|
||||
.filter((d) => d.lastSeenAt > 0)
|
||||
.map((d) => ({
|
||||
deviceId: d.deviceId,
|
||||
name: d.name,
|
||||
pairedAt: d.pairedAt,
|
||||
lastSeenAt: d.lastSeenAt
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('mobile:revokeDevice', (_event, args: { deviceId: string }) => {
|
||||
const registry = rpcServer.getDeviceRegistry()
|
||||
if (!registry) {
|
||||
return { revoked: false }
|
||||
}
|
||||
return { revoked: registry.removeDevice(args.deviceId) }
|
||||
})
|
||||
|
||||
ipcMain.handle('mobile:isWebSocketReady', () => {
|
||||
return {
|
||||
ready: rpcServer.getWebSocketEndpoint() !== null,
|
||||
endpoint: rpcServer.getWebSocketEndpoint()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app, BrowserWindow, Notification, ipcMain, shell } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type { NotificationDispatchRequest, NotificationDispatchResult } from '../../shared/types'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
|
||||
const NOTIFICATION_COOLDOWN_MS = 5000
|
||||
|
||||
@@ -11,7 +12,7 @@ const NOTIFICATION_COOLDOWN_MS = 5000
|
||||
// strong reference until the notification is clicked or closed.
|
||||
const activeNotifications = new Set<Notification>()
|
||||
|
||||
export function registerNotificationHandlers(store: Store): void {
|
||||
export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void {
|
||||
const recentNotifications = new Map<string, number>()
|
||||
|
||||
ipcMain.removeHandler('notifications:openSystemSettings')
|
||||
@@ -28,6 +29,21 @@ export function registerNotificationHandlers(store: Store): void {
|
||||
ipcMain.handle(
|
||||
'notifications:dispatch',
|
||||
(_event, args: NotificationDispatchRequest): NotificationDispatchResult => {
|
||||
// Why: mobile push is independent of desktop notification guards.
|
||||
// The user's phone should receive the notification even when the desktop
|
||||
// window is focused (suppressWhenFocused), Electron notifications aren't
|
||||
// supported, or the desktop is in cooldown. The mobile client decides
|
||||
// independently whether to show based on its own app state.
|
||||
if (runtime) {
|
||||
const opts = buildNotificationOptions(args)
|
||||
runtime.dispatchMobileNotification({
|
||||
source: args.source,
|
||||
title: opts.title,
|
||||
body: opts.body,
|
||||
worktreeId: args.worktreeId
|
||||
})
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
return { delivered: false, reason: 'not-supported' }
|
||||
}
|
||||
@@ -119,6 +135,7 @@ export function registerNotificationHandlers(store: Store): void {
|
||||
}
|
||||
|
||||
notification.show()
|
||||
|
||||
return { delivered: true }
|
||||
}
|
||||
)
|
||||
|
||||
+109
-13
@@ -4,6 +4,7 @@ foreground-process inspection, and renderer IPC stay behind a single audited
|
||||
boundary. Splitting it by line count would scatter tightly coupled terminal
|
||||
process behavior across files without a cleaner ownership seam. */
|
||||
import { join, delimiter } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type BrowserWindow, ipcMain, app } from 'electron'
|
||||
export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
@@ -36,6 +37,9 @@ const sshProviders = new Map<string, IPtyProvider>()
|
||||
// post-spawn operations to the correct provider without the renderer needing
|
||||
// to track connectionId per-PTY.
|
||||
const ptyOwnership = new Map<string, string | null>()
|
||||
// Why: mobile clients must mirror desktop PTY geometry even when the renderer
|
||||
// cannot provide an xterm snapshot yet, such as immediately after tab creation.
|
||||
const ptySizes = new Map<string, { cols: number; rows: number }>()
|
||||
// Why: the agent-hooks server caches per-paneKey state (last prompt, last
|
||||
// tool) that otherwise grows unbounded as panes come and go. Track the
|
||||
// spawn-time paneKey so clearProviderPtyState can clear that cache on PTY
|
||||
@@ -284,6 +288,7 @@ export function clearProviderPtyState(id: string): void {
|
||||
// new teardown path forgets to remove one provider's overlay/hook state.
|
||||
openCodeHookService.clearPty(id)
|
||||
piTitlebarExtensionService.clearPty(id)
|
||||
ptySizes.delete(id)
|
||||
// Why: drop the memory-collector registration so a dead PTY does not keep
|
||||
// trying to resolve its (now-dead) pid on every snapshot. Safe no-op for
|
||||
// PTYs that were never registered (SSH-owned).
|
||||
@@ -436,10 +441,15 @@ export function registerPtyHandlers(
|
||||
// Why: LocalPtyProvider routes data to the runtime via configure().onData,
|
||||
// but daemon-backed providers don't have configure(). Without this, daemon
|
||||
// PTY data never reaches the runtime's tail buffer, so terminal.read returns
|
||||
// empty and agent-detection from raw data never fires.
|
||||
// empty and agent-detection from raw data never fires. Runtime tails also
|
||||
// power mobile read/stream, so they must be notified regardless of window
|
||||
// state.
|
||||
const isLocalProvider = localProvider instanceof LocalPtyProvider
|
||||
|
||||
localDataUnsub = localProvider.onData((payload) => {
|
||||
if (!isLocalProvider) {
|
||||
runtime?.onPtyData(payload.id, payload.data, Date.now())
|
||||
}
|
||||
if (mainWindow.isDestroyed()) {
|
||||
// Why: clear the pending flush timer so it doesn't fire after the window
|
||||
// is gone. Without this, macOS app re-activation leaks orphaned timers
|
||||
@@ -451,9 +461,6 @@ export function registerPtyHandlers(
|
||||
pendingData.clear()
|
||||
return
|
||||
}
|
||||
if (!isLocalProvider) {
|
||||
runtime?.onPtyData(payload.id, payload.data, Date.now())
|
||||
}
|
||||
const existing = pendingData.get(payload.id)
|
||||
pendingData.set(payload.id, existing ? existing + payload.data : payload.data)
|
||||
if (!flushTimer) {
|
||||
@@ -484,6 +491,54 @@ export function registerPtyHandlers(
|
||||
bindProviderListeners()
|
||||
rebindProviderListeners = bindProviderListeners
|
||||
|
||||
function requestSerializedBuffer(
|
||||
ptyId: string
|
||||
): Promise<{ data: string; cols: number; rows: number } | null> {
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
const requestId = randomUUID()
|
||||
return new Promise((resolve) => {
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timeout)
|
||||
ipcMain.removeListener('pty:serializeBuffer:response', onResponse)
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve(null)
|
||||
}, 750)
|
||||
|
||||
const onResponse = (
|
||||
_event: Electron.IpcMainEvent,
|
||||
args: {
|
||||
requestId?: string
|
||||
snapshot?: { data?: unknown; cols?: unknown; rows?: unknown } | null
|
||||
}
|
||||
): void => {
|
||||
if (args.requestId !== requestId) {
|
||||
return
|
||||
}
|
||||
cleanup()
|
||||
const snapshot = args.snapshot
|
||||
if (
|
||||
snapshot &&
|
||||
typeof snapshot.data === 'string' &&
|
||||
typeof snapshot.cols === 'number' &&
|
||||
typeof snapshot.rows === 'number'
|
||||
) {
|
||||
resolve({ data: snapshot.data, cols: snapshot.cols, rows: snapshot.rows })
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.on('pty:serializeBuffer:response', onResponse)
|
||||
mainWindow.webContents.send('pty:serializeBuffer:request', { requestId, ptyId })
|
||||
})
|
||||
}
|
||||
|
||||
// Kill orphaned PTY processes from previous page loads when the renderer reloads.
|
||||
// Why: only applies to LocalPtyProvider where PTYs live in the Electron main
|
||||
// process and can become orphaned on page reload. Daemon-backed sessions
|
||||
@@ -535,6 +590,28 @@ export function registerPtyHandlers(
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
listProcesses: async () => {
|
||||
const providerSessions = await Promise.all([
|
||||
localProvider.listProcesses(),
|
||||
...Array.from(sshProviders.values(), (provider) => provider.listProcesses().catch(() => []))
|
||||
])
|
||||
return providerSessions.flat()
|
||||
},
|
||||
serializeBuffer: (ptyId) => {
|
||||
// Why: mobile xterm must start from the desktop xterm's exact screen
|
||||
// state and dimensions before live TUI chunks can render correctly.
|
||||
return requestSerializedBuffer(ptyId)
|
||||
},
|
||||
getSize: (ptyId) => ptySizes.get(ptyId) ?? null,
|
||||
resize: (ptyId, cols, rows) => {
|
||||
try {
|
||||
ptySizes.set(ptyId, { cols, rows })
|
||||
getProviderForPty(ptyId).resize(ptyId, cols, rows)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -691,6 +768,12 @@ export function registerPtyHandlers(
|
||||
if (effectiveShellOverride !== undefined) {
|
||||
spawnOptions.shellOverride = effectiveShellOverride
|
||||
}
|
||||
if (effectiveSessionId !== undefined) {
|
||||
// Why: daemon PTYs can emit prompt/startup bytes before spawn()
|
||||
// resolves. Runtime headless snapshots need the real pane geometry
|
||||
// for those early bytes; otherwise they default to 80x24 and wrap TUIs.
|
||||
ptySizes.set(effectiveSessionId, { cols: args.cols, rows: args.rows })
|
||||
}
|
||||
if (process.platform === 'win32' && !args.connectionId) {
|
||||
// Why: the renderer only models PowerShell as one shell family. Thread
|
||||
// the persisted implementation choice through spawnOptions so both the
|
||||
@@ -704,16 +787,11 @@ export function registerPtyHandlers(
|
||||
try {
|
||||
result = await provider.spawn(spawnOptions)
|
||||
} catch (err) {
|
||||
if (effectiveSessionId !== undefined) {
|
||||
ptySizes.delete(effectiveSessionId)
|
||||
}
|
||||
// Why: when buildPtyHostEnv materialized a Pi overlay for this id
|
||||
// but provider.spawn failed, the overlay would leak. Sweep per-PTY
|
||||
// state for the minted id so it isn't orphaned. Safe to call even
|
||||
// when no overlay was created (clearProviderPtyState is a no-op in
|
||||
// that case).
|
||||
//
|
||||
// Only clean up when we MINTED the id in this request. Caller-supplied
|
||||
// ids may correspond to existing PTYs whose state (OpenCode hooks, Pi
|
||||
// overlay, agent-hook pane caches) we MUST NOT clear on a retry/attach
|
||||
// failure.
|
||||
// but provider.spawn failed, the overlay would leak.
|
||||
if (isMintedSessionId && effectiveSessionId !== undefined) {
|
||||
clearProviderPtyState(effectiveSessionId)
|
||||
}
|
||||
@@ -723,6 +801,14 @@ export function registerPtyHandlers(
|
||||
if (preAllocatedHandle) {
|
||||
runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||||
}
|
||||
ptySizes.set(result.id, { cols: args.cols, rows: args.rows })
|
||||
if (
|
||||
typeof args.worktreeId === 'string' &&
|
||||
args.worktreeId.length > 0 &&
|
||||
args.worktreeId.length <= 512
|
||||
) {
|
||||
runtime?.registerPty(result.id, args.worktreeId)
|
||||
}
|
||||
if (isClaudeLaunch) {
|
||||
markClaudePtySpawned(result.id)
|
||||
}
|
||||
@@ -789,7 +875,17 @@ export function registerPtyHandlers(
|
||||
// empty acknowledgement message back to the renderer.
|
||||
ipcMain.removeAllListeners('pty:resize')
|
||||
ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
|
||||
// Why: after a desktop-fit override change, the desktop renderer's
|
||||
// re-render cascade runs safeFit on ALL panes (not just the affected
|
||||
// one). Background-tab panes get measured at full-width (214) instead
|
||||
// of their correct split width. Suppressing ALL pty:resize during
|
||||
// this window prevents the cascade from corrupting PTY dimensions.
|
||||
if (runtime?.isResizeSuppressed()) {
|
||||
return
|
||||
}
|
||||
ptySizes.set(args.id, { cols: args.cols, rows: args.rows })
|
||||
getProviderForPty(args.id).resize(args.id, args.cols, args.rows)
|
||||
runtime?.onExternalPtyResize(args.id, args.cols, args.rows)
|
||||
})
|
||||
|
||||
// Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold restore
|
||||
|
||||
@@ -251,7 +251,7 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerFeedbackHandlersMock).toHaveBeenCalled()
|
||||
expect(registerStatsHandlersMock).toHaveBeenCalledWith(stats)
|
||||
expect(registerMemoryHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store, runtime)
|
||||
expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled()
|
||||
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerSessionHandlersMock).toHaveBeenCalledWith(store)
|
||||
|
||||
@@ -80,7 +80,7 @@ export function registerCoreHandlers(
|
||||
registerExportHandlers()
|
||||
registerStatsHandlers(stats)
|
||||
registerMemoryHandlers(store)
|
||||
registerNotificationHandlers(store)
|
||||
registerNotificationHandlers(store, runtime)
|
||||
registerDeveloperPermissionHandlers()
|
||||
registerSettingsHandlers(store)
|
||||
registerTelemetryHandlers()
|
||||
|
||||
@@ -20,4 +20,35 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
||||
ipcMain.handle('runtime:getStatus', (): RuntimeStatus => {
|
||||
return runtime.getStatus()
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('runtime:getTerminalFitOverrides')
|
||||
ipcMain.handle(
|
||||
'runtime:getTerminalFitOverrides',
|
||||
(): { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] => {
|
||||
const overrides = runtime.getAllTerminalFitOverrides()
|
||||
return Array.from(overrides.entries()).map(([ptyId, override]) => ({
|
||||
ptyId,
|
||||
...override
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
// Why: the desktop "Restore" button sets the display mode to 'desktop' and
|
||||
// applies it, which restores the PTY to its original dimensions and emits
|
||||
// a 'resized' event to any active mobile subscriber. This uses the same
|
||||
// code path as the mobile toggle button (terminal.setDisplayMode RPC).
|
||||
ipcMain.removeHandler('runtime:restoreTerminalFit')
|
||||
ipcMain.handle('runtime:restoreTerminalFit', (_event, args: { ptyId: string }) => {
|
||||
const override = runtime.getTerminalFitOverride(args.ptyId)
|
||||
if (!override) {
|
||||
return { restored: false }
|
||||
}
|
||||
try {
|
||||
runtime.setMobileDisplayMode(args.ptyId, 'desktop')
|
||||
runtime.applyMobileDisplayMode(args.ptyId)
|
||||
return { restored: true }
|
||||
} catch {
|
||||
return { restored: false }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -210,13 +210,15 @@ __orca_restore_attribution_path() {
|
||||
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_attribution_path
|
||||
# Why: emit OSC 133;A only after the user's startup hooks finish so Orca knows
|
||||
# the prompt is actually ready for a long startup command paste.
|
||||
# Why: zsh precmd runs before the prompt is drawn and before zle owns input,
|
||||
# which can double-echo startup commands. line-init fires when zle is ready.
|
||||
if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then
|
||||
__orca_prompt_mark() {
|
||||
printf "\\033]133;A\\007"
|
||||
}
|
||||
precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark)
|
||||
autoload -Uz add-zle-hook-widget
|
||||
zle -N __orca_prompt_mark
|
||||
add-zle-hook-widget line-init __orca_prompt_mark
|
||||
fi
|
||||
`
|
||||
const bashRc = getBashShellReadyRcfileContent()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Why: per-device tokens replace the shared runtime auth token for WebSocket
|
||||
// (mobile) connections. Each paired device gets its own revocable token so
|
||||
// compromising one device doesn't expose others. The registry is a simple
|
||||
// JSON file with hardened permissions matching the runtime metadata pattern.
|
||||
import { randomBytes, randomUUID } from 'crypto'
|
||||
import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const DEVICE_REGISTRY_FILENAME = 'orca-devices.json'
|
||||
|
||||
export type DeviceEntry = {
|
||||
deviceId: string
|
||||
name: string
|
||||
token: string
|
||||
pairedAt: number
|
||||
lastSeenAt: number
|
||||
}
|
||||
|
||||
export class DeviceRegistry {
|
||||
private readonly registryPath: string
|
||||
private devices: DeviceEntry[] = []
|
||||
|
||||
constructor(userDataPath: string) {
|
||||
this.registryPath = join(userDataPath, DEVICE_REGISTRY_FILENAME)
|
||||
this.load()
|
||||
}
|
||||
|
||||
addDevice(name: string): DeviceEntry {
|
||||
const entry: DeviceEntry = {
|
||||
deviceId: randomUUID(),
|
||||
name,
|
||||
token: randomBytes(24).toString('hex'),
|
||||
pairedAt: Date.now(),
|
||||
lastSeenAt: 0
|
||||
}
|
||||
this.devices.push(entry)
|
||||
this.save()
|
||||
return entry
|
||||
}
|
||||
|
||||
removeDevice(deviceId: string): boolean {
|
||||
const before = this.devices.length
|
||||
this.devices = this.devices.filter((d) => d.deviceId !== deviceId)
|
||||
if (this.devices.length < before) {
|
||||
this.save()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
listDevices(): readonly DeviceEntry[] {
|
||||
return this.devices
|
||||
}
|
||||
|
||||
validateToken(token: string): DeviceEntry | null {
|
||||
return this.devices.find((d) => d.token === token) ?? null
|
||||
}
|
||||
|
||||
updateLastSeen(deviceId: string): void {
|
||||
const device = this.devices.find((d) => d.deviceId === deviceId)
|
||||
if (device) {
|
||||
device.lastSeenAt = Date.now()
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (!existsSync(this.registryPath)) {
|
||||
this.devices = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.devices = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[]
|
||||
} catch {
|
||||
this.devices = []
|
||||
}
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
writeFileSync(this.registryPath, JSON.stringify(this.devices, null, 2), { mode: 0o600 })
|
||||
chmodSync(this.registryPath, 0o600)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Why: the E2EE keypair enables application-layer encryption between mobile
|
||||
// and desktop over plain ws://. The public key is embedded in the QR pairing
|
||||
// offer so the mobile client can derive a shared secret via ECDH.
|
||||
import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import nacl from 'tweetnacl'
|
||||
|
||||
const KEYPAIR_FILENAME = 'orca-e2ee-keypair.json'
|
||||
const KEYPAIR_VERSION = 1
|
||||
|
||||
type KeypairFile = {
|
||||
v: number
|
||||
publicKeyB64: string
|
||||
secretKeyB64: string
|
||||
}
|
||||
|
||||
export type E2EEKeypair = {
|
||||
publicKey: Uint8Array
|
||||
secretKey: Uint8Array
|
||||
publicKeyB64: string
|
||||
}
|
||||
|
||||
export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair {
|
||||
const filePath = join(userDataPath, KEYPAIR_FILENAME)
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8'))
|
||||
if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) {
|
||||
const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64'))
|
||||
const secretKey = Uint8Array.from(Buffer.from(raw.secretKeyB64, 'base64'))
|
||||
if (publicKey.length === 32 && secretKey.length === 32) {
|
||||
return { publicKey, secretKey, publicKeyB64: raw.publicKeyB64 }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed file — regenerate below.
|
||||
}
|
||||
}
|
||||
|
||||
const keypair = nacl.box.keyPair()
|
||||
const publicKeyB64 = Buffer.from(keypair.publicKey).toString('base64')
|
||||
const secretKeyB64 = Buffer.from(keypair.secretKey).toString('base64')
|
||||
|
||||
const data: KeypairFile = { v: KEYPAIR_VERSION, publicKeyB64, secretKeyB64 }
|
||||
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8')
|
||||
chmodSync(filePath, 0o600)
|
||||
|
||||
return { publicKey: keypair.publicKey, secretKey: keypair.secretKey, publicKeyB64 }
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Integration test for the mobile-fit override flow.
|
||||
* Tests the full lifecycle: mobile-fit → restore → verify PTY resized.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/worktree-a',
|
||||
head: 'abc',
|
||||
branch: 'feature/foo',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
createSetupRunnerScript: vi.fn(),
|
||||
getEffectiveHooks: vi.fn().mockReturnValue(null),
|
||||
runHook: vi.fn().mockResolvedValue({ success: true, output: '' })
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/worktree-logic', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('../ipc/filesystem-auth', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
|
||||
getBranchConflictKind: vi.fn().mockResolvedValue(null),
|
||||
getGitUsername: vi.fn().mockReturnValue('')
|
||||
}
|
||||
})
|
||||
|
||||
const store = {
|
||||
getRepo: () => ({
|
||||
id: 'repo-1',
|
||||
path: '/tmp/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
}),
|
||||
getRepos: () => [store.getRepo()],
|
||||
addRepo: () => {},
|
||||
updateRepo: () => undefined as never,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
getGitHubCache: () => ({ pr: {}, issue: {} }),
|
||||
setWorktreeMeta: () => undefined as never,
|
||||
removeWorktreeMeta: () => {},
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/tmp/workspaces',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
})
|
||||
}
|
||||
|
||||
describe('fit override integration', () => {
|
||||
it('full lifecycle: fit → getSize → restore → verify PTY dims', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const currentSize = { cols: 150, rows: 40 }
|
||||
const resizes: { ptyId: string; cols: number; rows: number }[] = []
|
||||
const notifications: { ptyId: string; mode: string; cols: number; rows: number }[] = []
|
||||
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
resize: (ptyId, cols, rows) => {
|
||||
currentSize.cols = cols
|
||||
currentSize.rows = rows
|
||||
resizes.push({ ptyId, cols, rows })
|
||||
return true
|
||||
},
|
||||
getSize: () => ({ ...currentSize })
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: (ptyId, mode, cols, rows) => {
|
||||
notifications.push({ ptyId, mode, cols, rows })
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate a synced leaf (mounted desktop pane)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Terminal',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
console.log('=== Step 1: Initial state ===')
|
||||
console.log('PTY size:', currentSize)
|
||||
expect(currentSize).toEqual({ cols: 150, rows: 40 })
|
||||
|
||||
console.log('\n=== Step 2: Mobile fit to 45x20 ===')
|
||||
const fitResult = runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20)
|
||||
console.log('Fit result:', fitResult)
|
||||
console.log('PTY size after fit:', currentSize)
|
||||
console.log('Override:', runtime.getTerminalFitOverride('pty-1'))
|
||||
expect(currentSize).toEqual({ cols: 45, rows: 20 })
|
||||
expect(fitResult.previousCols).toBe(150)
|
||||
expect(fitResult.previousRows).toBe(40)
|
||||
|
||||
console.log('\n=== Step 3: Desktop Restore (via IPC handler path) ===')
|
||||
// Simulate what runtime:restoreTerminalFit IPC handler does
|
||||
const override = runtime.getTerminalFitOverride('pty-1')
|
||||
expect(override).not.toBeNull()
|
||||
const restoreResult = runtime.resizeForClient('pty-1', 'restore', override!.clientId)
|
||||
console.log('Restore result:', restoreResult)
|
||||
console.log('PTY size after restore:', currentSize)
|
||||
console.log('Override after restore:', runtime.getTerminalFitOverride('pty-1'))
|
||||
expect(currentSize).toEqual({ cols: 150, rows: 40 })
|
||||
expect(restoreResult.mode).toBe('desktop-fit')
|
||||
|
||||
console.log('\n=== Step 4: Verify all resizes ===')
|
||||
console.log('All resize calls:', resizes)
|
||||
expect(resizes).toEqual([
|
||||
{ ptyId: 'pty-1', cols: 45, rows: 20 },
|
||||
{ ptyId: 'pty-1', cols: 150, rows: 40 }
|
||||
])
|
||||
|
||||
console.log('\n=== Step 5: Verify notifications ===')
|
||||
console.log('All notifications:', notifications)
|
||||
expect(notifications).toEqual([
|
||||
{ ptyId: 'pty-1', mode: 'mobile-fit', cols: 45, rows: 20 },
|
||||
{ ptyId: 'pty-1', mode: 'desktop-fit', cols: 150, rows: 40 }
|
||||
])
|
||||
|
||||
console.log('\n=== Step 6: Mobile restore via RPC path ===')
|
||||
// Re-fit, then restore via the mobile RPC handler path
|
||||
runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20)
|
||||
expect(currentSize).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// This is what terminal.resizeForClient RPC handler does
|
||||
const mobileRestore = runtime.resizeForClient('pty-1', 'restore', 'client-phone')
|
||||
console.log('Mobile restore result:', mobileRestore)
|
||||
console.log('PTY size after mobile restore:', currentSize)
|
||||
expect(currentSize).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('restore resizes PTY even with mounted leaf (the bug fix)', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
let ptySize = { cols: 120, rows: 35 }
|
||||
const resizes: string[] = []
|
||||
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
resize: (ptyId, cols, rows) => {
|
||||
ptySize = { cols, rows }
|
||||
resizes.push(`${ptyId}:${cols}x${rows}`)
|
||||
return true
|
||||
},
|
||||
getSize: () => ({ ...ptySize })
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn()
|
||||
})
|
||||
|
||||
// Synced leaf = mounted desktop pane
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Mobile fit
|
||||
runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-a', 42, 18)
|
||||
expect(ptySize).toEqual({ cols: 42, rows: 18 })
|
||||
|
||||
// Restore — THIS is the critical assertion.
|
||||
// Before the fix, mounted leaves skipped the PTY resize.
|
||||
runtime.resizeForClient('pty-1', 'restore', 'phone-a')
|
||||
expect(ptySize).toEqual({ cols: 120, rows: 35 })
|
||||
expect(resizes).toEqual(['pty-1:42x18', 'pty-1:120x35'])
|
||||
})
|
||||
|
||||
it('disconnect auto-restore also resizes PTY', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
let ptySize = { cols: 100, rows: 30 }
|
||||
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
resize: (_ptyId, cols, rows) => {
|
||||
ptySize = { cols, rows }
|
||||
return true
|
||||
},
|
||||
getSize: () => ({ ...ptySize })
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn()
|
||||
})
|
||||
|
||||
runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-disconnect', 45, 20)
|
||||
expect(ptySize).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Simulate WS disconnect
|
||||
runtime.onClientDisconnected('phone-disconnect')
|
||||
expect(ptySize).toEqual({ cols: 100, rows: 30 })
|
||||
expect(runtime.getTerminalFitOverride('pty-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,471 @@
|
||||
/* oxlint-disable max-lines -- Why: integration tests cover the full mobile subscribe lifecycle across many scenarios; splitting would scatter related assertions. */
|
||||
/**
|
||||
* Integration tests for the server-authoritative mobile subscribe lifecycle.
|
||||
* Tests handleMobileSubscribe, handleMobileUnsubscribe, applyMobileDisplayMode,
|
||||
* debounced restore, inline restore on timer cancel, and cleanup paths.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/worktree-a',
|
||||
head: 'abc',
|
||||
branch: 'feature/foo',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
createSetupRunnerScript: vi.fn(),
|
||||
getEffectiveHooks: vi.fn().mockReturnValue(null),
|
||||
runHook: vi.fn().mockResolvedValue({ success: true, output: '' })
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/worktree-logic', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('../ipc/filesystem-auth', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
|
||||
getBranchConflictKind: vi.fn().mockResolvedValue(null),
|
||||
getGitUsername: vi.fn().mockReturnValue('')
|
||||
}
|
||||
})
|
||||
|
||||
const store = {
|
||||
getRepo: () => ({
|
||||
id: 'repo-1',
|
||||
path: '/tmp/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
}),
|
||||
getRepos: () => [store.getRepo()],
|
||||
addRepo: () => {},
|
||||
updateRepo: () => undefined as never,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
getGitHubCache: () => ({ pr: {}, issue: {} }),
|
||||
setWorktreeMeta: () => undefined as never,
|
||||
removeWorktreeMeta: () => {},
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/tmp/workspaces',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
})
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const ptySizes = new Map<string, { cols: number; rows: number }>()
|
||||
ptySizes.set('pty-1', { cols: 150, rows: 40 })
|
||||
ptySizes.set('pty-2', { cols: 120, rows: 35 })
|
||||
ptySizes.set('pty-3', { cols: 100, rows: 30 })
|
||||
|
||||
const resizes: { ptyId: string; cols: number; rows: number }[] = []
|
||||
const notifications: { ptyId: string; mode: string; cols: number; rows: number }[] = []
|
||||
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
resize: (ptyId, cols, rows) => {
|
||||
ptySizes.set(ptyId, { cols, rows })
|
||||
resizes.push({ ptyId, cols, rows })
|
||||
return true
|
||||
},
|
||||
getSize: (ptyId) => ptySizes.get(ptyId) ?? null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: (ptyId, mode, cols, rows) => {
|
||||
notifications.push({ ptyId, mode, cols, rows })
|
||||
}
|
||||
})
|
||||
|
||||
return { runtime, ptySizes, resizes, notifications }
|
||||
}
|
||||
|
||||
describe('mobile subscribe integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('handleMobileSubscribe resizes PTY to phone dims', () => {
|
||||
const { runtime, ptySizes, resizes, notifications } = createRuntime()
|
||||
|
||||
const result = runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
expect(resizes).toEqual([{ ptyId: 'pty-1', cols: 45, rows: 20 }])
|
||||
expect(notifications).toEqual([{ ptyId: 'pty-1', mode: 'mobile-fit', cols: 45, rows: 20 }])
|
||||
expect(runtime.isMobileSubscriberActive('pty-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('handleMobileSubscribe skips resize when mode is desktop', () => {
|
||||
const { runtime, ptySizes, resizes } = createRuntime()
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
|
||||
const result = runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(resizes).toEqual([])
|
||||
})
|
||||
|
||||
it('handleMobileSubscribe skips resize when no viewport provided', () => {
|
||||
const { runtime, ptySizes, resizes } = createRuntime()
|
||||
|
||||
const result = runtime.handleMobileSubscribe('pty-1', 'client-a')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(resizes).toEqual([])
|
||||
})
|
||||
|
||||
it('handleMobileUnsubscribe restores PTY after 300ms debounce in auto mode', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
// Not yet restored
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('handleMobileUnsubscribe does not restore in phone mode', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.setMobileDisplayMode('pty-1', 'phone')
|
||||
// In phone mode, handleMobileSubscribe still resizes because mode is 'phone'
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
vi.advanceTimersByTime(1000)
|
||||
// Still at phone dims — no restore for phone mode
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
})
|
||||
|
||||
// TODO: inline restore on re-subscribe not yet implemented
|
||||
it.skip('re-subscribe within 300ms cancels debounce timer and inline-restores old PTY', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
// Re-subscribe to a different terminal before the timer fires
|
||||
vi.advanceTimersByTime(100)
|
||||
runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
// pty-1 was inline-restored when pty-2 subscribed (timer cancelled + immediate restore)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(ptySizes.get('pty-2')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Advancing past the 300ms debounce should not cause a second restore
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
// TODO: inline restore on re-subscribe not yet implemented
|
||||
it.skip('rapid A→B→C tab navigation: inline restore of A when B subscribes', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
// Subscribe to A
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Unsubscribe A, subscribe B — A's pending timer is cancelled, A gets inline restore
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
// pty-1 should be restored inline (not waiting for timer)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(ptySizes.get('pty-2')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Unsubscribe B, subscribe C — B's pending timer cancelled, B gets inline restore
|
||||
runtime.handleMobileUnsubscribe('pty-2', 'client-a')
|
||||
runtime.handleMobileSubscribe('pty-3', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 })
|
||||
expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Verify final state
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 })
|
||||
expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 })
|
||||
})
|
||||
|
||||
it('preserves previousDims across re-subscribes to same terminal', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
// First subscribe at desktop 150x40
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Re-subscribe to the same terminal (e.g., after reconnect)
|
||||
// The PTY is already at 45x20, but previousDims should still be 150x40
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
// Unsubscribe and let restore fire
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
// Should restore to original desktop dims, not 45x20
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('clamps viewport to valid range', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 10, rows: 3 })
|
||||
// Should clamp to minimum 20x8
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 20, rows: 8 })
|
||||
})
|
||||
|
||||
describe('display mode', () => {
|
||||
it('defaults to auto', () => {
|
||||
const { runtime } = createRuntime()
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||||
})
|
||||
|
||||
it('set/get round-trip', () => {
|
||||
const { runtime } = createRuntime()
|
||||
runtime.setMobileDisplayMode('pty-1', 'phone')
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('phone')
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('desktop')
|
||||
|
||||
// Setting to 'auto' deletes the entry (same as default)
|
||||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyMobileDisplayMode', () => {
|
||||
it('desktop mode restores PTY when currently phone-fitted', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
const resizeEvents: unknown[] = []
|
||||
runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event))
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(resizeEvents).toHaveLength(1)
|
||||
expect(resizeEvents[0]).toMatchObject({
|
||||
cols: 150,
|
||||
rows: 40,
|
||||
displayMode: 'desktop',
|
||||
reason: 'mode-change'
|
||||
})
|
||||
})
|
||||
|
||||
it('auto mode re-fits PTY when subscriber exists and not phone-fitted', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
// Switch to desktop (restores to 150x40)
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
|
||||
const resizeEvents: unknown[] = []
|
||||
runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event))
|
||||
|
||||
// Switch back to auto (should re-fit to phone dims)
|
||||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
expect(resizeEvents).toHaveLength(1)
|
||||
expect(resizeEvents[0]).toMatchObject({
|
||||
displayMode: 'auto',
|
||||
reason: 'mode-change'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup paths', () => {
|
||||
it('onClientDisconnected restores all PTYs immediately (no debounce)', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
runtime.onClientDisconnected('client-a')
|
||||
|
||||
// Both PTYs restored immediately
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 })
|
||||
expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false)
|
||||
expect(runtime.isMobileSubscriberActive('pty-2')).toBe(false)
|
||||
})
|
||||
|
||||
it('onClientDisconnected cancels pending restore timers', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
// Timer is pending
|
||||
|
||||
runtime.onClientDisconnected('client-a')
|
||||
// Timer should be cancelled, PTY already restored by disconnect handler
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('onPtyExit cleans up mobileSubscribers and pending timers', () => {
|
||||
const { runtime } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
// Timer pending for pty-1
|
||||
|
||||
runtime.onPtyExit('pty-1', 0)
|
||||
expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false)
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||||
|
||||
// Timer should have been cancelled — no crash from resizing a dead PTY
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
it('onPtyExit does not cancel timers for other PTYs', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
// pty-2 exits — should not affect pty-1's pending restore
|
||||
runtime.onPtyExit('pty-2', 0)
|
||||
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resize listener system', () => {
|
||||
it('subscribe/unsubscribe lifecycle', () => {
|
||||
const { runtime } = createRuntime()
|
||||
const events: unknown[] = []
|
||||
const unsubscribe = runtime.subscribeToTerminalResize('pty-1', (e) => events.push(e))
|
||||
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
|
||||
expect(events.length).toBeGreaterThan(0)
|
||||
|
||||
const countBefore = events.length
|
||||
unsubscribe()
|
||||
|
||||
// After unsubscribe, no more events
|
||||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(events.length).toBe(countBefore)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onExternalPtyResize', () => {
|
||||
it('updates previousCols when desktop renderer resizes PTY after desktop restore', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
// Toggle to desktop — restores to previousCols (150x40)
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
|
||||
// Simulate desktop renderer's safeFit correcting to split-pane width
|
||||
runtime.onExternalPtyResize('pty-1', 105, 40)
|
||||
|
||||
// Toggle back to auto — should capture previousCols=105 (not 150)
|
||||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Toggle to desktop again — should restore to 105 (the corrected value)
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
|
||||
})
|
||||
|
||||
it('uses lastRendererSize for previousCols on first subscribe', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
// Simulate: PTY spawned at 214 (ptySizes), but renderer already fit to 105
|
||||
ptySizes.set('pty-1', { cols: 214, rows: 72 })
|
||||
runtime.onExternalPtyResize('pty-1', 105, 40)
|
||||
|
||||
// First mobile subscribe — should use rendererSize (105) not ptySizes (214)
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Toggle to desktop — should restore to 105, not 214
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
|
||||
})
|
||||
|
||||
it('does not update previousCols when PTY is phone-fitted', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
// PTY is phone-fitted (wasResizedToPhone=true) — external resize should not
|
||||
// overwrite previousCols with phone dims
|
||||
runtime.onExternalPtyResize('pty-1', 45, 20)
|
||||
|
||||
// Toggle to desktop — should still restore to original 150x40
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('old resizeForClient still works alongside new system', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
// Old flow: explicit resizeForClient
|
||||
const fitResult = runtime.resizeForClient('pty-1', 'mobile-fit', 'client-old', 45, 20)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
expect(fitResult.mode).toBe('mobile-fit')
|
||||
|
||||
// Old flow: restore
|
||||
const restoreResult = runtime.resizeForClient('pty-1', 'restore', 'client-old')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(restoreResult.mode).toBe('desktop-fit')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorktreeMeta } from '../../shared/types'
|
||||
import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree'
|
||||
import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks'
|
||||
import {
|
||||
createSetupRunnerScript,
|
||||
getEffectiveHooks,
|
||||
runHook,
|
||||
shouldRunSetupForCreate
|
||||
} from '../hooks'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
@@ -39,7 +44,12 @@ vi.mock('../git/worktree', () => ({
|
||||
vi.mock('../hooks', () => ({
|
||||
createSetupRunnerScript: vi.fn(),
|
||||
getEffectiveHooks: vi.fn().mockReturnValue(null),
|
||||
runHook: vi.fn().mockResolvedValue({ success: true, output: '' })
|
||||
runHook: vi.fn().mockResolvedValue({ success: true, output: '' }),
|
||||
shouldRunSetupForCreate: vi
|
||||
.fn()
|
||||
.mockImplementation((_repo: never, decision: string) => decision === 'run'),
|
||||
getEffectiveSetupRunPolicy: vi.fn().mockReturnValue('auto'),
|
||||
hasHooksFile: vi.fn().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/worktree-logic', async (importOriginal) => {
|
||||
@@ -77,6 +87,8 @@ afterEach(() => {
|
||||
vi.mocked(createSetupRunnerScript).mockReset()
|
||||
vi.mocked(getEffectiveHooks).mockReset()
|
||||
vi.mocked(runHook).mockReset()
|
||||
vi.mocked(shouldRunSetupForCreate).mockReset()
|
||||
vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => decision === 'run')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue(null)
|
||||
computeWorktreePathMock.mockReset()
|
||||
ensurePathWithinWorkspaceMock.mockReset()
|
||||
@@ -156,6 +168,7 @@ const store = {
|
||||
...meta
|
||||
}) as never,
|
||||
removeWorktreeMeta: () => {},
|
||||
getGitHubCache: () => undefined as never,
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/tmp/workspaces',
|
||||
nestWorkspaces: false,
|
||||
@@ -763,7 +776,11 @@ describe('OrcaRuntimeService', () => {
|
||||
repo: 'repo',
|
||||
path: '/tmp/worktree-a',
|
||||
branch: 'feature/foo',
|
||||
displayName: 'foo',
|
||||
linkedIssue: 123,
|
||||
linkedPR: null,
|
||||
isPinned: false,
|
||||
status: 'active',
|
||||
unread: false,
|
||||
liveTerminalCount: 1,
|
||||
hasAttachedPty: true,
|
||||
@@ -926,7 +943,9 @@ describe('OrcaRuntimeService', () => {
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn()
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
@@ -1001,7 +1020,9 @@ describe('OrcaRuntimeService', () => {
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn()
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
@@ -1092,7 +1113,9 @@ describe('OrcaRuntimeService', () => {
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn()
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn()
|
||||
})
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/cli-worktree')
|
||||
@@ -1150,6 +1173,7 @@ describe('OrcaRuntimeService', () => {
|
||||
return nextMeta
|
||||
},
|
||||
removeWorktreeMeta: () => {},
|
||||
getGitHubCache: () => undefined as never,
|
||||
getSettings: () => ({
|
||||
workspaceDir: 'C:\\workspaces',
|
||||
nestWorkspaces: false,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ export type RpcSuccess = {
|
||||
id: string
|
||||
ok: true
|
||||
result: unknown
|
||||
streaming?: true
|
||||
_meta: RpcEnvelopeMeta
|
||||
}
|
||||
|
||||
@@ -76,10 +77,53 @@ export function defineMethod<TSchema extends ZodType | null>(
|
||||
}
|
||||
}
|
||||
|
||||
export type RpcRegistry = ReadonlyMap<string, RpcMethod>
|
||||
export type RpcStreamingHandler<TParams> = (
|
||||
params: TParams,
|
||||
ctx: RpcContext,
|
||||
emit: (result: unknown) => void
|
||||
) => Promise<void>
|
||||
|
||||
export function buildRegistry(methods: readonly RpcMethod[]): RpcRegistry {
|
||||
const registry = new Map<string, RpcMethod>()
|
||||
// Why: streaming methods emit multiple responses over a long-lived connection.
|
||||
// The `stream` flag lets the dispatcher distinguish them from one-shot methods
|
||||
// and route them to the emit-based call path instead of the Promise-based one.
|
||||
export type RpcStreamingMethod = {
|
||||
readonly name: string
|
||||
readonly params: ZodType | null
|
||||
readonly stream: true
|
||||
readonly handler: (
|
||||
params: unknown,
|
||||
ctx: RpcContext,
|
||||
emit: (result: unknown) => void
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
type DefineStreamingMethodSpec<TSchema extends ZodType | null> = {
|
||||
name: string
|
||||
params: TSchema
|
||||
handler: RpcStreamingHandler<TSchema extends ZodType ? TSchema['_output'] : void>
|
||||
}
|
||||
|
||||
export function defineStreamingMethod<TSchema extends ZodType | null>(
|
||||
spec: DefineStreamingMethodSpec<TSchema>
|
||||
): RpcStreamingMethod {
|
||||
return {
|
||||
name: spec.name,
|
||||
params: spec.params,
|
||||
stream: true,
|
||||
handler: spec.handler as RpcStreamingMethod['handler']
|
||||
}
|
||||
}
|
||||
|
||||
export type RpcAnyMethod = RpcMethod | RpcStreamingMethod
|
||||
|
||||
export function isStreamingMethod(method: RpcAnyMethod): method is RpcStreamingMethod {
|
||||
return 'stream' in method && method.stream === true
|
||||
}
|
||||
|
||||
export type RpcRegistry = ReadonlyMap<string, RpcAnyMethod>
|
||||
|
||||
export function buildRegistry(methods: readonly RpcAnyMethod[]): RpcRegistry {
|
||||
const registry = new Map<string, RpcAnyMethod>()
|
||||
for (const method of methods) {
|
||||
if (registry.has(method.name)) {
|
||||
throw new Error(`duplicate_rpc_method:${method.name}`)
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
ZodError,
|
||||
buildRegistry,
|
||||
formatZodError,
|
||||
isStreamingMethod,
|
||||
type RpcAnyMethod,
|
||||
type RpcEnvelopeMeta,
|
||||
type RpcMethod,
|
||||
type RpcRegistry,
|
||||
type RpcRequest,
|
||||
type RpcResponse
|
||||
@@ -18,7 +19,7 @@ import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
|
||||
export type DispatcherOptions = {
|
||||
runtime: OrcaRuntimeService
|
||||
methods?: readonly RpcMethod[]
|
||||
methods?: readonly RpcAnyMethod[]
|
||||
}
|
||||
|
||||
export class RpcDispatcher {
|
||||
@@ -42,39 +43,110 @@ export class RpcDispatcher {
|
||||
)
|
||||
}
|
||||
|
||||
let parsedParams: unknown
|
||||
if (method.params === null) {
|
||||
parsedParams = undefined
|
||||
} else {
|
||||
const rawParams = request.params ?? {}
|
||||
const result = method.params.safeParse(rawParams)
|
||||
if (!result.success) {
|
||||
return errorResponse(request.id, meta, 'invalid_argument', formatZodError(result.error))
|
||||
}
|
||||
parsedParams = result.data
|
||||
const parsedParams = this.parseParams(request, method, meta)
|
||||
if (parsedParams.error) {
|
||||
return parsedParams.error
|
||||
}
|
||||
|
||||
// Why: streaming methods are not supported over one-shot transports like
|
||||
// Unix sockets. They require a reply function that can be called multiple
|
||||
// times, which is only available via dispatchStreaming.
|
||||
if (isStreamingMethod(method)) {
|
||||
return errorResponse(
|
||||
request.id,
|
||||
meta,
|
||||
'method_not_supported',
|
||||
`Method ${request.method} requires a streaming transport`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await method.handler(parsedParams, {
|
||||
const result = await method.handler(parsedParams.value, {
|
||||
runtime: this.runtime,
|
||||
signal: options?.signal
|
||||
})
|
||||
return successResponse(request.id, meta, result)
|
||||
} catch (error) {
|
||||
// Why: browser methods throw BrowserError with a structured `code`;
|
||||
// every other runtime error has a plain-message code. Routing by method
|
||||
// prefix keeps the mapping a single decision rather than a per-method
|
||||
// flag callers must remember to set.
|
||||
if (request.method.startsWith('browser.')) {
|
||||
return mapBrowserError(request.id, meta, error)
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return errorResponse(request.id, meta, 'invalid_argument', formatZodError(error))
|
||||
}
|
||||
return mapRuntimeError(request.id, meta, error)
|
||||
return this.mapError(request, meta, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: streaming dispatch sends multiple responses through the reply callback
|
||||
// instead of returning a single Promise. This enables terminal.subscribe and
|
||||
// other subscription-style methods that push data over time.
|
||||
async dispatchStreaming(request: RpcRequest, reply: (response: string) => void): Promise<void> {
|
||||
const meta = this.meta()
|
||||
const method = this.registry.get(request.method)
|
||||
if (!method) {
|
||||
reply(
|
||||
JSON.stringify(
|
||||
errorResponse(request.id, meta, 'method_not_found', `Unknown method: ${request.method}`)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const parsedParams = this.parseParams(request, method, meta)
|
||||
if (parsedParams.error) {
|
||||
reply(JSON.stringify(parsedParams.error))
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStreamingMethod(method)) {
|
||||
try {
|
||||
const result = await method.handler(parsedParams.value, { runtime: this.runtime })
|
||||
reply(JSON.stringify(successResponse(request.id, meta, result)))
|
||||
} catch (error) {
|
||||
reply(JSON.stringify(this.mapError(request, meta, error)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const emit = (result: unknown): void => {
|
||||
const response = successResponse(request.id, meta, result)
|
||||
response.streaming = true
|
||||
reply(JSON.stringify(response))
|
||||
}
|
||||
|
||||
try {
|
||||
await method.handler(parsedParams.value, { runtime: this.runtime }, emit)
|
||||
} catch (error) {
|
||||
reply(JSON.stringify(this.mapError(request, meta, error)))
|
||||
}
|
||||
}
|
||||
|
||||
private parseParams(
|
||||
request: RpcRequest,
|
||||
method: RpcAnyMethod,
|
||||
meta: RpcEnvelopeMeta
|
||||
): { value: unknown; error?: undefined } | { value?: undefined; error: RpcResponse } {
|
||||
if (method.params === null) {
|
||||
return { value: undefined }
|
||||
}
|
||||
const rawParams = request.params ?? {}
|
||||
const result = method.params.safeParse(rawParams)
|
||||
if (!result.success) {
|
||||
return {
|
||||
error: errorResponse(request.id, meta, 'invalid_argument', formatZodError(result.error))
|
||||
}
|
||||
}
|
||||
return { value: result.data }
|
||||
}
|
||||
|
||||
private mapError(request: RpcRequest, meta: RpcEnvelopeMeta, error: unknown): RpcResponse {
|
||||
// Why: browser methods throw BrowserError with a structured `code`;
|
||||
// every other runtime error has a plain-message code. Routing by method
|
||||
// prefix keeps the mapping a single decision rather than a per-method
|
||||
// flag callers must remember to set.
|
||||
if (request.method.startsWith('browser.')) {
|
||||
return mapBrowserError(request.id, meta, error)
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return errorResponse(request.id, meta, 'invalid_argument', formatZodError(error))
|
||||
}
|
||||
return mapRuntimeError(request.id, meta, error)
|
||||
}
|
||||
|
||||
private meta(): RpcEnvelopeMeta {
|
||||
return { runtimeId: this.runtime.getRuntimeId() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { WebSocket } from 'ws'
|
||||
import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel'
|
||||
import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto'
|
||||
|
||||
function publicKeyToBase64(key: Uint8Array): string {
|
||||
return Buffer.from(key).toString('base64')
|
||||
}
|
||||
|
||||
function createMockWs() {
|
||||
const sent: string[] = []
|
||||
return {
|
||||
OPEN: 1 as const,
|
||||
readyState: 1,
|
||||
send: vi.fn((data: string) => sent.push(data)),
|
||||
close: vi.fn(),
|
||||
sent
|
||||
}
|
||||
}
|
||||
|
||||
function setup(overrides?: Partial<E2EEChannelOptions>) {
|
||||
const serverKeys = generateKeyPair()
|
||||
const clientKeys = generateKeyPair()
|
||||
const ws = createMockWs()
|
||||
const onReady = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
const channel = new E2EEChannel(ws as unknown as WebSocket, {
|
||||
serverSecretKey: serverKeys.secretKey,
|
||||
validateToken: (token) => token === 'valid-token',
|
||||
onReady,
|
||||
onError,
|
||||
...overrides
|
||||
})
|
||||
|
||||
return { channel, ws, serverKeys, clientKeys, onReady, onError }
|
||||
}
|
||||
|
||||
function doHandshake(ctx: ReturnType<typeof setup>) {
|
||||
const hello = JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey)
|
||||
})
|
||||
ctx.channel.handleRawMessage(hello)
|
||||
const sharedKey = deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey)
|
||||
ctx.channel.handleRawMessage(
|
||||
encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), sharedKey)
|
||||
)
|
||||
return sharedKey
|
||||
}
|
||||
|
||||
describe('E2EEChannel', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('handshake', () => {
|
||||
it('completes handshake with valid encrypted auth', () => {
|
||||
const ctx = setup()
|
||||
doHandshake(ctx)
|
||||
|
||||
expect(ctx.onReady).toHaveBeenCalledWith(ctx.channel)
|
||||
expect(ctx.onError).not.toHaveBeenCalled()
|
||||
expect(ctx.channel.deviceToken).toBe('valid-token')
|
||||
|
||||
const readyMsg = JSON.parse(ctx.ws.sent[0]!)
|
||||
expect(readyMsg).toEqual({ type: 'e2ee_ready' })
|
||||
const authMsg = decrypt(
|
||||
ctx.ws.sent[1]!,
|
||||
deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey)
|
||||
)
|
||||
expect(JSON.parse(authMsg!)).toEqual({ type: 'e2ee_authenticated' })
|
||||
})
|
||||
|
||||
it('does not authenticate from plaintext hello alone', () => {
|
||||
const ctx = setup()
|
||||
ctx.channel.handleRawMessage(
|
||||
JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey)
|
||||
})
|
||||
)
|
||||
|
||||
expect(ctx.onReady).not.toHaveBeenCalled()
|
||||
expect(JSON.parse(ctx.ws.sent[0]!)).toEqual({ type: 'e2ee_ready' })
|
||||
})
|
||||
|
||||
it('rejects invalid encrypted token', () => {
|
||||
const ctx = setup()
|
||||
ctx.channel.handleRawMessage(
|
||||
JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey)
|
||||
})
|
||||
)
|
||||
const sharedKey = deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey)
|
||||
ctx.channel.handleRawMessage(
|
||||
encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'bad-token' }), sharedKey)
|
||||
)
|
||||
|
||||
expect(ctx.onError).toHaveBeenCalledWith(4001, 'Unauthorized')
|
||||
expect(ctx.onReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed JSON', () => {
|
||||
const ctx = setup()
|
||||
ctx.channel.handleRawMessage('not json')
|
||||
|
||||
expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid handshake message')
|
||||
})
|
||||
|
||||
it('rejects missing fields', () => {
|
||||
const ctx = setup()
|
||||
ctx.channel.handleRawMessage(JSON.stringify({ type: 'e2ee_hello' }))
|
||||
|
||||
expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid e2ee_hello')
|
||||
})
|
||||
|
||||
it('rejects invalid public key length', () => {
|
||||
const ctx = setup()
|
||||
ctx.channel.handleRawMessage(
|
||||
JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: Buffer.from('short').toString('base64')
|
||||
})
|
||||
)
|
||||
|
||||
expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid public key')
|
||||
})
|
||||
|
||||
it('times out if no hello received', () => {
|
||||
const ctx = setup()
|
||||
|
||||
vi.advanceTimersByTime(10_001)
|
||||
|
||||
expect(ctx.onError).toHaveBeenCalledWith(4002, 'E2EE handshake timeout')
|
||||
})
|
||||
|
||||
it('clears timeout after successful handshake', () => {
|
||||
const ctx = setup()
|
||||
doHandshake(ctx)
|
||||
|
||||
vi.advanceTimersByTime(10_001)
|
||||
|
||||
expect(ctx.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('post-handshake messaging', () => {
|
||||
it('decrypts and forwards messages', () => {
|
||||
const ctx = setup()
|
||||
const sharedKey = doHandshake(ctx)
|
||||
const received: string[] = []
|
||||
|
||||
ctx.channel.onMessage((plaintext) => {
|
||||
received.push(plaintext)
|
||||
})
|
||||
|
||||
const request = '{"id":"rpc-1","method":"status.get"}'
|
||||
ctx.channel.handleRawMessage(encrypt(request, sharedKey))
|
||||
|
||||
expect(received).toEqual([request])
|
||||
})
|
||||
|
||||
it('provides encrypted reply function', () => {
|
||||
const ctx = setup()
|
||||
const sharedKey = doHandshake(ctx)
|
||||
|
||||
ctx.channel.onMessage((_plaintext, encryptedReply) => {
|
||||
encryptedReply('{"id":"rpc-1","ok":true}')
|
||||
})
|
||||
|
||||
ctx.channel.handleRawMessage(encrypt('{"id":"rpc-1","method":"status.get"}', sharedKey))
|
||||
|
||||
// The reply (ws.sent[2], after ready + authenticated) should be encrypted
|
||||
const replyEncrypted = ctx.ws.sent[2]!
|
||||
const replyPlain = decrypt(replyEncrypted, sharedKey)
|
||||
expect(replyPlain).toBe('{"id":"rpc-1","ok":true}')
|
||||
})
|
||||
|
||||
it('silently drops messages with wrong key', () => {
|
||||
const ctx = setup()
|
||||
doHandshake(ctx)
|
||||
const received: string[] = []
|
||||
|
||||
ctx.channel.onMessage((plaintext) => {
|
||||
received.push(plaintext)
|
||||
})
|
||||
|
||||
const attackerKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey)
|
||||
ctx.channel.handleRawMessage(encrypt('attack', attackerKey))
|
||||
|
||||
expect(received).toEqual([])
|
||||
})
|
||||
|
||||
it('closes after too many consecutive decrypt failures', () => {
|
||||
const ctx = setup()
|
||||
doHandshake(ctx)
|
||||
ctx.channel.onMessage(() => {})
|
||||
|
||||
const badKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey)
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
ctx.channel.handleRawMessage(encrypt('bad', badKey))
|
||||
}
|
||||
expect(ctx.onError).not.toHaveBeenCalled()
|
||||
|
||||
ctx.channel.handleRawMessage(encrypt('bad', badKey))
|
||||
expect(ctx.onError).toHaveBeenCalledWith(4003, 'Too many decryption failures')
|
||||
})
|
||||
|
||||
it('resets failure count on successful decrypt', () => {
|
||||
const ctx = setup()
|
||||
const sharedKey = doHandshake(ctx)
|
||||
ctx.channel.onMessage(() => {})
|
||||
|
||||
const badKey = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey)
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
ctx.channel.handleRawMessage(encrypt('bad', badKey))
|
||||
}
|
||||
|
||||
// Successful decrypt resets the counter
|
||||
ctx.channel.handleRawMessage(encrypt('good', sharedKey))
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
ctx.channel.handleRawMessage(encrypt('bad', badKey))
|
||||
}
|
||||
expect(ctx.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cross-compatibility', () => {
|
||||
it('desktop encrypt is decryptable by desktop decrypt (sanity)', () => {
|
||||
const a = generateKeyPair()
|
||||
const b = generateKeyPair()
|
||||
const sharedA = deriveSharedKey(a.secretKey, b.publicKey)
|
||||
const sharedB = deriveSharedKey(b.secretKey, a.publicKey)
|
||||
|
||||
const msg = '{"method":"terminal.subscribe","params":{"terminal":"t1"}}'
|
||||
const enc = encrypt(msg, sharedA)
|
||||
expect(decrypt(enc, sharedB)).toBe(msg)
|
||||
})
|
||||
})
|
||||
|
||||
describe('destroy', () => {
|
||||
it('clears state and stops forwarding', () => {
|
||||
const ctx = setup()
|
||||
const sharedKey = doHandshake(ctx)
|
||||
const received: string[] = []
|
||||
|
||||
ctx.channel.onMessage((plaintext) => {
|
||||
received.push(plaintext)
|
||||
})
|
||||
|
||||
ctx.channel.destroy()
|
||||
ctx.channel.handleRawMessage(encrypt('after destroy', sharedKey))
|
||||
|
||||
expect(received).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
// Why: the E2EE channel sits between the WebSocket transport and the RPC handler.
|
||||
// It owns the handshake state machine and transparent encrypt/decrypt so the RPC
|
||||
// handler only sees plaintext JSON, identical to the Unix socket path.
|
||||
import type { WebSocket } from 'ws'
|
||||
import { deriveSharedKey, encrypt, decrypt } from './e2ee-crypto'
|
||||
|
||||
type ChannelState = 'awaiting_hello' | 'awaiting_auth' | 'ready'
|
||||
|
||||
const HANDSHAKE_TIMEOUT_MS = 10_000
|
||||
const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5
|
||||
|
||||
type E2EEHello = {
|
||||
type: 'e2ee_hello'
|
||||
publicKeyB64: string
|
||||
}
|
||||
|
||||
type E2EEAuth = {
|
||||
type: 'e2ee_auth'
|
||||
deviceToken: string
|
||||
}
|
||||
|
||||
export type E2EEChannelOptions = {
|
||||
serverSecretKey: Uint8Array
|
||||
validateToken: (token: string) => boolean
|
||||
onReady: (channel: E2EEChannel) => void
|
||||
onError: (code: number, reason: string) => void
|
||||
}
|
||||
|
||||
export class E2EEChannel {
|
||||
private state: ChannelState = 'awaiting_hello'
|
||||
private sharedKey: Uint8Array | null = null
|
||||
private consecutiveFailures = 0
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private readonly ws: WebSocket
|
||||
private readonly serverSecretKey: Uint8Array
|
||||
private readonly validateToken: (token: string) => boolean
|
||||
private readonly onReady: (channel: E2EEChannel) => void
|
||||
private readonly onError: (code: number, reason: string) => void
|
||||
// Why: the RPC handler is set after the channel is ready, so the channel
|
||||
// can forward decrypted messages. Kept as a callback rather than constructor
|
||||
// param because the handler needs the encrypt function for replies.
|
||||
private messageHandler:
|
||||
| ((plaintext: string, encryptedReply: (response: string) => void) => void)
|
||||
| null = null
|
||||
|
||||
deviceToken: string | null = null
|
||||
|
||||
constructor(ws: WebSocket, options: E2EEChannelOptions) {
|
||||
this.ws = ws
|
||||
this.serverSecretKey = options.serverSecretKey
|
||||
this.validateToken = options.validateToken
|
||||
this.onReady = options.onReady
|
||||
this.onError = options.onError
|
||||
|
||||
this.handshakeTimer = setTimeout(() => {
|
||||
this.onError(4002, 'E2EE handshake timeout')
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
onMessage(
|
||||
handler: (plaintext: string, encryptedReply: (response: string) => void) => void
|
||||
): void {
|
||||
this.messageHandler = handler
|
||||
}
|
||||
|
||||
handleRawMessage(raw: string): void {
|
||||
if (this.state === 'awaiting_hello') {
|
||||
this.handleHello(raw)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.sharedKey) {
|
||||
return
|
||||
}
|
||||
|
||||
const plaintext = decrypt(raw, this.sharedKey)
|
||||
if (plaintext === null) {
|
||||
this.consecutiveFailures++
|
||||
if (this.consecutiveFailures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) {
|
||||
this.onError(4003, 'Too many decryption failures')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.consecutiveFailures = 0
|
||||
if (this.state === 'awaiting_auth') {
|
||||
this.handleAuth(plaintext)
|
||||
return
|
||||
}
|
||||
|
||||
const encryptedReply = (response: string) => {
|
||||
if (this.ws.readyState === this.ws.OPEN) {
|
||||
this.ws.send(encrypt(response, this.sharedKey!))
|
||||
}
|
||||
}
|
||||
this.messageHandler?.(plaintext, encryptedReply)
|
||||
}
|
||||
|
||||
private handleHello(raw: string): void {
|
||||
let hello: E2EEHello
|
||||
try {
|
||||
hello = JSON.parse(raw) as E2EEHello
|
||||
} catch {
|
||||
this.onError(4001, 'Invalid handshake message')
|
||||
return
|
||||
}
|
||||
|
||||
if (hello.type !== 'e2ee_hello' || !hello.publicKeyB64) {
|
||||
this.onError(4001, 'Invalid e2ee_hello')
|
||||
return
|
||||
}
|
||||
|
||||
// Why: derive the shared key from our secret + client's public key.
|
||||
// Both sides compute the same shared secret via ECDH.
|
||||
const clientPublicKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64'))
|
||||
if (clientPublicKey.length !== 32) {
|
||||
this.onError(4001, 'Invalid public key')
|
||||
return
|
||||
}
|
||||
|
||||
this.sharedKey = deriveSharedKey(this.serverSecretKey, clientPublicKey)
|
||||
this.state = 'awaiting_auth'
|
||||
|
||||
// Why: send e2ee_ready as plaintext — the client needs it to know the
|
||||
// key exchange succeeded before it can send encrypted authentication.
|
||||
if (this.ws.readyState === this.ws.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
}
|
||||
}
|
||||
|
||||
private handleAuth(plaintext: string): void {
|
||||
let auth: E2EEAuth
|
||||
try {
|
||||
auth = JSON.parse(plaintext) as E2EEAuth
|
||||
} catch {
|
||||
this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } })
|
||||
this.onError(4001, 'Invalid e2ee_auth')
|
||||
return
|
||||
}
|
||||
|
||||
if (auth.type !== 'e2ee_auth' || !auth.deviceToken) {
|
||||
this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } })
|
||||
this.onError(4001, 'Invalid e2ee_auth')
|
||||
return
|
||||
}
|
||||
if (!this.validateToken(auth.deviceToken)) {
|
||||
this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'unauthorized' } })
|
||||
this.onError(4001, 'Unauthorized')
|
||||
return
|
||||
}
|
||||
|
||||
this.deviceToken = auth.deviceToken
|
||||
this.state = 'ready'
|
||||
|
||||
if (this.handshakeTimer) {
|
||||
clearTimeout(this.handshakeTimer)
|
||||
this.handshakeTimer = null
|
||||
}
|
||||
|
||||
this.sendEncryptedControl({ type: 'e2ee_authenticated' })
|
||||
this.onReady(this)
|
||||
}
|
||||
|
||||
private sendEncryptedControl(message: unknown): void {
|
||||
if (this.ws.readyState === this.ws.OPEN && this.sharedKey) {
|
||||
this.ws.send(encrypt(JSON.stringify(message), this.sharedKey))
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.handshakeTimer) {
|
||||
clearTimeout(this.handshakeTimer)
|
||||
this.handshakeTimer = null
|
||||
}
|
||||
this.sharedKey = null
|
||||
this.messageHandler = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto'
|
||||
|
||||
describe('e2ee-crypto', () => {
|
||||
it('encrypt/decrypt round-trips with shared key', () => {
|
||||
const server = generateKeyPair()
|
||||
const client = generateKeyPair()
|
||||
|
||||
const serverShared = deriveSharedKey(server.secretKey, client.publicKey)
|
||||
const clientShared = deriveSharedKey(client.secretKey, server.publicKey)
|
||||
|
||||
const message = '{"id":"rpc-1","method":"status.get"}'
|
||||
const encrypted = encrypt(message, clientShared)
|
||||
const decrypted = decrypt(encrypted, serverShared)
|
||||
|
||||
expect(decrypted).toBe(message)
|
||||
})
|
||||
|
||||
it('decrypt returns null with wrong key', () => {
|
||||
const server = generateKeyPair()
|
||||
const client = generateKeyPair()
|
||||
const attacker = generateKeyPair()
|
||||
|
||||
const clientShared = deriveSharedKey(client.secretKey, server.publicKey)
|
||||
const attackerShared = deriveSharedKey(attacker.secretKey, server.publicKey)
|
||||
|
||||
const encrypted = encrypt('secret data', clientShared)
|
||||
expect(decrypt(encrypted, attackerShared)).toBeNull()
|
||||
})
|
||||
|
||||
it('each encryption produces unique ciphertext (random nonce)', () => {
|
||||
const server = generateKeyPair()
|
||||
const client = generateKeyPair()
|
||||
const shared = deriveSharedKey(client.secretKey, server.publicKey)
|
||||
|
||||
const message = 'same message'
|
||||
const a = encrypt(message, shared)
|
||||
const b = encrypt(message, shared)
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
const server = generateKeyPair()
|
||||
const client = generateKeyPair()
|
||||
const shared = deriveSharedKey(client.secretKey, server.publicKey)
|
||||
|
||||
const encrypted = encrypt('', shared)
|
||||
expect(decrypt(encrypted, shared)).toBe('')
|
||||
})
|
||||
|
||||
it('handles unicode content', () => {
|
||||
const server = generateKeyPair()
|
||||
const client = generateKeyPair()
|
||||
const shared = deriveSharedKey(client.secretKey, server.publicKey)
|
||||
|
||||
const message = '日本語テスト 🎉 émojis'
|
||||
const encrypted = encrypt(message, shared)
|
||||
expect(decrypt(encrypted, shared)).toBe(message)
|
||||
})
|
||||
|
||||
it('decrypt returns null for truncated data', () => {
|
||||
const shared = deriveSharedKey(generateKeyPair().secretKey, generateKeyPair().publicKey)
|
||||
expect(decrypt('dG9vc2hvcnQ=', shared)).toBeNull()
|
||||
})
|
||||
|
||||
it('decrypt returns null for tampered data', () => {
|
||||
const server = generateKeyPair()
|
||||
const client = generateKeyPair()
|
||||
const shared = deriveSharedKey(client.secretKey, server.publicKey)
|
||||
|
||||
const encrypted = encrypt('hello', shared)
|
||||
const bytes = Buffer.from(encrypted, 'base64')
|
||||
bytes[bytes.length - 1] ^= 0xff
|
||||
const tampered = bytes.toString('base64')
|
||||
|
||||
expect(decrypt(tampered, shared)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
// Why: shared E2EE primitives for the desktop side. Wraps tweetnacl to provide
|
||||
// encrypt/decrypt with the NaCl box format: [24-byte nonce][ciphertext], encoded
|
||||
// as base64 for transmission over WebSocket text frames.
|
||||
import nacl from 'tweetnacl'
|
||||
|
||||
export function generateKeyPair(): nacl.BoxKeyPair {
|
||||
return nacl.box.keyPair()
|
||||
}
|
||||
|
||||
export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array {
|
||||
return nacl.box.before(peerPublicKey, ourSecretKey)
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string, sharedKey: Uint8Array): string {
|
||||
const nonce = nacl.randomBytes(nacl.box.nonceLength)
|
||||
const messageBytes = new TextEncoder().encode(plaintext)
|
||||
const ciphertext = nacl.box.after(messageBytes, nonce, sharedKey)
|
||||
|
||||
const bundle = new Uint8Array(nonce.length + ciphertext.length)
|
||||
bundle.set(nonce)
|
||||
bundle.set(ciphertext, nonce.length)
|
||||
|
||||
return Buffer.from(bundle).toString('base64')
|
||||
}
|
||||
|
||||
export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null {
|
||||
const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64'))
|
||||
if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nonce = bundle.slice(0, nacl.box.nonceLength)
|
||||
const ciphertext = bundle.slice(nacl.box.nonceLength)
|
||||
const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey)
|
||||
|
||||
if (!plaintext) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(plaintext)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { WebSocket } from 'ws'
|
||||
import { E2EEChannel } from './e2ee-channel'
|
||||
import { generateKeyPair, deriveSharedKey, encrypt, decrypt } from './e2ee-crypto'
|
||||
|
||||
// Why: this test simulates the full mobile → desktop E2EE flow without a real
|
||||
// WebSocket. The "mobile" side generates an ephemeral keypair, sends e2ee_hello,
|
||||
// the E2EEChannel (desktop) derives the shared key, and then both sides can
|
||||
// exchange encrypted RPC messages. This validates that the handshake protocol
|
||||
// and crypto are end-to-end compatible.
|
||||
|
||||
function publicKeyToBase64(key: Uint8Array): string {
|
||||
return Buffer.from(key).toString('base64')
|
||||
}
|
||||
|
||||
describe('E2EE integration (simulated mobile ↔ desktop)', () => {
|
||||
let serverKeys: ReturnType<typeof generateKeyPair>
|
||||
let mobileEphemeralKeys: ReturnType<typeof generateKeyPair>
|
||||
let wsSent: string[]
|
||||
let mockWs: {
|
||||
OPEN: 1
|
||||
readyState: number
|
||||
send: ReturnType<typeof vi.fn>
|
||||
close: ReturnType<typeof vi.fn>
|
||||
}
|
||||
let channel: E2EEChannel
|
||||
let onReady: (channel: E2EEChannel) => void
|
||||
let onError: (code: number, reason: string) => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
serverKeys = generateKeyPair()
|
||||
mobileEphemeralKeys = generateKeyPair()
|
||||
wsSent = []
|
||||
mockWs = {
|
||||
OPEN: 1,
|
||||
readyState: 1,
|
||||
send: vi.fn((data: string) => wsSent.push(data)),
|
||||
close: vi.fn()
|
||||
}
|
||||
onReady = vi.fn() as unknown as (channel: E2EEChannel) => void
|
||||
onError = vi.fn() as unknown as (code: number, reason: string) => void
|
||||
|
||||
channel = new E2EEChannel(mockWs as unknown as WebSocket, {
|
||||
serverSecretKey: serverKeys.secretKey,
|
||||
validateToken: (token) => token === 'device-abc',
|
||||
onReady,
|
||||
onError
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
channel.destroy()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function completeHandshake(targetChannel = channel, keys = mobileEphemeralKeys): Uint8Array {
|
||||
targetChannel.handleRawMessage(
|
||||
JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: publicKeyToBase64(keys.publicKey)
|
||||
})
|
||||
)
|
||||
const sharedKey = deriveSharedKey(keys.secretKey, serverKeys.publicKey)
|
||||
targetChannel.handleRawMessage(
|
||||
encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'device-abc' }), sharedKey)
|
||||
)
|
||||
return sharedKey
|
||||
}
|
||||
|
||||
it('full handshake → encrypted RPC round-trip', () => {
|
||||
const mobileShared = completeHandshake()
|
||||
|
||||
// Desktop should respond with plaintext ready, then encrypted auth ack.
|
||||
expect(onReady).toHaveBeenCalled()
|
||||
expect(wsSent).toHaveLength(2)
|
||||
expect(JSON.parse(wsSent[0]!)).toEqual({ type: 'e2ee_ready' })
|
||||
expect(JSON.parse(decrypt(wsSent[1]!, mobileShared)!)).toEqual({
|
||||
type: 'e2ee_authenticated'
|
||||
})
|
||||
|
||||
// Set up the desktop message handler
|
||||
const desktopReceived: string[] = []
|
||||
channel.onMessage((plaintext, encryptedReply) => {
|
||||
desktopReceived.push(plaintext)
|
||||
encryptedReply(JSON.stringify({ id: 'rpc-1', ok: true, result: { status: 'ready' } }))
|
||||
})
|
||||
|
||||
// Mobile sends encrypted RPC request
|
||||
const request = JSON.stringify({ id: 'rpc-1', method: 'status.get' })
|
||||
channel.handleRawMessage(encrypt(request, mobileShared))
|
||||
|
||||
// Desktop received the plaintext
|
||||
expect(desktopReceived).toEqual([request])
|
||||
|
||||
// Desktop's encrypted reply (wsSent[2]) is decryptable by mobile
|
||||
expect(wsSent).toHaveLength(3)
|
||||
const replyPlain = decrypt(wsSent[2]!, mobileShared)
|
||||
expect(JSON.parse(replyPlain!)).toEqual({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: { status: 'ready' }
|
||||
})
|
||||
})
|
||||
|
||||
it('mobile reconnects with fresh ephemeral key', () => {
|
||||
// First connection
|
||||
completeHandshake()
|
||||
expect(onReady).toHaveBeenCalledTimes(1)
|
||||
|
||||
const firstShared = deriveSharedKey(mobileEphemeralKeys.secretKey, serverKeys.publicKey)
|
||||
|
||||
// Simulate disconnect + reconnect with new ephemeral key
|
||||
channel.destroy()
|
||||
wsSent.length = 0
|
||||
|
||||
const newMobileKeys = generateKeyPair()
|
||||
const newChannel = new E2EEChannel(mockWs as unknown as WebSocket, {
|
||||
serverSecretKey: serverKeys.secretKey,
|
||||
validateToken: (token) => token === 'device-abc',
|
||||
onReady,
|
||||
onError
|
||||
})
|
||||
|
||||
completeHandshake(newChannel, newMobileKeys)
|
||||
|
||||
expect(onReady).toHaveBeenCalledTimes(2)
|
||||
|
||||
const secondShared = deriveSharedKey(newMobileKeys.secretKey, serverKeys.publicKey)
|
||||
|
||||
// Keys from different sessions must not be interchangeable
|
||||
expect(Buffer.from(firstShared).toString('hex')).not.toBe(
|
||||
Buffer.from(secondShared).toString('hex')
|
||||
)
|
||||
|
||||
// Verify new session works
|
||||
const received: string[] = []
|
||||
newChannel.onMessage((plaintext) => received.push(plaintext))
|
||||
newChannel.handleRawMessage(encrypt('{"id":"2","method":"test"}', secondShared))
|
||||
expect(received).toEqual(['{"id":"2","method":"test"}'])
|
||||
|
||||
newChannel.destroy()
|
||||
})
|
||||
|
||||
it('streaming messages work through E2EE', () => {
|
||||
const mobileShared = completeHandshake()
|
||||
const received: string[] = []
|
||||
|
||||
channel.onMessage((plaintext, encryptedReply) => {
|
||||
received.push(plaintext)
|
||||
// Simulate streaming: send multiple encrypted responses
|
||||
for (let i = 0; i < 3; i++) {
|
||||
encryptedReply(
|
||||
JSON.stringify({
|
||||
id: 'stream-1',
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'data', chunk: `line ${i}\n` }
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
channel.handleRawMessage(
|
||||
encrypt(JSON.stringify({ id: 'stream-1', method: 'terminal.subscribe' }), mobileShared)
|
||||
)
|
||||
|
||||
// 1 plaintext ready + 1 encrypted auth ack + 3 streaming responses
|
||||
expect(wsSent).toHaveLength(5)
|
||||
|
||||
for (let i = 2; i < 5; i++) {
|
||||
const plain = decrypt(wsSent[i]!, mobileShared)
|
||||
const parsed = JSON.parse(plain!)
|
||||
expect(parsed.streaming).toBe(true)
|
||||
expect(parsed.result.chunk).toBe(`line ${i - 2}\n`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RpcMethod } from '../core'
|
||||
import type { RpcAnyMethod } from '../core'
|
||||
import { STATUS_METHODS } from './status'
|
||||
import { REPO_METHODS } from './repo'
|
||||
import { WORKTREE_METHODS } from './worktree'
|
||||
@@ -6,16 +6,20 @@ import { TERMINAL_METHODS } from './terminal'
|
||||
import { BROWSER_CORE_METHODS } from './browser-core'
|
||||
import { BROWSER_EXTRA_METHODS } from './browser-extras'
|
||||
import { ORCHESTRATION_METHODS } from './orchestration'
|
||||
import { NOTIFICATION_METHODS } from './notifications'
|
||||
import { STATS_METHODS } from './stats'
|
||||
|
||||
// Why: a flat manifest keeps registration order explicit and provides one
|
||||
// grep-point for "what methods does the RPC server expose?" — useful when
|
||||
// auditing the security boundary or wiring new CLI commands.
|
||||
export const ALL_RPC_METHODS: readonly RpcMethod[] = [
|
||||
export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...STATUS_METHODS,
|
||||
...REPO_METHODS,
|
||||
...WORKTREE_METHODS,
|
||||
...TERMINAL_METHODS,
|
||||
...BROWSER_CORE_METHODS,
|
||||
...BROWSER_EXTRA_METHODS,
|
||||
...ORCHESTRATION_METHODS
|
||||
...ORCHESTRATION_METHODS,
|
||||
...NOTIFICATION_METHODS,
|
||||
...STATS_METHODS
|
||||
]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod'
|
||||
import { defineStreamingMethod, defineMethod, type RpcAnyMethod } from '../core'
|
||||
|
||||
const NotificationUnsubscribeParams = z.object({
|
||||
subscriptionId: z
|
||||
.unknown()
|
||||
.transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
|
||||
.pipe(z.string().min(1, 'Missing subscriptionId'))
|
||||
})
|
||||
|
||||
// Why: notifications.subscribe streams desktop notification events to mobile
|
||||
// clients over WebSocket. The mobile client shows a local push notification
|
||||
// for each event. This avoids requiring Firebase/APNs — the existing
|
||||
// persistent WebSocket connection doubles as the push channel.
|
||||
export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [
|
||||
defineStreamingMethod({
|
||||
name: 'notifications.subscribe',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }, emit) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
const unsubscribe = runtime.onNotificationDispatched((event) => {
|
||||
emit({ type: 'notification', ...event })
|
||||
})
|
||||
|
||||
const subscriptionId = `notifications-${Date.now()}`
|
||||
runtime.registerSubscriptionCleanup(subscriptionId, () => {
|
||||
unsubscribe()
|
||||
emit({ type: 'end' })
|
||||
resolve()
|
||||
})
|
||||
|
||||
emit({ type: 'ready', subscriptionId })
|
||||
})
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'notifications.unsubscribe',
|
||||
params: NotificationUnsubscribeParams,
|
||||
handler: async (params, { runtime }) => {
|
||||
runtime.cleanupSubscription(params.subscriptionId)
|
||||
return { unsubscribed: true }
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -52,5 +52,10 @@ export const REPO_METHODS: RpcMethod[] = [
|
||||
params: RepoSearchRefs,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.searchRepoRefs(params.repo, params.query, params.limit)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.hooks',
|
||||
params: RepoSelector,
|
||||
handler: async (params, { runtime }) => runtime.getRepoHooks(params.repo)
|
||||
})
|
||||
]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
|
||||
export const STATS_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'stats.summary',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }) => {
|
||||
return runtime.getStatsSummary() ?? {}
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
/* oxlint-disable max-lines -- Why: terminal RPC methods are co-located for discoverability; splitting would scatter related handlers across files. */
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
|
||||
const TerminalHandle = z.object({
|
||||
@@ -77,7 +78,45 @@ const TerminalStop = z.object({
|
||||
worktree: requiredString('Missing worktree selector')
|
||||
})
|
||||
|
||||
export const TERMINAL_METHODS: RpcMethod[] = [
|
||||
const TerminalResizeForClient = z.discriminatedUnion('mode', [
|
||||
z.object({
|
||||
terminal: requiredString('Missing terminal handle'),
|
||||
mode: z.literal('mobile-fit'),
|
||||
cols: z.number().finite().positive(),
|
||||
rows: z.number().finite().positive(),
|
||||
clientId: requiredString('Missing client ID')
|
||||
}),
|
||||
z.object({
|
||||
terminal: requiredString('Missing terminal handle'),
|
||||
mode: z.literal('restore'),
|
||||
clientId: requiredString('Missing client ID')
|
||||
})
|
||||
])
|
||||
|
||||
const TerminalSubscribe = TerminalHandle.extend({
|
||||
client: z
|
||||
.object({
|
||||
id: requiredString('Missing client ID'),
|
||||
type: z.enum(['mobile', 'desktop']).default('desktop')
|
||||
})
|
||||
.optional(),
|
||||
viewport: z
|
||||
.object({
|
||||
cols: z.number().int().min(20).max(240),
|
||||
rows: z.number().int().min(8).max(120)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const TerminalSetDisplayMode = TerminalHandle.extend({
|
||||
mode: z.enum(['auto', 'phone', 'desktop'])
|
||||
})
|
||||
|
||||
const TerminalUnsubscribe = z.object({
|
||||
subscriptionId: requiredString('Missing subscription ID')
|
||||
})
|
||||
|
||||
export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'terminal.list',
|
||||
params: TerminalListParams,
|
||||
@@ -157,6 +196,29 @@ export const TERMINAL_METHODS: RpcMethod[] = [
|
||||
params: TerminalStop,
|
||||
handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.resizeForClient',
|
||||
params: TerminalResizeForClient,
|
||||
handler: async (params, { runtime }) => {
|
||||
const leaf = runtime.resolveLeafForHandle(params.terminal)
|
||||
if (!leaf?.ptyId) {
|
||||
throw new Error('no_connected_pty')
|
||||
}
|
||||
const result = runtime.resizeForClient(
|
||||
leaf.ptyId,
|
||||
params.mode,
|
||||
params.clientId,
|
||||
params.mode === 'mobile-fit' ? params.cols : undefined,
|
||||
params.mode === 'mobile-fit' ? params.rows : undefined
|
||||
)
|
||||
return {
|
||||
terminal: {
|
||||
handle: params.terminal,
|
||||
...result
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.focus',
|
||||
params: TerminalHandle,
|
||||
@@ -170,5 +232,141 @@ export const TERMINAL_METHODS: RpcMethod[] = [
|
||||
handler: async (params, { runtime }) => ({
|
||||
close: await runtime.closeTerminal(params.terminal)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.setDisplayMode',
|
||||
params: TerminalSetDisplayMode,
|
||||
handler: async (params, { runtime }) => {
|
||||
const leaf = runtime.resolveLeafForHandle(params.terminal)
|
||||
if (!leaf?.ptyId) {
|
||||
throw new Error('no_connected_pty')
|
||||
}
|
||||
runtime.setMobileDisplayMode(leaf.ptyId, params.mode)
|
||||
runtime.applyMobileDisplayMode(leaf.ptyId)
|
||||
return { mode: params.mode }
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.getDisplayMode',
|
||||
params: TerminalHandle,
|
||||
handler: async (params, { runtime }) => {
|
||||
const leaf = runtime.resolveLeafForHandle(params.terminal)
|
||||
const mode = leaf?.ptyId ? runtime.getMobileDisplayMode(leaf.ptyId) : 'auto'
|
||||
const isPhoneFitted = leaf?.ptyId ? runtime.isMobileSubscriberActive(leaf.ptyId) : false
|
||||
return { mode, isPhoneFitted }
|
||||
}
|
||||
}),
|
||||
// Why: terminal.subscribe streams live terminal output over WebSocket.
|
||||
// It sends initial scrollback, then live data chunks as they arrive.
|
||||
// Mobile clients pass client+viewport params for server-side auto-fit.
|
||||
defineStreamingMethod({
|
||||
name: 'terminal.subscribe',
|
||||
params: TerminalSubscribe,
|
||||
handler: async (params, { runtime }, emit) => {
|
||||
let leaf = runtime.resolveLeafForHandle(params.terminal)
|
||||
const isMobile = params.client?.type === 'mobile'
|
||||
|
||||
// Why: the left pane's PTY spawns asynchronously after the tab is created.
|
||||
// Mobile clients that subscribe before the PTY is ready would get a bare
|
||||
// scrollback+end with no live stream or phone-fit. Wait for the PTY so
|
||||
// the subscribe can proceed normally.
|
||||
if (!leaf?.ptyId && isMobile) {
|
||||
try {
|
||||
const ptyId = await runtime.waitForLeafPtyId(params.terminal)
|
||||
leaf = { ptyId }
|
||||
} catch {
|
||||
// PTY wait timed out — fall through to scrollback-only path below
|
||||
}
|
||||
}
|
||||
|
||||
if (!leaf?.ptyId) {
|
||||
const read = await runtime.readTerminal(params.terminal)
|
||||
emit({
|
||||
type: 'scrollback',
|
||||
lines: read.tail,
|
||||
truncated: read.truncated,
|
||||
serialized: undefined,
|
||||
cols: undefined,
|
||||
rows: undefined
|
||||
})
|
||||
emit({ type: 'end' })
|
||||
return
|
||||
}
|
||||
|
||||
const ptyId = leaf.ptyId
|
||||
const clientId = params.client?.id
|
||||
|
||||
// Server-side auto-fit: resize PTY to phone dims before serializing scrollback
|
||||
if (isMobile && clientId) {
|
||||
runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
|
||||
}
|
||||
|
||||
const read = await runtime.readTerminal(params.terminal)
|
||||
const serialized = await runtime.serializeTerminalBuffer(ptyId)
|
||||
const size = runtime.getTerminalSize(ptyId)
|
||||
const displayMode = runtime.getMobileDisplayMode(ptyId)
|
||||
emit({
|
||||
type: 'scrollback',
|
||||
lines: read.tail,
|
||||
truncated: read.truncated,
|
||||
serialized: serialized?.data,
|
||||
cols: serialized?.cols ?? size?.cols,
|
||||
rows: serialized?.rows ?? size?.rows,
|
||||
displayMode
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => {
|
||||
emit({ type: 'data', chunk: data })
|
||||
})
|
||||
|
||||
// Inline resize events replace the old fit-override-changed event for
|
||||
// mobile clients. They include fresh serialized scrollback so the client
|
||||
// can reinitialize xterm without resubscribing.
|
||||
const unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, async (event) => {
|
||||
const fresh = await runtime.serializeTerminalBuffer(ptyId)
|
||||
emit({
|
||||
type: 'resized',
|
||||
cols: event.cols,
|
||||
rows: event.rows,
|
||||
serialized: fresh?.data,
|
||||
displayMode: event.displayMode,
|
||||
reason: event.reason
|
||||
})
|
||||
})
|
||||
|
||||
// Legacy fit-override-changed for non-mobile (desktop) subscribers
|
||||
const unsubscribeFit = !isMobile
|
||||
? runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
|
||||
emit({
|
||||
type: 'fit-override-changed',
|
||||
mode: event.mode,
|
||||
cols: event.cols,
|
||||
rows: event.rows
|
||||
})
|
||||
})
|
||||
: () => {}
|
||||
|
||||
const subscriptionId = params.terminal
|
||||
runtime.registerSubscriptionCleanup(subscriptionId, () => {
|
||||
unsubscribeData()
|
||||
unsubscribeResize()
|
||||
unsubscribeFit()
|
||||
if (isMobile && clientId) {
|
||||
runtime.handleMobileUnsubscribe(ptyId, clientId)
|
||||
}
|
||||
emit({ type: 'end' })
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.unsubscribe',
|
||||
params: TerminalUnsubscribe,
|
||||
handler: async (params, { runtime }) => {
|
||||
runtime.cleanupSubscription(params.subscriptionId)
|
||||
return { unsubscribed: true }
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -28,20 +28,27 @@ const WorktreeCreate = z.object({
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing repo selector')),
|
||||
name: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing worktree name')),
|
||||
name: OptionalString,
|
||||
baseBranch: OptionalString,
|
||||
linkedIssue: TriStateLinkedIssue,
|
||||
comment: OptionalString,
|
||||
runHooks: OptionalBoolean
|
||||
runHooks: OptionalBoolean,
|
||||
setupDecision: z
|
||||
.unknown()
|
||||
.transform((v) =>
|
||||
typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined
|
||||
)
|
||||
.pipe(z.enum(['run', 'skip', 'inherit']).optional()),
|
||||
// Why: mobile clients pass a startup command (e.g. 'claude') so the first
|
||||
// terminal pane launches the selected agent instead of an idle shell.
|
||||
startupCommand: OptionalString
|
||||
})
|
||||
|
||||
const WorktreeSet = WorktreeSelector.extend({
|
||||
displayName: OptionalString,
|
||||
linkedIssue: TriStateLinkedIssue,
|
||||
comment: OptionalString
|
||||
comment: OptionalString,
|
||||
isPinned: OptionalBoolean
|
||||
})
|
||||
|
||||
const WorktreeRemove = WorktreeSelector.extend({
|
||||
@@ -67,17 +74,29 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
worktree: await runtime.showManagedWorktree(params.worktree)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.sleep',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) => runtime.sleepManagedWorktree(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.activate',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) => runtime.activateManagedWorktree(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.create',
|
||||
params: WorktreeCreate,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.createManagedWorktree({
|
||||
repoSelector: params.repo,
|
||||
name: params.name,
|
||||
name: params.name ?? '',
|
||||
baseBranch: params.baseBranch,
|
||||
linkedIssue: params.linkedIssue,
|
||||
comment: params.comment,
|
||||
runHooks: params.runHooks === true
|
||||
runHooks: params.runHooks === true,
|
||||
setupDecision: params.setupDecision,
|
||||
startup: params.startupCommand ? { command: params.startupCommand } : undefined
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
@@ -87,7 +106,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
worktree: await runtime.updateManagedWorktreeMeta(params.worktree, {
|
||||
displayName: params.displayName,
|
||||
linkedIssue: params.linkedIssue,
|
||||
comment: params.comment
|
||||
comment: params.comment,
|
||||
isPinned: params.isPinned
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { defineMethod, defineStreamingMethod, type RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
|
||||
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('RpcDispatcher streaming', () => {
|
||||
it('sends initial scrollback via emit', async () => {
|
||||
const messages: string[] = []
|
||||
const dispatcher = new RpcDispatcher({
|
||||
runtime: stubRuntime({
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: 'hello\nworld\n', truncated: false }),
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null })
|
||||
}),
|
||||
methods: [
|
||||
defineStreamingMethod({
|
||||
name: 'terminal.subscribe',
|
||||
params: z.object({ terminal: z.string() }),
|
||||
handler: async (params, { runtime }, emit) => {
|
||||
const read = await (runtime as OrcaRuntimeService).readTerminal(params.terminal)
|
||||
emit({ type: 'scrollback', lines: read.tail, truncated: read.truncated })
|
||||
|
||||
const leaf = (runtime as OrcaRuntimeService).resolveLeafForHandle(params.terminal)
|
||||
if (!leaf?.ptyId) {
|
||||
emit({ type: 'end' })
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
await dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.subscribe', { terminal: 'h-1' }),
|
||||
(msg) => messages.push(msg)
|
||||
)
|
||||
|
||||
expect(messages).toHaveLength(2)
|
||||
const scrollback = JSON.parse(messages[0]!)
|
||||
expect(scrollback).toMatchObject({
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'scrollback', lines: 'hello\nworld\n', truncated: false }
|
||||
})
|
||||
const end = JSON.parse(messages[1]!)
|
||||
expect(end).toMatchObject({
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'end' }
|
||||
})
|
||||
})
|
||||
|
||||
it('streams live data chunks via emit', async () => {
|
||||
const messages: string[] = []
|
||||
let emitFn: ((result: unknown) => void) | null = null
|
||||
let resolveHandler: (() => void) | null = null
|
||||
|
||||
const dispatcher = new RpcDispatcher({
|
||||
runtime: stubRuntime(),
|
||||
methods: [
|
||||
defineStreamingMethod({
|
||||
name: 'test.stream',
|
||||
params: null,
|
||||
handler: async (_params, _ctx, emit) => {
|
||||
emitFn = emit
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveHandler = resolve
|
||||
})
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(makeRequest('test.stream'), (msg) =>
|
||||
messages.push(msg)
|
||||
)
|
||||
|
||||
// Wait for handler to capture emit
|
||||
await vi.waitFor(() => expect(emitFn).not.toBeNull())
|
||||
|
||||
emitFn!({ type: 'data', chunk: 'line 1\n' })
|
||||
emitFn!({ type: 'data', chunk: 'line 2\n' })
|
||||
emitFn!({ type: 'end' })
|
||||
resolveHandler!()
|
||||
|
||||
await dispatchPromise
|
||||
|
||||
expect(messages).toHaveLength(3)
|
||||
expect(JSON.parse(messages[0]!)).toMatchObject({
|
||||
streaming: true,
|
||||
result: { type: 'data', chunk: 'line 1\n' }
|
||||
})
|
||||
expect(JSON.parse(messages[1]!)).toMatchObject({
|
||||
streaming: true,
|
||||
result: { type: 'data', chunk: 'line 2\n' }
|
||||
})
|
||||
expect(JSON.parse(messages[2]!)).toMatchObject({
|
||||
streaming: true,
|
||||
result: { type: 'end' }
|
||||
})
|
||||
})
|
||||
|
||||
it('unsubscribe stops further streaming', async () => {
|
||||
const messages: string[] = []
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
const dispatcher = new RpcDispatcher({
|
||||
runtime: stubRuntime({
|
||||
registerSubscriptionCleanup: vi.fn().mockImplementation((_id: string, fn: () => void) => {
|
||||
cleanup = fn
|
||||
}),
|
||||
cleanupSubscription: vi.fn().mockImplementation(() => {
|
||||
cleanup?.()
|
||||
})
|
||||
}),
|
||||
methods: [
|
||||
defineStreamingMethod({
|
||||
name: 'test.subscribe',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }, emit) => {
|
||||
emit({ type: 'scrollback', lines: '' })
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
;(runtime as OrcaRuntimeService).registerSubscriptionCleanup('sub-1', () => {
|
||||
emit({ type: 'end' })
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'test.unsubscribe',
|
||||
params: z.object({ subscriptionId: z.string() }),
|
||||
handler: async (params, { runtime }) => {
|
||||
;(runtime as OrcaRuntimeService).cleanupSubscription(params.subscriptionId)
|
||||
return { unsubscribed: true }
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
const subPromise = dispatcher.dispatchStreaming(makeRequest('test.subscribe'), (msg) =>
|
||||
messages.push(msg)
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(cleanup).not.toBeNull())
|
||||
|
||||
const unsubMessages: string[] = []
|
||||
await dispatcher.dispatchStreaming(
|
||||
{
|
||||
id: 'req-unsub',
|
||||
authToken: 'tok',
|
||||
method: 'test.unsubscribe',
|
||||
params: { subscriptionId: 'sub-1' }
|
||||
},
|
||||
(msg) => unsubMessages.push(msg)
|
||||
)
|
||||
|
||||
await subPromise
|
||||
|
||||
expect(unsubMessages).toHaveLength(1)
|
||||
expect(JSON.parse(unsubMessages[0]!)).toMatchObject({
|
||||
ok: true,
|
||||
result: { unsubscribed: true }
|
||||
})
|
||||
|
||||
const streamMessages = messages.map((m) => JSON.parse(m))
|
||||
expect(streamMessages).toContainEqual(
|
||||
expect.objectContaining({ result: { type: 'scrollback', lines: '' } })
|
||||
)
|
||||
expect(streamMessages).toContainEqual(expect.objectContaining({ result: { type: 'end' } }))
|
||||
})
|
||||
|
||||
it('falls back to one-shot dispatch for non-streaming methods via dispatchStreaming', async () => {
|
||||
const messages: string[] = []
|
||||
const dispatcher = new RpcDispatcher({
|
||||
runtime: stubRuntime(),
|
||||
methods: [
|
||||
defineMethod({
|
||||
name: 'status.get',
|
||||
params: null,
|
||||
handler: async () => ({ status: 'ok' })
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
await dispatcher.dispatchStreaming(makeRequest('status.get'), (msg) => messages.push(msg))
|
||||
|
||||
expect(messages).toHaveLength(1)
|
||||
const response = JSON.parse(messages[0]!)
|
||||
expect(response).toMatchObject({ ok: true, result: { status: 'ok' } })
|
||||
expect(response.streaming).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns error for unknown method via dispatchStreaming', async () => {
|
||||
const messages: string[] = []
|
||||
const dispatcher = new RpcDispatcher({
|
||||
runtime: stubRuntime(),
|
||||
methods: []
|
||||
})
|
||||
|
||||
await dispatcher.dispatchStreaming(makeRequest('nonexistent.method'), (msg) =>
|
||||
messages.push(msg)
|
||||
)
|
||||
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(JSON.parse(messages[0]!)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'method_not_found' }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error when streaming method is called via one-shot dispatch', async () => {
|
||||
const dispatcher = new RpcDispatcher({
|
||||
runtime: stubRuntime(),
|
||||
methods: [
|
||||
defineStreamingMethod({
|
||||
name: 'test.stream',
|
||||
params: null,
|
||||
handler: async () => {}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('test.stream'))
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'method_not_supported' }
|
||||
})
|
||||
})
|
||||
|
||||
it('captures handler errors in streaming dispatch', async () => {
|
||||
const messages: string[] = []
|
||||
const dispatcher = new RpcDispatcher({
|
||||
runtime: stubRuntime(),
|
||||
methods: [
|
||||
defineStreamingMethod({
|
||||
name: 'test.explode',
|
||||
params: null,
|
||||
handler: async () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
await dispatcher.dispatchStreaming(makeRequest('test.explode'), (msg) => messages.push(msg))
|
||||
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(JSON.parse(messages[0]!)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
// Why: the transport interface decouples the RPC server from a specific
|
||||
// transport mechanism (Unix socket, WebSocket, named pipe). Each transport
|
||||
// owns its own connection lifecycle — the RPC server just binds message
|
||||
// handling to whatever transports are registered. Individual transports
|
||||
// override `onMessage` with their own richer signatures (e.g. Unix adds a
|
||||
// `RpcMessageContext` with an abort signal; WebSocket adds the `ws` handle
|
||||
// for auth association). Consumers hold a concrete transport type, not
|
||||
// `RpcTransport`, when they need those extensions.
|
||||
|
||||
// Why: per-message hook bag owned by the Unix transport. `signal` aborts
|
||||
// when the underlying connection terminates so long-poll handlers can bail
|
||||
// out. `startKeepalive` is opt-in per request — only long-poll dispatches
|
||||
// call it, so short RPCs pay no timer overhead. See design doc §3.1.
|
||||
export type RpcMessageContext = {
|
||||
signal: AbortSignal
|
||||
startKeepalive: () => void
|
||||
}
|
||||
|
||||
export type RpcTransport = {
|
||||
start(): Promise<void>
|
||||
stop(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Why: this is the original Unix socket / named pipe transport extracted from
|
||||
// runtime-rpc.ts. It preserves the exact same behavior: newline-delimited JSON,
|
||||
// 30s idle timeout, 1MB max message, 32 max connections, chmod 0o600 on Unix.
|
||||
// It also owns the keepalive timer and per-connection abort signal so the
|
||||
// server-side handler can cancel long-poll dispatches when the client goes
|
||||
// away. See design doc §3.1.
|
||||
import { createServer, type Server, type Socket } from 'net'
|
||||
import { chmodSync, existsSync, rmSync } from 'fs'
|
||||
import type { RpcMessageContext, RpcTransport } from './transport'
|
||||
|
||||
const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024
|
||||
const RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS = 30_000
|
||||
const MAX_RUNTIME_RPC_CONNECTIONS = 32
|
||||
const DEFAULT_KEEPALIVE_INTERVAL_MS = 10_000
|
||||
|
||||
export type UnixSocketTransportOptions = {
|
||||
endpoint: string
|
||||
kind: 'unix' | 'named-pipe'
|
||||
// Why: how often to write `{"_keepalive":true}\n` frames while a dispatch
|
||||
// is pending. Each write resets both the server-side idle timer and, once
|
||||
// the client honours them, the client-side idle timer. Tests override this
|
||||
// to avoid waiting 10 s for a frame.
|
||||
keepaliveIntervalMs?: number
|
||||
}
|
||||
|
||||
type MessageHandler = (
|
||||
msg: string,
|
||||
reply: (response: string) => void,
|
||||
context?: RpcMessageContext
|
||||
) => void
|
||||
|
||||
export class UnixSocketTransport implements RpcTransport {
|
||||
private readonly endpoint: string
|
||||
private readonly kind: 'unix' | 'named-pipe'
|
||||
private readonly keepaliveIntervalMs: number
|
||||
private server: Server | null = null
|
||||
private messageHandler: MessageHandler | null = null
|
||||
|
||||
constructor({ endpoint, kind, keepaliveIntervalMs }: UnixSocketTransportOptions) {
|
||||
this.endpoint = endpoint
|
||||
this.kind = kind
|
||||
this.keepaliveIntervalMs = keepaliveIntervalMs ?? DEFAULT_KEEPALIVE_INTERVAL_MS
|
||||
}
|
||||
|
||||
onMessage(handler: MessageHandler): void {
|
||||
this.messageHandler = handler
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.server) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.kind === 'unix' && existsSync(this.endpoint)) {
|
||||
rmSync(this.endpoint, { force: true })
|
||||
}
|
||||
|
||||
const server = createServer((socket) => {
|
||||
this.handleConnection(socket)
|
||||
})
|
||||
server.maxConnections = MAX_RUNTIME_RPC_CONNECTIONS
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(this.endpoint, () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
if (this.kind === 'unix') {
|
||||
chmodSync(this.endpoint, 0o600)
|
||||
}
|
||||
|
||||
this.server = server
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const server = this.server
|
||||
this.server = null
|
||||
if (!server) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
if (this.kind === 'unix' && existsSync(this.endpoint)) {
|
||||
rmSync(this.endpoint, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnection(socket: Socket): void {
|
||||
let buffer = ''
|
||||
let oversized = false
|
||||
// Why: each in-flight dispatch registers its own AbortController here so
|
||||
// `socket.on('close')` can abort them all at once. Keeping the set scoped
|
||||
// to the connection (rather than a single shared controller) means
|
||||
// completing one dispatch does not abort any other dispatch still running
|
||||
// on the same socket — future-proofing for a persistent CLI socket that
|
||||
// multiplexes sequential requests.
|
||||
const inflight = new Set<AbortController>()
|
||||
|
||||
socket.setEncoding('utf8')
|
||||
socket.setNoDelay(true)
|
||||
socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => {
|
||||
socket.destroy()
|
||||
})
|
||||
socket.on('error', () => {
|
||||
socket.destroy()
|
||||
})
|
||||
socket.on('close', () => {
|
||||
for (const ctrl of inflight) {
|
||||
ctrl.abort()
|
||||
}
|
||||
inflight.clear()
|
||||
})
|
||||
socket.on('data', (chunk: string) => {
|
||||
if (oversized) {
|
||||
return
|
||||
}
|
||||
buffer += chunk
|
||||
// Why: the Orca runtime lives in Electron main, so it must reject
|
||||
// oversized local RPC frames instead of letting a local client grow an
|
||||
// unbounded buffer and stall the app.
|
||||
if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) {
|
||||
oversized = true
|
||||
this.messageHandler?.('', (response) => {
|
||||
socket.write(`${response}\n`)
|
||||
socket.end()
|
||||
})
|
||||
return
|
||||
}
|
||||
let newlineIndex = buffer.indexOf('\n')
|
||||
while (newlineIndex !== -1) {
|
||||
const rawMessage = buffer.slice(0, newlineIndex).trim()
|
||||
buffer = buffer.slice(newlineIndex + 1)
|
||||
if (rawMessage) {
|
||||
this.dispatchMessage(socket, rawMessage, inflight)
|
||||
}
|
||||
newlineIndex = buffer.indexOf('\n')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: the keepalive timer is opt-in per request via `startKeepalive()`.
|
||||
// Short RPCs never call it and pay no timer overhead; only long-poll
|
||||
// handlers (e.g. orchestration.check --wait) arm it. See §3.1.
|
||||
private dispatchMessage(
|
||||
socket: Socket,
|
||||
rawMessage: string,
|
||||
inflight: Set<AbortController>
|
||||
): void {
|
||||
let replied = false
|
||||
let keepaliveTimer: NodeJS.Timeout | null = null
|
||||
// Why: per-dispatch AbortController so completing one request does not
|
||||
// abort any sibling request running on the same connection. The
|
||||
// connection-level `socket.on('close')` iterates `inflight` to cancel all
|
||||
// outstanding dispatches at once.
|
||||
const abortController = new AbortController()
|
||||
inflight.add(abortController)
|
||||
|
||||
const reply = (response: string): void => {
|
||||
if (replied) {
|
||||
return
|
||||
}
|
||||
replied = true
|
||||
inflight.delete(abortController)
|
||||
if (keepaliveTimer) {
|
||||
clearInterval(keepaliveTimer)
|
||||
}
|
||||
if (!socket.destroyed && socket.writable) {
|
||||
socket.write(`${response}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
const startKeepalive = (): void => {
|
||||
if (keepaliveTimer || replied) {
|
||||
return
|
||||
}
|
||||
keepaliveTimer = setInterval(() => {
|
||||
if (replied || socket.destroyed || !socket.writable) {
|
||||
return
|
||||
}
|
||||
socket.write('{"_keepalive":true}\n')
|
||||
}, this.keepaliveIntervalMs)
|
||||
// Why: don't hold the process open solely on the keepalive interval.
|
||||
if (typeof keepaliveTimer.unref === 'function') {
|
||||
keepaliveTimer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
this.messageHandler?.(rawMessage, reply, {
|
||||
signal: abortController.signal,
|
||||
startKeepalive
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { mkdtempSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it, afterEach } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
import { WebSocketTransport } from './ws-transport'
|
||||
import { loadOrCreateTlsCertificate } from '../tls-certificate'
|
||||
|
||||
// Why: disable TLS verification for self-signed certs in tests.
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
|
||||
function makeTls() {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'ws-transport-test-'))
|
||||
return loadOrCreateTlsCertificate(userDataPath)
|
||||
}
|
||||
|
||||
function findFreePort(): number {
|
||||
// Why: use port 0 to let the OS assign an available port, but the transport
|
||||
// needs an explicit port. Use a high random port to minimize collisions.
|
||||
return 30000 + Math.floor(Math.random() * 20000)
|
||||
}
|
||||
|
||||
describe('WebSocketTransport', () => {
|
||||
const transports: WebSocketTransport[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(transports.map((t) => t.stop().catch(() => {})))
|
||||
transports.length = 0
|
||||
})
|
||||
|
||||
async function createTransport(
|
||||
handler?: (msg: string, reply: (response: string) => void) => void
|
||||
) {
|
||||
const tls = makeTls()
|
||||
const port = findFreePort()
|
||||
const transport = new WebSocketTransport({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
tlsCert: tls.cert,
|
||||
tlsKey: tls.key
|
||||
})
|
||||
if (handler) {
|
||||
transport.onMessage(handler)
|
||||
}
|
||||
transports.push(transport)
|
||||
return { transport, port, tls }
|
||||
}
|
||||
|
||||
function connectWs(port: number): Promise<WebSocket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(`wss://127.0.0.1:${port}`, {
|
||||
rejectUnauthorized: false
|
||||
})
|
||||
ws.once('open', () => resolve(ws))
|
||||
ws.once('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function sendAndReceive(ws: WebSocket, message: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
ws.once('message', (data) => {
|
||||
resolve(typeof data === 'string' ? data : data.toString('utf-8'))
|
||||
})
|
||||
ws.send(message)
|
||||
})
|
||||
}
|
||||
|
||||
it('starts and stops cleanly', async () => {
|
||||
const { transport } = await createTransport()
|
||||
|
||||
await transport.start()
|
||||
await transport.stop()
|
||||
})
|
||||
|
||||
it('handles request/response round-trip', async () => {
|
||||
const { transport, port } = await createTransport((msg, reply) => {
|
||||
const request = JSON.parse(msg)
|
||||
reply(JSON.stringify({ id: request.id, ok: true, result: { echo: true } }))
|
||||
})
|
||||
|
||||
await transport.start()
|
||||
|
||||
const ws = await connectWs(port)
|
||||
const response = await sendAndReceive(
|
||||
ws,
|
||||
JSON.stringify({ id: 'req-1', method: 'test', deviceToken: 'tok' })
|
||||
)
|
||||
|
||||
expect(JSON.parse(response)).toMatchObject({
|
||||
id: 'req-1',
|
||||
ok: true,
|
||||
result: { echo: true }
|
||||
})
|
||||
|
||||
ws.close()
|
||||
})
|
||||
|
||||
it('supports multiple concurrent connections', async () => {
|
||||
const { transport, port } = await createTransport((msg, reply) => {
|
||||
const request = JSON.parse(msg)
|
||||
reply(JSON.stringify({ id: request.id, ok: true }))
|
||||
})
|
||||
|
||||
await transport.start()
|
||||
|
||||
const clients = await Promise.all([connectWs(port), connectWs(port), connectWs(port)])
|
||||
|
||||
const responses = await Promise.all(
|
||||
clients.map((ws, i) => sendAndReceive(ws, JSON.stringify({ id: `req-${i}`, method: 'test' })))
|
||||
)
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(JSON.parse(responses[i]!)).toMatchObject({ id: `req-${i}`, ok: true })
|
||||
}
|
||||
|
||||
for (const ws of clients) {
|
||||
ws.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('multiplexes multiple requests on a single connection', async () => {
|
||||
const { transport, port } = await createTransport((msg, reply) => {
|
||||
const request = JSON.parse(msg)
|
||||
reply(JSON.stringify({ id: request.id, ok: true, result: { method: request.method } }))
|
||||
})
|
||||
|
||||
await transport.start()
|
||||
|
||||
const ws = await connectWs(port)
|
||||
|
||||
const r1 = sendAndReceive(ws, JSON.stringify({ id: 'a', method: 'first' }))
|
||||
const resp1 = JSON.parse(await r1)
|
||||
expect(resp1).toMatchObject({ id: 'a', result: { method: 'first' } })
|
||||
|
||||
const r2 = sendAndReceive(ws, JSON.stringify({ id: 'b', method: 'second' }))
|
||||
const resp2 = JSON.parse(await r2)
|
||||
expect(resp2).toMatchObject({ id: 'b', result: { method: 'second' } })
|
||||
|
||||
ws.close()
|
||||
})
|
||||
|
||||
it('sends multiple streaming responses via reply callback', async () => {
|
||||
const { transport, port } = await createTransport((msg, reply) => {
|
||||
const request = JSON.parse(msg)
|
||||
reply(JSON.stringify({ id: request.id, ok: true, streaming: true, result: { chunk: 1 } }))
|
||||
reply(JSON.stringify({ id: request.id, ok: true, streaming: true, result: { chunk: 2 } }))
|
||||
reply(JSON.stringify({ id: request.id, ok: true, result: { type: 'end' } }))
|
||||
})
|
||||
|
||||
await transport.start()
|
||||
|
||||
const ws = await connectWs(port)
|
||||
const messages: string[] = []
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.on('message', (data) => {
|
||||
messages.push(typeof data === 'string' ? data : data.toString('utf-8'))
|
||||
if (messages.length === 3) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
ws.send(JSON.stringify({ id: 'stream-1', method: 'terminal.subscribe' }))
|
||||
})
|
||||
|
||||
expect(JSON.parse(messages[0]!)).toMatchObject({ streaming: true, result: { chunk: 1 } })
|
||||
expect(JSON.parse(messages[1]!)).toMatchObject({ streaming: true, result: { chunk: 2 } })
|
||||
expect(JSON.parse(messages[2]!)).toMatchObject({ result: { type: 'end' } })
|
||||
|
||||
ws.close()
|
||||
})
|
||||
|
||||
it('rejects oversized messages by closing the connection', async () => {
|
||||
const { transport, port } = await createTransport()
|
||||
|
||||
await transport.start()
|
||||
|
||||
const ws = await connectWs(port)
|
||||
|
||||
// Why: ws maxPayload is 1MB — sending >1MB should trigger close.
|
||||
const oversized = 'x'.repeat(1024 * 1024 + 100)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.once('close', () => resolve())
|
||||
ws.send(oversized)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not crash when replying to a closed connection', async () => {
|
||||
let capturedReply: ((response: string) => void) | null = null
|
||||
|
||||
const { transport, port } = await createTransport((_msg, reply) => {
|
||||
capturedReply = reply
|
||||
})
|
||||
|
||||
await transport.start()
|
||||
|
||||
const ws = await connectWs(port)
|
||||
ws.send(JSON.stringify({ id: 'req-1', method: 'test' }))
|
||||
|
||||
// Why: wait for the handler to capture the reply function.
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (capturedReply) {
|
||||
clearInterval(interval)
|
||||
resolve()
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
|
||||
ws.close()
|
||||
|
||||
// Why: wait for the WebSocket to fully close before trying to reply.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// Should not throw — guards with readyState check.
|
||||
expect(() => capturedReply!(JSON.stringify({ id: 'req-1', ok: true }))).not.toThrow()
|
||||
})
|
||||
|
||||
it('is idempotent on double start', async () => {
|
||||
const { transport } = await createTransport()
|
||||
|
||||
await transport.start()
|
||||
await transport.start()
|
||||
|
||||
await transport.stop()
|
||||
})
|
||||
|
||||
it('is safe to stop without starting', async () => {
|
||||
const { transport } = await createTransport()
|
||||
await transport.stop()
|
||||
})
|
||||
|
||||
it('falls back to OS-assigned port when preferred port is in use', async () => {
|
||||
const { transport: first, port } = await createTransport()
|
||||
await first.start()
|
||||
|
||||
// Why: second transport requests the same port, which is now occupied.
|
||||
// It should silently fall back to an OS-assigned port instead of throwing.
|
||||
const tls = makeTls()
|
||||
const second = new WebSocketTransport({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
tlsCert: tls.cert,
|
||||
tlsKey: tls.key
|
||||
})
|
||||
transports.push(second)
|
||||
|
||||
await second.start()
|
||||
|
||||
expect(second.resolvedPort).not.toBe(port)
|
||||
expect(second.resolvedPort).toBeGreaterThan(0)
|
||||
|
||||
const ws = await connectWs(second.resolvedPort)
|
||||
ws.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
// Why: the WebSocket transport enables mobile clients to connect to the Orca
|
||||
// runtime over the local network. When TLS cert/key are provided it uses wss://
|
||||
// to prevent passive sniffing; otherwise it falls back to plain ws://. Per-device
|
||||
// tokens (validated by the message handler in OrcaRuntimeRpcServer) provide auth
|
||||
// regardless of transport encryption.
|
||||
import { createServer as createHttpsServer, type Server as HttpsServer } from 'https'
|
||||
import { createServer as createHttpServer, type Server as HttpServer } from 'http'
|
||||
import { WebSocketServer, type WebSocket } from 'ws'
|
||||
import type { RpcTransport } from './transport'
|
||||
|
||||
const MAX_WS_MESSAGE_BYTES = 1024 * 1024
|
||||
const MAX_WS_CONNECTIONS = 32
|
||||
|
||||
export type WebSocketTransportOptions = {
|
||||
host: string
|
||||
port: number
|
||||
tlsCert?: string
|
||||
tlsKey?: string
|
||||
}
|
||||
|
||||
export class WebSocketTransport implements RpcTransport {
|
||||
private readonly host: string
|
||||
private readonly port: number
|
||||
private readonly tlsCert: string | undefined
|
||||
private readonly tlsKey: string | undefined
|
||||
private httpServer: HttpsServer | HttpServer | null = null
|
||||
private wss: WebSocketServer | null = null
|
||||
private messageHandler:
|
||||
| ((msg: string, reply: (response: string) => void, ws: WebSocket) => void)
|
||||
| null = null
|
||||
private connectionCloseHandler: ((clientId: string) => void) | null = null
|
||||
// Why: maps each WebSocket to the clientId (deviceToken) that authenticated it,
|
||||
// so ws.on('close') can notify the runtime which mobile client disconnected.
|
||||
private wsClientIds = new Map<WebSocket, string>()
|
||||
|
||||
constructor({ host, port, tlsCert, tlsKey }: WebSocketTransportOptions) {
|
||||
this.host = host
|
||||
this.port = port
|
||||
this.tlsCert = tlsCert
|
||||
this.tlsKey = tlsKey
|
||||
}
|
||||
|
||||
onMessage(
|
||||
handler: (msg: string, reply: (response: string) => void, ws: WebSocket) => void
|
||||
): void {
|
||||
this.messageHandler = handler
|
||||
}
|
||||
|
||||
onConnectionClose(handler: (clientId: string) => void): void {
|
||||
this.connectionCloseHandler = handler
|
||||
}
|
||||
|
||||
setClientId(ws: WebSocket, clientId: string): void {
|
||||
this.wsClientIds.set(ws, clientId)
|
||||
}
|
||||
|
||||
// Why: when port 0 is passed the OS assigns a random available port. The
|
||||
// runtime metadata and mobile QR code need the real port, so callers read
|
||||
// it here after start() resolves.
|
||||
get resolvedPort(): number {
|
||||
const addr = this.httpServer?.address()
|
||||
if (addr && typeof addr === 'object') {
|
||||
return addr.port
|
||||
}
|
||||
return this.port
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.wss) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: when the preferred port is occupied (e.g. another Orca instance is
|
||||
// already running), fall back to an OS-assigned port so mobile pairing
|
||||
// still works. The QR code reads resolvedPort after start, so it will
|
||||
// advertise the correct port regardless.
|
||||
let port = this.port
|
||||
try {
|
||||
await this.tryListen(port)
|
||||
} catch (error: unknown) {
|
||||
if (isEAddressInUse(error) && port !== 0) {
|
||||
console.warn(`[ws-transport] Port ${port} is in use, falling back to OS-assigned port`)
|
||||
port = 0
|
||||
await this.tryListen(port)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createHttpServer(): HttpServer | HttpsServer {
|
||||
return this.tlsCert && this.tlsKey
|
||||
? createHttpsServer({ cert: this.tlsCert, key: this.tlsKey })
|
||||
: createHttpServer()
|
||||
}
|
||||
|
||||
// Why: the WebSocketServer is attached only after listen succeeds. If we
|
||||
// attached it before, the WSS would re-emit the EADDRINUSE error from the
|
||||
// httpServer as an uncatchable exception, preventing the fallback from working.
|
||||
private async tryListen(port: number): Promise<void> {
|
||||
const httpServer = this.createHttpServer()
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.once('error', reject)
|
||||
httpServer.listen(port, this.host, () => {
|
||||
httpServer.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
server: httpServer,
|
||||
maxPayload: MAX_WS_MESSAGE_BYTES
|
||||
})
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
if (wss.clients.size > MAX_WS_CONNECTIONS) {
|
||||
ws.close(1013, 'Maximum connections reached')
|
||||
return
|
||||
}
|
||||
this.handleConnection(ws)
|
||||
})
|
||||
|
||||
this.httpServer = httpServer
|
||||
this.wss = wss
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const wss = this.wss
|
||||
const httpServer = this.httpServer
|
||||
this.wss = null
|
||||
this.httpServer = null
|
||||
|
||||
if (wss) {
|
||||
for (const client of wss.clients) {
|
||||
client.close(1001, 'Server shutting down')
|
||||
}
|
||||
wss.close()
|
||||
}
|
||||
|
||||
if (httpServer) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: WebSocket connections are long-lived (unlike Unix socket which is
|
||||
// one-per-request). Multiple requests can be multiplexed on the same
|
||||
// connection via the RPC `id` field. The transport delegates all auth
|
||||
// and dispatch logic to the message handler set by OrcaRuntimeRpcServer.
|
||||
private handleConnection(ws: WebSocket): void {
|
||||
ws.on('message', (data) => {
|
||||
const msg = typeof data === 'string' ? data : data.toString('utf-8')
|
||||
this.messageHandler?.(
|
||||
msg,
|
||||
(response) => {
|
||||
// Why: mobile clients disconnect frequently (backgrounding, network
|
||||
// switch, phone locked). Guard writes to avoid errors on dead sockets.
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(response)
|
||||
}
|
||||
},
|
||||
ws
|
||||
)
|
||||
})
|
||||
|
||||
// Why: mobile clients disconnect when the phone locks, loses wifi, or
|
||||
// backgrounds the app. The runtime must clean up connection-scoped state
|
||||
// (e.g., mobile-fit overrides) to prevent orphaned phone-fit on desktop.
|
||||
ws.on('close', () => {
|
||||
const clientId = this.wsClientIds.get(ws)
|
||||
this.wsClientIds.delete(ws)
|
||||
if (clientId) {
|
||||
this.connectionCloseHandler?.(clientId)
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('error', () => {
|
||||
ws.close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isEAddressInUse(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'EADDRINUSE'
|
||||
}
|
||||
@@ -26,10 +26,12 @@ describe('runtime metadata', () => {
|
||||
writeRuntimeMetadata(userDataPath, {
|
||||
runtimeId: 'rt_123',
|
||||
pid: 42,
|
||||
transport: {
|
||||
kind: 'unix',
|
||||
endpoint: '/tmp/orca.sock'
|
||||
},
|
||||
transports: [
|
||||
{
|
||||
kind: 'unix',
|
||||
endpoint: '/tmp/orca.sock'
|
||||
}
|
||||
],
|
||||
authToken: 'secret',
|
||||
startedAt: 100
|
||||
})
|
||||
@@ -37,10 +39,12 @@ describe('runtime metadata', () => {
|
||||
expect(readRuntimeMetadata(userDataPath)).toEqual({
|
||||
runtimeId: 'rt_123',
|
||||
pid: 42,
|
||||
transport: {
|
||||
kind: 'unix',
|
||||
endpoint: '/tmp/orca.sock'
|
||||
},
|
||||
transports: [
|
||||
{
|
||||
kind: 'unix',
|
||||
endpoint: '/tmp/orca.sock'
|
||||
}
|
||||
],
|
||||
authToken: 'secret',
|
||||
startedAt: 100
|
||||
})
|
||||
@@ -53,7 +57,7 @@ describe('runtime metadata', () => {
|
||||
writeRuntimeMetadata(userDataPath, {
|
||||
runtimeId: 'rt_123',
|
||||
pid: 42,
|
||||
transport: null,
|
||||
transports: [],
|
||||
authToken: null,
|
||||
startedAt: 100
|
||||
})
|
||||
@@ -71,7 +75,7 @@ describe('runtime metadata', () => {
|
||||
writeRuntimeMetadata(userDataPath, {
|
||||
runtimeId: 'rt_owner',
|
||||
pid: 42,
|
||||
transport: null,
|
||||
transports: [],
|
||||
authToken: null,
|
||||
startedAt: 100
|
||||
})
|
||||
@@ -87,7 +91,7 @@ describe('runtime metadata', () => {
|
||||
writeRuntimeMetadata(userDataPath, {
|
||||
runtimeId: 'rt_replacement',
|
||||
pid: 999,
|
||||
transport: null,
|
||||
transports: [],
|
||||
authToken: null,
|
||||
startedAt: 200
|
||||
})
|
||||
@@ -109,7 +113,7 @@ describe('runtime metadata', () => {
|
||||
writeRuntimeMetadata(userDataPath, {
|
||||
runtimeId: 'rt_replacement',
|
||||
pid: 42,
|
||||
transport: null,
|
||||
transports: [],
|
||||
authToken: null,
|
||||
startedAt: 200
|
||||
})
|
||||
@@ -140,10 +144,12 @@ describe('runtime metadata', () => {
|
||||
writeRuntimeMetadata(userDataPath, {
|
||||
runtimeId: 'rt_123',
|
||||
pid: 42,
|
||||
transport: {
|
||||
kind: 'unix',
|
||||
endpoint: '/tmp/orca.sock'
|
||||
},
|
||||
transports: [
|
||||
{
|
||||
kind: 'unix',
|
||||
endpoint: '/tmp/orca.sock'
|
||||
}
|
||||
],
|
||||
authToken: 'secret',
|
||||
startedAt: 100
|
||||
})
|
||||
|
||||
@@ -167,8 +167,8 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
expect(metadata?.runtimeId).toBe(runtime.getRuntimeId())
|
||||
expect(metadata?.authToken).toBeTruthy()
|
||||
expect(metadata?.transport?.endpoint).toBeTruthy()
|
||||
expect(metadata?.transport).toEqual(server['transport'])
|
||||
expect(metadata?.transports?.[0]?.endpoint).toBeTruthy()
|
||||
expect(metadata?.transports).toEqual(server['transports'])
|
||||
|
||||
await server.stop()
|
||||
expect(readRuntimeMetadata(userDataPath)).toMatchObject({
|
||||
@@ -215,8 +215,8 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
await expect(server.start()).rejects.toThrow('write failed')
|
||||
expect(readRuntimeMetadata(userDataPath)).toBeNull()
|
||||
expect(existsSync(endpoint)).toBe(false)
|
||||
expect(server['transport']).toBeNull()
|
||||
expect(server['server']).toBeNull()
|
||||
expect(server['transports']).toEqual([])
|
||||
expect(server['activeTransports']).toEqual([])
|
||||
|
||||
writeMetadataSpy.mockRestore()
|
||||
})
|
||||
@@ -229,7 +229,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const response = await sendRequest(metadata!.transport!.endpoint, {
|
||||
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_1',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'status.get'
|
||||
@@ -255,7 +255,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const response = await sendRequest(metadata!.transport!.endpoint, {
|
||||
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_1',
|
||||
authToken: 'wrong',
|
||||
method: 'status.get'
|
||||
@@ -280,7 +280,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const response = await sendRequest(metadata!.transport!.endpoint, {
|
||||
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
|
||||
authToken: metadata!.authToken,
|
||||
method: 'status.get'
|
||||
})
|
||||
@@ -336,7 +336,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const listResponse = await sendRequest(metadata!.transport!.endpoint, {
|
||||
const listResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_list',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.list',
|
||||
@@ -360,7 +360,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
).handle
|
||||
expect(handle).toBeTruthy()
|
||||
|
||||
const showResponse = await sendRequest(metadata!.transport!.endpoint, {
|
||||
const showResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_show',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.show',
|
||||
@@ -373,7 +373,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
ok: true
|
||||
})
|
||||
|
||||
const readResponse = await sendRequest(metadata!.transport!.endpoint, {
|
||||
const readResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_read',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.read',
|
||||
@@ -386,7 +386,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
ok: true
|
||||
})
|
||||
|
||||
const sendResponse = await sendRequest(metadata!.transport!.endpoint, {
|
||||
const sendResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_send',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.send',
|
||||
@@ -402,7 +402,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
})
|
||||
expect(writes).toEqual(['continue', '\r'])
|
||||
|
||||
const waitPromise = sendRequest(metadata!.transport!.endpoint, {
|
||||
const waitPromise = sendRequest(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_wait',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'terminal.wait',
|
||||
@@ -462,7 +462,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const response = await sendRequest(metadata!.transport!.endpoint, {
|
||||
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_ps',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'worktree.ps'
|
||||
@@ -503,7 +503,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const response = await sendRequest(metadata!.transport!.endpoint, {
|
||||
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_worktrees',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'worktree.list',
|
||||
@@ -533,7 +533,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const response = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const socket = createConnection(metadata!.transport!.endpoint)
|
||||
const socket = createConnection(metadata!.transports[0]!.endpoint)
|
||||
let buffer = ''
|
||||
socket.setEncoding('utf8')
|
||||
socket.once('error', reject)
|
||||
@@ -581,7 +581,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
|
||||
try {
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const session = openFramedSession(metadata!.transport!.endpoint, {
|
||||
const session = openFramedSession(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_wait',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'orchestration.check',
|
||||
@@ -621,7 +621,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
|
||||
try {
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const endpoint = metadata!.transport!.endpoint
|
||||
const endpoint = metadata!.transports[0]!.endpoint
|
||||
|
||||
// Fill the cap with two long waits (10s each — we'll kill them).
|
||||
const a = openFramedSession(endpoint, {
|
||||
@@ -681,7 +681,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
|
||||
try {
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const endpoint = metadata!.transport!.endpoint
|
||||
const endpoint = metadata!.transports[0]!.endpoint
|
||||
|
||||
const a = openFramedSession(endpoint, {
|
||||
id: 'req_a',
|
||||
@@ -722,6 +722,73 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not emit keepalive frames for short RPCs', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
// Why: a 10ms interval means any frame in the first ~100ms of a short
|
||||
// RPC would show up; `status.get` returns in <10ms so no keepalive
|
||||
// should ever fire. Locks in the "keepalive is long-poll-only" invariant
|
||||
// so a future refactor can't silently re-broaden the timer.
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath,
|
||||
keepaliveIntervalMs: 10
|
||||
})
|
||||
await server.start()
|
||||
|
||||
try {
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const session = openFramedSession(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_short',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'status.get'
|
||||
})
|
||||
await session.done
|
||||
|
||||
const keepalives = session.frames.filter((f) => f._keepalive === true)
|
||||
const terminals = session.frames.filter((f) => f.ok !== undefined)
|
||||
expect(terminals).toHaveLength(1)
|
||||
expect(terminals[0]).toMatchObject({ id: 'req_short', ok: true })
|
||||
expect(keepalives).toHaveLength(0)
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns an internal_error envelope when the dispatcher throws', async () => {
|
||||
// Why: handlers are designed to return error envelopes, never to throw,
|
||||
// but a bug somewhere in the RPC stack (e.g. JSON.stringify choking on
|
||||
// a response with circular refs) must still produce a terminal frame.
|
||||
// Without the `.catch` on handleMessage's promise, a throw would leave
|
||||
// the client hanging until the 30s idle timer and leak the dispatch's
|
||||
// AbortController in the transport's in-flight set.
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
await server.start()
|
||||
|
||||
// Force the dispatcher to throw a non-envelope error.
|
||||
const originalDispatch = server['dispatcher'].dispatch.bind(server['dispatcher'])
|
||||
server['dispatcher'].dispatch = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
|
||||
try {
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
|
||||
id: 'req_throw',
|
||||
authToken: metadata!.authToken,
|
||||
method: 'status.get'
|
||||
})
|
||||
expect(response).toMatchObject({
|
||||
id: 'req_throw',
|
||||
ok: false,
|
||||
error: { code: 'internal_error', message: 'boom' }
|
||||
})
|
||||
} finally {
|
||||
server['dispatcher'].dispatch = originalDispatch
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Why: §6 test for the idempotent + hard-fail schema migration. A broken
|
||||
|
||||
+253
-168
@@ -1,12 +1,11 @@
|
||||
/* eslint-disable max-lines -- Why: this file is the single security boundary for the bundled CLI — transport setup, auth-token enforcement, admission control, keepalive framing, and orphan-socket sweeping all co-locate deliberately so a reviewer can audit the boundary in one sitting. Splitting this across files would scatter the invariants without reducing complexity. */
|
||||
// Why: this is the single security boundary for the bundled CLI. It owns
|
||||
// transport setup (unix socket / named pipe), auth-token enforcement, and
|
||||
// bootstrap-metadata publication so a running runtime is always discoverable
|
||||
// via exactly one on-disk file. Method handling lives in `rpc/` so this file
|
||||
// stays easy to audit in one sitting.
|
||||
// auth-token enforcement, bootstrap-metadata publication, and transport
|
||||
// orchestration so a running runtime is always discoverable via exactly
|
||||
// one on-disk file. Method handling lives in `rpc/` and transport specifics
|
||||
// live in `rpc/unix-socket-transport.ts` and `rpc/ws-transport.ts`.
|
||||
import { randomBytes } from 'crypto'
|
||||
import { createServer, type Server, type Socket } from 'net'
|
||||
import { chmodSync, existsSync, readdirSync, rmSync } from 'fs'
|
||||
import { readdirSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
@@ -14,12 +13,23 @@ import { writeRuntimeMetadata } from './runtime-metadata'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import type { RpcRequest, RpcResponse } from './rpc/core'
|
||||
import { errorResponse } from './rpc/errors'
|
||||
import type { RpcMessageContext, RpcTransport } from './rpc/transport'
|
||||
import { UnixSocketTransport } from './rpc/unix-socket-transport'
|
||||
import { WebSocketTransport } from './rpc/ws-transport'
|
||||
import type { WebSocket } from 'ws'
|
||||
import { DeviceRegistry } from './device-registry'
|
||||
import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair'
|
||||
import { E2EEChannel } from './rpc/e2ee-channel'
|
||||
|
||||
const DEFAULT_WS_PORT = 6768
|
||||
|
||||
type OrcaRuntimeRpcServerOptions = {
|
||||
runtime: OrcaRuntimeService
|
||||
userDataPath: string
|
||||
pid?: number
|
||||
platform?: NodeJS.Platform
|
||||
enableWebSocket?: boolean
|
||||
wsPort?: number
|
||||
// Why: test-only overrides for the two time-bound constants below.
|
||||
// Production callers must not pass these — defaults are set by the design
|
||||
// doc (§3.1) and changing them in production would weaken the admission
|
||||
@@ -28,10 +38,6 @@ type OrcaRuntimeRpcServerOptions = {
|
||||
longPollCap?: number
|
||||
}
|
||||
|
||||
const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024
|
||||
const RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS = 30_000
|
||||
const MAX_RUNTIME_RPC_CONNECTIONS = 32
|
||||
|
||||
// Why: after 10 s of a pending dispatch we emit a tiny `{"_keepalive":true}`
|
||||
// frame every 10 s until the handler resolves. Each write resets both the
|
||||
// server's own socket idle timer (30 s) and — once §3.1 ships on the client —
|
||||
@@ -69,11 +75,19 @@ export class OrcaRuntimeRpcServer {
|
||||
private readonly userDataPath: string
|
||||
private readonly pid: number
|
||||
private readonly platform: NodeJS.Platform
|
||||
private readonly enableWebSocket: boolean
|
||||
private readonly wsPort: number
|
||||
private readonly authToken = randomBytes(24).toString('hex')
|
||||
private readonly keepaliveIntervalMs: number
|
||||
private readonly longPollCap: number
|
||||
private server: Server | null = null
|
||||
private transport: RuntimeTransportMetadata | null = null
|
||||
private deviceRegistry: DeviceRegistry | null = null
|
||||
private e2eeKeypair: E2EEKeypair | null = null
|
||||
private tlsFingerprint: string | null = null
|
||||
private activeTransports: RpcTransport[] = []
|
||||
private transports: RuntimeTransportMetadata[] = []
|
||||
// Why: each WebSocket connection has its own E2EE channel that manages the
|
||||
// handshake and encrypt/decrypt lifecycle. Keyed by WebSocket instance.
|
||||
private e2eeChannels = new Map<WebSocket, E2EEChannel>()
|
||||
// Why: separate from Node's server.maxConnections because we need to count
|
||||
// only long-running dispatches, not every in-flight short RPC. See §3.1 +
|
||||
// §7 risk #2.
|
||||
@@ -84,6 +98,8 @@ export class OrcaRuntimeRpcServer {
|
||||
userDataPath,
|
||||
pid = process.pid,
|
||||
platform = process.platform,
|
||||
enableWebSocket = false,
|
||||
wsPort = DEFAULT_WS_PORT,
|
||||
keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS,
|
||||
longPollCap = LONG_POLL_CAP
|
||||
}: OrcaRuntimeRpcServerOptions) {
|
||||
@@ -92,12 +108,35 @@ export class OrcaRuntimeRpcServer {
|
||||
this.userDataPath = userDataPath
|
||||
this.pid = pid
|
||||
this.platform = platform
|
||||
this.enableWebSocket = enableWebSocket
|
||||
this.wsPort = wsPort
|
||||
this.keepaliveIntervalMs = keepaliveIntervalMs
|
||||
this.longPollCap = longPollCap
|
||||
}
|
||||
|
||||
getDeviceRegistry(): DeviceRegistry | null {
|
||||
return this.deviceRegistry
|
||||
}
|
||||
|
||||
getTlsFingerprint(): string | null {
|
||||
return this.tlsFingerprint
|
||||
}
|
||||
|
||||
getE2EEPublicKey(): string | null {
|
||||
return this.e2eeKeypair?.publicKeyB64 ?? null
|
||||
}
|
||||
|
||||
getE2EEKeypair(): E2EEKeypair | null {
|
||||
return this.e2eeKeypair
|
||||
}
|
||||
|
||||
getWebSocketEndpoint(): string | null {
|
||||
const ws = this.transports.find((t) => t.kind === 'websocket')
|
||||
return ws?.endpoint ?? null
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.server) {
|
||||
if (this.activeTransports.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -111,82 +150,160 @@ export class OrcaRuntimeRpcServer {
|
||||
sweepOrphanedRuntimeSockets(this.userDataPath, this.pid)
|
||||
}
|
||||
|
||||
const transport = createRuntimeTransportMetadata(
|
||||
const transportMeta = createRuntimeTransportMetadata(
|
||||
this.userDataPath,
|
||||
this.pid,
|
||||
this.platform,
|
||||
this.runtime.getRuntimeId()
|
||||
)
|
||||
if (transport.kind === 'unix' && existsSync(transport.endpoint)) {
|
||||
rmSync(transport.endpoint, { force: true })
|
||||
}
|
||||
|
||||
const server = createServer((socket) => {
|
||||
this.handleConnection(socket)
|
||||
const socketTransport = new UnixSocketTransport({
|
||||
endpoint: transportMeta.endpoint,
|
||||
kind: transportMeta.kind as 'unix' | 'named-pipe',
|
||||
keepaliveIntervalMs: this.keepaliveIntervalMs
|
||||
})
|
||||
server.maxConnections = MAX_RUNTIME_RPC_CONNECTIONS
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(transport.endpoint, () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
// Why: Unix socket transport uses the shared runtime auth token. This is
|
||||
// the existing security model for CLI connections — the token lives in a
|
||||
// 0o600-permissioned file on disk.
|
||||
// Why: the `.catch` guarantees `reply()` always fires even if
|
||||
// `handleMessage` (or `JSON.stringify` on a pathological response) throws.
|
||||
// Without it, a throw would leave the client waiting for a terminal frame
|
||||
// that never arrives AND leak the dispatch's AbortController in the
|
||||
// transport's in-flight set until the 30 s socket idle timer closes the
|
||||
// connection.
|
||||
socketTransport.onMessage((msg, reply, context) => {
|
||||
void this.handleMessage(msg, context)
|
||||
.then((response) => {
|
||||
reply(JSON.stringify(response))
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// Why: best-effort id recovery so the client can correlate the
|
||||
// error frame to its pending request. A malformed message would
|
||||
// have been caught by handleMessage and returned an envelope
|
||||
// instead of throwing, so in practice the id is always present.
|
||||
let id = 'unknown'
|
||||
try {
|
||||
const parsed = JSON.parse(msg) as { id?: unknown }
|
||||
if (typeof parsed.id === 'string' && parsed.id.length > 0) {
|
||||
id = parsed.id
|
||||
}
|
||||
} catch {
|
||||
// ignore — fall through with id='unknown'
|
||||
}
|
||||
reply(JSON.stringify(this.buildError(id, 'internal_error', message)))
|
||||
})
|
||||
})
|
||||
if (transport.kind === 'unix') {
|
||||
chmodSync(transport.endpoint, 0o600)
|
||||
|
||||
await socketTransport.start()
|
||||
|
||||
const activeTransports: RpcTransport[] = [socketTransport]
|
||||
const transportsMeta: RuntimeTransportMetadata[] = [transportMeta]
|
||||
|
||||
// Why: WebSocket transport is opt-in and starts alongside the Unix socket.
|
||||
// It uses per-device tokens and E2EE (application-layer encryption via
|
||||
// tweetnacl) rather than TLS, since React Native can't pin self-signed certs.
|
||||
if (this.enableWebSocket) {
|
||||
try {
|
||||
this.deviceRegistry = new DeviceRegistry(this.userDataPath)
|
||||
this.e2eeKeypair = loadOrCreateE2EEKeypair(this.userDataPath)
|
||||
|
||||
const wsTransport = new WebSocketTransport({
|
||||
host: '0.0.0.0',
|
||||
port: this.wsPort
|
||||
})
|
||||
|
||||
// Why: each WebSocket connection gets an E2EE channel that handles the
|
||||
// handshake before any RPC messages are processed. The channel decrypts
|
||||
// inbound messages and encrypts outbound replies transparently.
|
||||
wsTransport.onMessage((msg, _reply, ws) => {
|
||||
let channel = this.e2eeChannels.get(ws)
|
||||
if (!channel) {
|
||||
channel = new E2EEChannel(ws, {
|
||||
serverSecretKey: this.e2eeKeypair!.secretKey,
|
||||
validateToken: (token) => this.deviceRegistry?.validateToken(token) != null,
|
||||
onReady: (ch) => {
|
||||
if (ch.deviceToken) {
|
||||
wsTransport.setClientId(ws, ch.deviceToken)
|
||||
// Why: mark the device as actually connected so it appears
|
||||
// in the "Paired Devices" list. Devices that were only
|
||||
// generated as QR codes but never scanned stay hidden.
|
||||
const device = this.deviceRegistry?.validateToken(ch.deviceToken)
|
||||
if (device) {
|
||||
this.deviceRegistry?.updateLastSeen(device.deviceId)
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (code, reason) => {
|
||||
this.e2eeChannels.get(ws)?.destroy()
|
||||
this.e2eeChannels.delete(ws)
|
||||
ws.close(code, reason)
|
||||
}
|
||||
})
|
||||
channel.onMessage((plaintext, encryptedReply) => {
|
||||
void this.handleWebSocketMessage(plaintext, encryptedReply, wsTransport, ws)
|
||||
})
|
||||
this.e2eeChannels.set(ws, channel)
|
||||
}
|
||||
channel.handleRawMessage(msg)
|
||||
})
|
||||
|
||||
// Why: when a mobile client disconnects, the runtime must clean up
|
||||
// connection-scoped state like mobile-fit overrides and the E2EE
|
||||
// channel to prevent orphaned state.
|
||||
wsTransport.onConnectionClose((clientId) => {
|
||||
for (const [ws, channel] of this.e2eeChannels) {
|
||||
if (channel.deviceToken === clientId) {
|
||||
channel.destroy()
|
||||
this.e2eeChannels.delete(ws)
|
||||
break
|
||||
}
|
||||
}
|
||||
this.runtime.onClientDisconnected(clientId)
|
||||
})
|
||||
|
||||
await wsTransport.start()
|
||||
activeTransports.push(wsTransport)
|
||||
transportsMeta.push({
|
||||
kind: 'websocket',
|
||||
endpoint: `ws://0.0.0.0:${wsTransport.resolvedPort}`
|
||||
})
|
||||
} catch (error) {
|
||||
// Why: WebSocket transport is supplementary — the runtime must still
|
||||
// function if it fails to start (e.g., port in use). Log and continue
|
||||
// with Unix socket only.
|
||||
console.error('[runtime] Failed to start WebSocket transport:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: publish the transport into in-memory state before writing metadata
|
||||
// so the bootstrap file always contains the real endpoint/token pair. The
|
||||
// CLI only discovers the runtime through that file.
|
||||
this.server = server
|
||||
this.transport = transport
|
||||
this.activeTransports = activeTransports
|
||||
this.transports = transportsMeta
|
||||
|
||||
try {
|
||||
this.writeMetadata()
|
||||
} catch (error) {
|
||||
// Why: a runtime that cannot publish bootstrap metadata is invisible to
|
||||
// the `orca` CLI. Close the socket immediately instead of leaving behind
|
||||
// a live but undiscoverable control plane.
|
||||
this.server = null
|
||||
this.transport = null
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((closeError) => {
|
||||
if (closeError) {
|
||||
reject(closeError)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
}).catch(() => {})
|
||||
if (transport.kind === 'unix' && existsSync(transport.endpoint)) {
|
||||
rmSync(transport.endpoint, { force: true })
|
||||
}
|
||||
// the `orca` CLI. Close all transports immediately instead of leaving
|
||||
// behind a live but undiscoverable control plane.
|
||||
this.activeTransports = []
|
||||
this.transports = []
|
||||
await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const server = this.server
|
||||
const transport = this.transport
|
||||
this.server = null
|
||||
this.transport = null
|
||||
if (!server) {
|
||||
const transports = this.activeTransports
|
||||
this.activeTransports = []
|
||||
this.transports = []
|
||||
if (transports.length === 0) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
if (transport?.kind === 'unix' && existsSync(transport.endpoint)) {
|
||||
rmSync(transport.endpoint, { force: true })
|
||||
}
|
||||
await Promise.all(transports.map((t) => t.stop()))
|
||||
// Why: we intentionally leave the last metadata file behind instead of
|
||||
// deleting it on shutdown. Shared userData paths can briefly host multiple
|
||||
// Orca processes during restarts, updates, or development, and stale
|
||||
@@ -194,120 +311,53 @@ export class OrcaRuntimeRpcServer {
|
||||
// bootstrap file.
|
||||
}
|
||||
|
||||
private handleConnection(socket: Socket): void {
|
||||
let buffer = ''
|
||||
// Why: Unix socket messages use one-shot dispatch (single response per
|
||||
// request) and the shared runtime auth token from the 0o600 metadata file.
|
||||
// The transport layer owns socket lifecycle, keepalive writes, and the
|
||||
// per-connection abort signal — this method just parses, auths, and
|
||||
// dispatches. See design doc §3.1.
|
||||
private async handleMessage(
|
||||
rawMessage: string,
|
||||
context?: RpcMessageContext
|
||||
): Promise<RpcResponse> {
|
||||
// Why: empty messages are sent by the Unix socket transport layer when a
|
||||
// client exceeds the max message size. The transport closes the connection
|
||||
// after this response.
|
||||
if (!rawMessage) {
|
||||
return this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size')
|
||||
}
|
||||
|
||||
socket.setEncoding('utf8')
|
||||
socket.setNoDelay(true)
|
||||
socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => {
|
||||
socket.destroy()
|
||||
})
|
||||
socket.on('error', () => {
|
||||
socket.destroy()
|
||||
})
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk
|
||||
// Why: the Orca runtime lives in Electron main, so it must reject
|
||||
// oversized local RPC frames instead of letting a local client grow an
|
||||
// unbounded buffer and stall the app.
|
||||
if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) {
|
||||
socket.write(
|
||||
`${JSON.stringify(this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size'))}\n`
|
||||
)
|
||||
socket.end()
|
||||
return
|
||||
}
|
||||
let newlineIndex = buffer.indexOf('\n')
|
||||
while (newlineIndex !== -1) {
|
||||
const rawMessage = buffer.slice(0, newlineIndex).trim()
|
||||
buffer = buffer.slice(newlineIndex + 1)
|
||||
if (rawMessage) {
|
||||
void this.handleRequest(socket, rawMessage)
|
||||
}
|
||||
newlineIndex = buffer.indexOf('\n')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: a single entry point per inbound request so keepalive + long-poll
|
||||
// admission + AbortController wiring + response write all live in one
|
||||
// place. See design doc §3.1.
|
||||
private async handleRequest(socket: Socket, rawMessage: string): Promise<void> {
|
||||
const parsed = this.parseAndAuth(rawMessage)
|
||||
if ('error' in parsed) {
|
||||
this.safeWrite(socket, `${JSON.stringify(parsed.error)}\n`)
|
||||
return
|
||||
return parsed.error
|
||||
}
|
||||
const request = parsed.request
|
||||
|
||||
// Why: long-poll admission fence. Short RPCs bypass the counter entirely
|
||||
// — it only guards handlers that can block for minutes. See §7 risk #2.
|
||||
const longPoll = isLongPollRequest(request)
|
||||
if (longPoll && this.activeLongPolls >= this.longPollCap) {
|
||||
return this.buildError(
|
||||
request.id,
|
||||
'runtime_busy',
|
||||
'long-poll capacity reached; retry with backoff'
|
||||
)
|
||||
}
|
||||
if (longPoll) {
|
||||
if (this.activeLongPolls >= this.longPollCap) {
|
||||
const busy = this.buildError(
|
||||
request.id,
|
||||
'runtime_busy',
|
||||
'long-poll capacity reached; retry with backoff'
|
||||
)
|
||||
this.safeWrite(socket, `${JSON.stringify(busy)}\n`)
|
||||
socket.end()
|
||||
return
|
||||
}
|
||||
this.activeLongPolls += 1
|
||||
}
|
||||
|
||||
// Why: `decremented` must guard against double-decrement when both
|
||||
// `close` and a post-resolve cleanup path fire. `socket.on('close')` is
|
||||
// the only path that fires for every termination (normal end, destroy,
|
||||
// idle timer, client kill -9, OS reset), so it carries the decrement.
|
||||
// Tying it to `.finally` alone would leak a slot any time a client dies
|
||||
// mid-wait because the inner waitForMessage can keep counting down for
|
||||
// minutes after the socket is gone. See §3.1 counter-lifecycle.
|
||||
let decremented = !longPoll
|
||||
const abortController = new AbortController()
|
||||
const onClose = (): void => {
|
||||
if (!decremented) {
|
||||
decremented = true
|
||||
this.activeLongPolls = Math.max(0, this.activeLongPolls - 1)
|
||||
}
|
||||
abortController.abort()
|
||||
}
|
||||
socket.on('close', onClose)
|
||||
|
||||
// Why: for long-poll requests we start a keepalive ticker after 10 s. The
|
||||
// first frame at 10 s resets both the server's 30 s idle timer and the
|
||||
// client's configured timeout. Short RPCs never see a keepalive — the
|
||||
// ticker never fires because the handler resolves first.
|
||||
let keepaliveTimer: NodeJS.Timeout | null = null
|
||||
if (longPoll) {
|
||||
keepaliveTimer = setInterval(() => {
|
||||
if (socket.writable && !socket.destroyed) {
|
||||
socket.write('{"_keepalive":true}\n')
|
||||
}
|
||||
}, this.keepaliveIntervalMs)
|
||||
// Why: don't hold the process open solely on the keepalive interval —
|
||||
// .unref() lets the event loop exit when nothing else is pending.
|
||||
if (typeof keepaliveTimer.unref === 'function') {
|
||||
keepaliveTimer.unref()
|
||||
}
|
||||
// Why: arm the keepalive timer only for long-polls. Short RPCs never
|
||||
// touch it so the `setInterval` is never created. See §3.1.
|
||||
context?.startKeepalive()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.dispatcher.dispatch(request, {
|
||||
signal: longPoll ? abortController.signal : undefined
|
||||
return await this.dispatcher.dispatch(request, {
|
||||
signal: longPoll ? context?.signal : undefined
|
||||
})
|
||||
if (!socket.destroyed) {
|
||||
this.safeWrite(socket, `${JSON.stringify(response)}\n`)
|
||||
}
|
||||
} finally {
|
||||
if (keepaliveTimer) {
|
||||
clearInterval(keepaliveTimer)
|
||||
if (longPoll) {
|
||||
this.activeLongPolls = Math.max(0, this.activeLongPolls - 1)
|
||||
}
|
||||
// Why: the close-handler path is still what decrements the counter (the
|
||||
// socket may be closed by the client before the response write flushes).
|
||||
// We don't remove the listener here — `once` semantics are handled by
|
||||
// the boolean guard.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,16 +385,51 @@ export class OrcaRuntimeRpcServer {
|
||||
return { request }
|
||||
}
|
||||
|
||||
private safeWrite(socket: Socket, payload: string): void {
|
||||
if (socket.destroyed || !socket.writable) {
|
||||
// Why: WebSocket messages go through streaming dispatch which can emit
|
||||
// multiple responses. Auth uses per-device tokens from the device registry.
|
||||
private async handleWebSocketMessage(
|
||||
rawMessage: string,
|
||||
reply: (response: string) => void,
|
||||
wsTransport?: WebSocketTransport,
|
||||
ws?: WebSocket
|
||||
): Promise<void> {
|
||||
let request: RpcRequest
|
||||
try {
|
||||
request = JSON.parse(rawMessage) as RpcRequest
|
||||
} catch {
|
||||
reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Invalid JSON request')))
|
||||
return
|
||||
}
|
||||
try {
|
||||
socket.write(payload)
|
||||
} catch {
|
||||
// Socket was closed in between the writable check and the write —
|
||||
// nothing we can do; the client already disconnected.
|
||||
|
||||
if (typeof request.id !== 'string' || request.id.length === 0) {
|
||||
reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Missing request id')))
|
||||
return
|
||||
}
|
||||
if (typeof request.method !== 'string' || request.method.length === 0) {
|
||||
reply(JSON.stringify(this.buildError(request.id, 'bad_request', 'Missing RPC method')))
|
||||
return
|
||||
}
|
||||
|
||||
const token =
|
||||
typeof (request as Record<string, unknown>).deviceToken === 'string'
|
||||
? ((request as Record<string, unknown>).deviceToken as string)
|
||||
: null
|
||||
if (!token) {
|
||||
reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token')))
|
||||
return
|
||||
}
|
||||
if (!this.deviceRegistry?.validateToken(token)) {
|
||||
reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Invalid device token')))
|
||||
return
|
||||
}
|
||||
|
||||
// Why: associate the deviceToken with this WebSocket so ws.on('close')
|
||||
// can notify the runtime which mobile client disconnected.
|
||||
if (wsTransport && ws) {
|
||||
wsTransport.setClientId(ws, token)
|
||||
}
|
||||
|
||||
await this.dispatcher.dispatchStreaming(request, reply)
|
||||
}
|
||||
|
||||
private buildError(id: string, code: string, message: string): RpcResponse {
|
||||
@@ -355,7 +440,7 @@ export class OrcaRuntimeRpcServer {
|
||||
const metadata: RuntimeMetadata = {
|
||||
runtimeId: this.runtime.getRuntimeId(),
|
||||
pid: this.pid,
|
||||
transport: this.transport,
|
||||
transports: this.transports,
|
||||
authToken: this.authToken,
|
||||
startedAt: this.runtime.getStartedAt()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Why: the WebSocket transport uses wss:// with a self-signed TLS certificate
|
||||
// to prevent passive sniffing of device tokens on shared WiFi networks. The
|
||||
// cert is generated once on first run and reused across restarts. The mobile
|
||||
// app pins the certificate fingerprint received during QR pairing.
|
||||
import { createHash } from 'crypto'
|
||||
import { execSync } from 'child_process'
|
||||
import { existsSync, readFileSync, chmodSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const TLS_CERT_FILENAME = 'orca-tls-cert.pem'
|
||||
const TLS_KEY_FILENAME = 'orca-tls-key.pem'
|
||||
|
||||
export type TlsCertificate = {
|
||||
cert: string
|
||||
key: string
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export function loadOrCreateTlsCertificate(userDataPath: string): TlsCertificate {
|
||||
const certPath = join(userDataPath, TLS_CERT_FILENAME)
|
||||
const keyPath = join(userDataPath, TLS_KEY_FILENAME)
|
||||
|
||||
if (existsSync(certPath) && existsSync(keyPath)) {
|
||||
const cert = readFileSync(certPath, 'utf-8')
|
||||
const key = readFileSync(keyPath, 'utf-8')
|
||||
const fingerprint = computeFingerprint(cert)
|
||||
if (fingerprint) {
|
||||
return { cert, key, fingerprint }
|
||||
}
|
||||
// Why: if the existing cert is malformed (e.g., from a buggy earlier
|
||||
// generation), regenerate rather than failing the WebSocket transport.
|
||||
}
|
||||
|
||||
const keyPath_ = join(userDataPath, TLS_KEY_FILENAME)
|
||||
const certPath_ = join(userDataPath, TLS_CERT_FILENAME)
|
||||
|
||||
// Why: openssl is available on macOS, Linux, and Windows (via Git Bash).
|
||||
// Using it avoids hand-rolling ASN.1 DER encoding which is error-prone.
|
||||
execSync(
|
||||
`openssl req -new -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 ` +
|
||||
`-nodes -days 3650 -subj "/CN=Orca Runtime" ` +
|
||||
`-keyout "${keyPath_}" -out "${certPath_}" 2>/dev/null`
|
||||
)
|
||||
|
||||
chmodSync(keyPath_, 0o600)
|
||||
chmodSync(certPath_, 0o600)
|
||||
|
||||
const cert = readFileSync(certPath_, 'utf-8')
|
||||
const key = readFileSync(keyPath_, 'utf-8')
|
||||
return { cert, key, fingerprint: computeFingerprint(cert)! }
|
||||
}
|
||||
|
||||
function computeFingerprint(certPem: string): string | null {
|
||||
const derMatch = certPem.match(
|
||||
/-----BEGIN CERTIFICATE-----\n([\s\S]+?)\n-----END CERTIFICATE-----/
|
||||
)
|
||||
if (!derMatch?.[1]) {
|
||||
return null
|
||||
}
|
||||
const der = Buffer.from(derMatch[1].replace(/\n/g, ''), 'base64')
|
||||
const hash = createHash('sha256').update(der).digest('hex')
|
||||
return `sha256:${hash}`
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user