feat(mobile): Expo companion app [beta] (#1245)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-04 13:08:27 -07:00
committed by GitHub
co-authored by Orca
parent 7093d2f391
commit fc578f5ea9
133 changed files with 30973 additions and 471 deletions
+71
View File
@@ -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
+43
View File
@@ -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
+203
View File
@@ -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.
+12
View File
@@ -0,0 +1,12 @@
node_modules/
.expo/
dist/
android/
ios/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
+3
View File
@@ -0,0 +1,3 @@
{
"rules": {}
}
+172
View File
@@ -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
```
+61
View File
@@ -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
}
}
]
]
}
}
+73
View File
@@ -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
}
})
+145
View File
@@ -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
+16
View File
@@ -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>
)
}
+947
View File
@@ -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
}
})
+109
View File
@@ -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
}
})
+307
View File
@@ -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'
}
})
+102
View File
@@ -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
}
})
+455
View File
@@ -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

+808
View File
@@ -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&nbsp;&nbsp;·&nbsp;&nbsp;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>
+53
View File
@@ -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"
}
}
+12571
View File
File diff suppressed because it is too large Load Diff
+321
View File
@@ -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`)
+362
View File
@@ -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)
})
+324
View File
@@ -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)
})
+35
View File
@@ -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
}
+133
View File
@@ -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
}
})
+62
View File
@@ -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'
}
})
+252
View File
@@ -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
}
})
+111
View File
@@ -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'
}
})
+338
View File
@@ -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'
}
})
+756
View File
@@ -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'
}
})
+23
View File
@@ -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>
)
}
+108
View File
@@ -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
}
})
+25
View File
@@ -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
}
})
+151
View File
@@ -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)
}
}
}
+13
View File
@@ -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(() => {})
}
}
+88
View File
@@ -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))
}
+633
View File
@@ -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
}
})
+45
View File
@@ -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
+91
View File
@@ -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)
}
+66
View File
@@ -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))
}
}
+19
View File
@@ -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'
+16
View File
@@ -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
}
}
+371
View File
@@ -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')
}
}
}
+62
View File
@@ -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.
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx"]
}
+3
View File
@@ -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",
+156
View File
@@ -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
+182
View File
@@ -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
```
+6 -4
View File
@@ -35,10 +35,12 @@ function writeMetadata(
JSON.stringify({
runtimeId: 'runtime-1',
pid,
transport: {
kind: 'unix',
endpoint
},
transports: [
{
kind: 'unix',
endpoint
}
],
authToken,
startedAt: 1
}),
+6 -2
View File
@@ -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}`
+1 -1
View File
@@ -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,
+12 -6
View File
@@ -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',
+1
View File
@@ -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',
+6 -2
View File
@@ -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,
+5 -1
View File
@@ -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
View File
@@ -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
+125
View File
@@ -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()
}
})
}
+18 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -80,7 +80,7 @@ export function registerCoreHandlers(
registerExportHandlers()
registerStatsHandlers(stats)
registerMemoryHandlers(store)
registerNotificationHandlers(store)
registerNotificationHandlers(store, runtime)
registerDeveloperPermissionHandlers()
registerSettingsHandlers(store)
registerTelemetryHandlers()
+31
View File
@@ -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 }
}
})
}
+5 -3
View File
@@ -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()
+83
View File
@@ -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)
}
}
+50
View File
@@ -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')
})
})
})
+29 -5
View File
@@ -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
+47 -3
View File
@@ -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}`)
+96 -24
View File
@@ -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() }
}
+266
View File
@@ -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([])
})
})
})
+178
View File
@@ -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
}
}
+79
View File
@@ -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()
})
})
+41
View File
@@ -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`)
}
})
})
+7 -3
View File
@@ -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 }
}
})
]
+5
View File
@@ -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)
})
]
+11
View File
@@ -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() ?? {}
}
})
]
+200 -2
View File
@@ -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 }
}
})
]
+29 -9
View File
@@ -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
})
})
}),
+266
View File
@@ -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' }
})
})
})
+22
View File
@@ -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
})
}
}
+256
View File
@@ -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()
})
})
+193
View File
@@ -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'
}
+22 -16
View File
@@ -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
})
+85 -18
View File
@@ -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
View File
@@ -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()
}
+63
View File
@@ -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