mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 00:03:55 +00:00
feat(mobile): add Expo mobile app with QR pairing and WebSocket RPC transport
Adds the Orca Mobile companion app (Expo SDK 55 / React Native 0.83) with: - QR code pairing flow: scan orca://pair# URL, validate auth, save host profile - WebSocket RPC client with auto-reconnect, exponential backoff, streaming support - Three screens: host list, worktree list, terminal session with live output - Mock WebSocket server for development without a running Orca desktop instance - Desktop-side: TLS certificate management, device registry, mobile IPC handlers, WebSocket transport layer, and pairing offer encode/decode with Zod validation - Unit tests for WebSocket transport and pairing codec Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
android/
|
||||
ios/
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"rules": {}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# Orca Mobile
|
||||
|
||||
React Native companion app for Orca. Monitor worktrees, view terminal output, and send commands from your phone.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 24+
|
||||
- pnpm
|
||||
- Expo Go app on your phone (from App Store / Google Play), **or** a dev client build
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
pnpm install
|
||||
pnpm start # starts Expo dev server
|
||||
```
|
||||
|
||||
Scan the QR code shown in the terminal with your phone's camera (iOS) or the Expo Go app (Android).
|
||||
|
||||
## Development Paths
|
||||
|
||||
### I have an Android phone
|
||||
|
||||
1. Install Expo Go from Google Play
|
||||
2. Run `pnpm start`, scan QR with Expo Go
|
||||
3. For native modules (camera): `npx expo prebuild --platform android && cd android && ./gradlew assembleDebug`
|
||||
4. Install APK: `adb install android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
5. Run with `pnpm start --dev-client`
|
||||
|
||||
### I only have a Mac (iOS Simulator)
|
||||
|
||||
1. Install Xcode from the App Store
|
||||
2. Run `pnpm start --ios` to open in iOS Simulator
|
||||
|
||||
## 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/
|
||||
│ └── transport/ # WebSocket RPC client (TBD)
|
||||
├── scripts/
|
||||
│ └── mock-server.ts # Standalone mock WebSocket server
|
||||
└── assets/ # App icons and splash screen
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Orca Mobile",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"scheme": "orca",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.stably.orca.mobile"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#1a1a2e"
|
||||
},
|
||||
"package": "com.stably.orca.mobile"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow Orca to use the camera for QR code scanning."
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Stack } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#1a1a2e' },
|
||||
headerTintColor: '#e0e0e0',
|
||||
contentStyle: { backgroundColor: '#0d0d1a' }
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { View, Text, StyleSheet, FlatList, Pressable, ActivityIndicator } from 'react-native'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { connect, type RpcClient } from '../../../src/transport/rpc-client'
|
||||
import { loadHosts, updateLastConnected } from '../../../src/transport/host-store'
|
||||
import type { ConnectionState, RpcSuccess } from '../../../src/transport/types'
|
||||
|
||||
type Worktree = {
|
||||
worktreeId: string
|
||||
repo: string
|
||||
branch: string
|
||||
liveTerminalCount: number
|
||||
hasAttachedPty: boolean
|
||||
preview: string
|
||||
unread: boolean
|
||||
}
|
||||
|
||||
export default function HostScreen() {
|
||||
const { hostId } = useLocalSearchParams<{ hostId: string }>()
|
||||
const router = useRouter()
|
||||
const [client, setClient] = useState<RpcClient | null>(null)
|
||||
const [connState, setConnState] = useState<ConnectionState>('disconnected')
|
||||
const [worktrees, setWorktrees] = useState<Worktree[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let rpcClient: RpcClient | null = null
|
||||
|
||||
void (async () => {
|
||||
const hosts = await loadHosts()
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (!host) {
|
||||
setError('Host not found')
|
||||
return
|
||||
}
|
||||
|
||||
rpcClient = connect(host.endpoint, host.deviceToken, setConnState)
|
||||
setClient(rpcClient)
|
||||
|
||||
await updateLastConnected(host.id)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
rpcClient?.close()
|
||||
}
|
||||
}, [hostId])
|
||||
|
||||
const fetchWorktrees = useCallback(async () => {
|
||||
if (!client || connState !== 'connected') return
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('worktree.ps')
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { worktrees: Worktree[] }
|
||||
setWorktrees(result.worktrees)
|
||||
}
|
||||
} catch {
|
||||
// Will retry on reconnect
|
||||
}
|
||||
}, [client, connState])
|
||||
|
||||
useEffect(() => {
|
||||
if (connState === 'connected') {
|
||||
void fetchWorktrees()
|
||||
}
|
||||
}, [connState, fetchWorktrees])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.statusBar}>
|
||||
<View style={[styles.statusDot, connState === 'connected' && styles.statusConnected]} />
|
||||
<Text style={styles.statusText}>{connState}</Text>
|
||||
</View>
|
||||
|
||||
{connState === 'connecting' && (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color="#3b82f6" />
|
||||
<Text style={styles.connectingText}>Connecting...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{connState === 'connected' && worktrees.length === 0 && (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.emptyText}>No active worktrees</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{worktrees.length > 0 && (
|
||||
<FlatList
|
||||
data={worktrees}
|
||||
keyExtractor={(w) => w.worktreeId}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable
|
||||
style={styles.worktreeCard}
|
||||
onPress={() =>
|
||||
router.push(`/h/${hostId}/session/${encodeURIComponent(item.worktreeId)}`)
|
||||
}
|
||||
>
|
||||
<View style={styles.worktreeHeader}>
|
||||
<Text style={styles.worktreeRepo}>{item.repo}</Text>
|
||||
{item.unread && <View style={styles.unreadDot} />}
|
||||
</View>
|
||||
<Text style={styles.worktreeBranch}>{item.branch}</Text>
|
||||
{item.preview ? (
|
||||
<Text style={styles.worktreePreview} numberOfLines={2}>
|
||||
{item.preview}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={styles.worktreeMeta}>
|
||||
{item.liveTerminalCount} terminal{item.liveTerminalCount !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0d0d1a'
|
||||
},
|
||||
statusBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
paddingHorizontal: 16
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#666',
|
||||
marginRight: 8
|
||||
},
|
||||
statusConnected: {
|
||||
backgroundColor: '#22c55e'
|
||||
},
|
||||
statusText: {
|
||||
color: '#888',
|
||||
fontSize: 13
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
connectingText: {
|
||||
color: '#e0e0e0',
|
||||
fontSize: 16,
|
||||
marginTop: 16
|
||||
},
|
||||
emptyText: {
|
||||
color: '#888',
|
||||
fontSize: 16
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 16
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
paddingTop: 0
|
||||
},
|
||||
worktreeCard: {
|
||||
backgroundColor: '#1a1a2e',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12
|
||||
},
|
||||
worktreeHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4
|
||||
},
|
||||
worktreeRepo: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#e0e0e0'
|
||||
},
|
||||
unreadDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#3b82f6',
|
||||
marginLeft: 8
|
||||
},
|
||||
worktreeBranch: {
|
||||
fontSize: 14,
|
||||
color: '#3b82f6',
|
||||
fontFamily: 'monospace',
|
||||
marginBottom: 8
|
||||
},
|
||||
worktreePreview: {
|
||||
fontSize: 13,
|
||||
color: '#888',
|
||||
fontFamily: 'monospace',
|
||||
marginBottom: 8,
|
||||
lineHeight: 18
|
||||
},
|
||||
worktreeMeta: {
|
||||
fontSize: 12,
|
||||
color: '#666'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
Pressable,
|
||||
KeyboardAvoidingView,
|
||||
Platform
|
||||
} from 'react-native'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { connect, type RpcClient } from '../../../../src/transport/rpc-client'
|
||||
import { loadHosts } from '../../../../src/transport/host-store'
|
||||
import type { ConnectionState, RpcSuccess } from '../../../../src/transport/types'
|
||||
|
||||
type Terminal = {
|
||||
handle: string
|
||||
title: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export default function SessionScreen() {
|
||||
const { hostId, worktreeId } = useLocalSearchParams<{ hostId: string; worktreeId: string }>()
|
||||
const [client, setClient] = useState<RpcClient | null>(null)
|
||||
const [connState, setConnState] = useState<ConnectionState>('disconnected')
|
||||
const [terminals, setTerminals] = useState<Terminal[]>([])
|
||||
const [output, setOutput] = useState('')
|
||||
const [input, setInput] = useState('')
|
||||
const [activeHandle, setActiveHandle] = useState<string | null>(null)
|
||||
const scrollRef = useRef<ScrollView>(null)
|
||||
const unsubRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let rpcClient: RpcClient | null = null
|
||||
|
||||
void (async () => {
|
||||
const hosts = await loadHosts()
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (!host) return
|
||||
|
||||
rpcClient = connect(host.endpoint, host.deviceToken, setConnState)
|
||||
setClient(rpcClient)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
unsubRef.current?.()
|
||||
rpcClient?.close()
|
||||
}
|
||||
}, [hostId])
|
||||
|
||||
const fetchTerminals = useCallback(async () => {
|
||||
if (!client) return
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('terminal.list', {
|
||||
worktree: `id:${worktreeId}`
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
|
||||
setTerminals(result.terminals)
|
||||
|
||||
const active = result.terminals.find((t) => t.isActive) ?? result.terminals[0]
|
||||
if (active && active.handle !== activeHandle) {
|
||||
setActiveHandle(active.handle)
|
||||
subscribeToTerminal(active.handle)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Will retry on reconnect
|
||||
}
|
||||
}, [client, worktreeId, activeHandle])
|
||||
|
||||
useEffect(() => {
|
||||
if (connState === 'connected') {
|
||||
void fetchTerminals()
|
||||
}
|
||||
}, [connState, fetchTerminals])
|
||||
|
||||
function subscribeToTerminal(handle: string) {
|
||||
unsubRef.current?.()
|
||||
|
||||
if (!client) return
|
||||
|
||||
setOutput('')
|
||||
|
||||
const unsub = client.subscribe('terminal.subscribe', { terminal: handle }, (result) => {
|
||||
const data = result as Record<string, unknown>
|
||||
if (data.type === 'scrollback') {
|
||||
setOutput(data.lines as string)
|
||||
scrollToBottom()
|
||||
} else if (data.type === 'data') {
|
||||
setOutput((prev) => prev + (data.chunk as string))
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
|
||||
unsubRef.current = unsub
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
setTimeout(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: true })
|
||||
}, 50)
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!client || !activeHandle || !input.trim()) return
|
||||
|
||||
const text = input
|
||||
setInput('')
|
||||
|
||||
try {
|
||||
await client.sendRequest('terminal.send', {
|
||||
terminal: activeHandle,
|
||||
text,
|
||||
enter: true
|
||||
})
|
||||
} catch {
|
||||
// Failed to send
|
||||
}
|
||||
}
|
||||
|
||||
const activeTerminal = terminals.find((t) => t.handle === activeHandle)
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={90}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.statusDot, connState === 'connected' && styles.statusConnected]} />
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{activeTerminal?.title ?? 'Terminal'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{terminals.length > 1 && (
|
||||
<ScrollView horizontal style={styles.tabBar} showsHorizontalScrollIndicator={false}>
|
||||
{terminals.map((t) => (
|
||||
<Pressable
|
||||
key={t.handle}
|
||||
style={[styles.tab, t.handle === activeHandle && styles.tabActive]}
|
||||
onPress={() => {
|
||||
setActiveHandle(t.handle)
|
||||
subscribeToTerminal(t.handle)
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[styles.tabText, t.handle === activeHandle && styles.tabTextActive]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{t.title || 'Terminal'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.outputScroll}
|
||||
contentContainerStyle={styles.outputContent}
|
||||
>
|
||||
<Text style={styles.outputText} selectable>
|
||||
{output || 'Waiting for output...'}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.inputBar}>
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
placeholder="Type a command..."
|
||||
placeholderTextColor="#555"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={() => void handleSend()}
|
||||
/>
|
||||
<Pressable style={styles.sendButton} onPress={() => void handleSend()}>
|
||||
<Text style={styles.sendButtonText}>Send</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0d0d1a'
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1a1a2e'
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#666',
|
||||
marginRight: 8
|
||||
},
|
||||
statusConnected: {
|
||||
backgroundColor: '#22c55e'
|
||||
},
|
||||
headerTitle: {
|
||||
color: '#e0e0e0',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1
|
||||
},
|
||||
tabBar: {
|
||||
maxHeight: 44,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1a1a2e'
|
||||
},
|
||||
tab: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: 'transparent'
|
||||
},
|
||||
tabActive: {
|
||||
borderBottomColor: '#3b82f6'
|
||||
},
|
||||
tabText: {
|
||||
color: '#888',
|
||||
fontSize: 13
|
||||
},
|
||||
tabTextActive: {
|
||||
color: '#e0e0e0'
|
||||
},
|
||||
outputScroll: {
|
||||
flex: 1
|
||||
},
|
||||
outputContent: {
|
||||
padding: 12
|
||||
},
|
||||
outputText: {
|
||||
color: '#d4d4d4',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 18
|
||||
},
|
||||
inputBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#1a1a2e',
|
||||
backgroundColor: '#111127'
|
||||
},
|
||||
textInput: {
|
||||
flex: 1,
|
||||
backgroundColor: '#1a1a2e',
|
||||
color: '#e0e0e0',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 15,
|
||||
fontFamily: 'monospace',
|
||||
marginRight: 8
|
||||
},
|
||||
sendButton: {
|
||||
backgroundColor: '#3b82f6',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8
|
||||
},
|
||||
sendButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, FlatList } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import type { HostProfile } from '../src/transport/types'
|
||||
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter()
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void loadHosts().then(setHosts)
|
||||
}, [])
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{hosts.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.title}>Orca Mobile</Text>
|
||||
<Text style={styles.subtitle}>No hosts paired yet</Text>
|
||||
<Pressable style={styles.button} onPress={() => router.push('/pair-scan')}>
|
||||
<Text style={styles.buttonText}>+ Add Host</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<FlatList
|
||||
data={hosts}
|
||||
keyExtractor={(h) => h.id}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.hostCard} onPress={() => router.push(`/h/${item.id}`)}>
|
||||
<Text style={styles.hostName}>{item.name}</Text>
|
||||
<Text style={styles.hostEndpoint}>{item.endpoint}</Text>
|
||||
{item.lastConnected > 0 && (
|
||||
<Text style={styles.hostMeta}>
|
||||
Last connected {new Date(item.lastConnected).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
<Pressable style={styles.addButton} onPress={() => router.push('/pair-scan')}>
|
||||
<Text style={styles.buttonText}>+ Add Host</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0d0d1a'
|
||||
},
|
||||
empty: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: '#e0e0e0',
|
||||
marginBottom: 8
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#888',
|
||||
marginBottom: 32
|
||||
},
|
||||
list: {
|
||||
padding: 16
|
||||
},
|
||||
hostCard: {
|
||||
backgroundColor: '#1a1a2e',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12
|
||||
},
|
||||
hostName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#e0e0e0',
|
||||
marginBottom: 4
|
||||
},
|
||||
hostEndpoint: {
|
||||
fontSize: 13,
|
||||
color: '#888',
|
||||
fontFamily: 'monospace'
|
||||
},
|
||||
hostMeta: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginTop: 8
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#3b82f6',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8
|
||||
},
|
||||
addButton: {
|
||||
backgroundColor: '#3b82f6',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
margin: 16,
|
||||
alignItems: 'center'
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { decodePairingUrl } from '../src/transport/pairing'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import { saveHost } from '../src/transport/host-store'
|
||||
import type { PairingOffer, RpcSuccess } from '../src/transport/types'
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const router = useRouter()
|
||||
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')
|
||||
|
||||
const client = connect(offer.endpoint, offer.deviceToken)
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('status.get')
|
||||
client.close()
|
||||
|
||||
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 runtimeId = (response as RpcSuccess)._meta.runtimeId
|
||||
const hostId = `host-${Date.now()}`
|
||||
|
||||
await saveHost({
|
||||
id: hostId,
|
||||
name: runtimeId,
|
||||
endpoint: offer.endpoint,
|
||||
deviceToken: offer.deviceToken,
|
||||
certFingerprint: offer.certFingerprint,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
setStatus('scanning')
|
||||
setErrorMessage('')
|
||||
processingRef.current = false
|
||||
}
|
||||
|
||||
if (!permission) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator color="#3b82f6" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<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.button} onPress={requestPermission}>
|
||||
<Text style={styles.buttonText}>Grant Camera Access</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Scan QR Code</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Open Orca on your computer, go to Settings → Mobile, and scan the QR code shown there.
|
||||
</Text>
|
||||
|
||||
{status === 'scanning' && (
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={handleBarCodeScanned}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === 'connecting' && (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color="#3b82f6" />
|
||||
<Text style={styles.connectingText}>Connecting...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||
<Pressable style={styles.button} onPress={retry}>
|
||||
<Text style={styles.buttonText}>Try Again</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0d0d1a',
|
||||
padding: 24
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#e0e0e0',
|
||||
marginBottom: 8
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#888',
|
||||
marginBottom: 24,
|
||||
lineHeight: 20
|
||||
},
|
||||
camera: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
connectingText: {
|
||||
color: '#e0e0e0',
|
||||
fontSize: 16,
|
||||
marginTop: 16
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
lineHeight: 22
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#3b82f6',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"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-camera": "^55.0.16",
|
||||
"expo-constants": "^55.0.15",
|
||||
"expo-linking": "^55.0.14",
|
||||
"expo-router": "^55.0.13",
|
||||
"expo-status-bar": "^55.0.5",
|
||||
"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-web": "^0.21.2",
|
||||
"react-native-worklets": "^0.7.4",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-native": "^0.73.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"expo-module-scripts": "^55.0.2",
|
||||
"oxfmt": "^0.47.0",
|
||||
"oxlint": "^1.62.0",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
Generated
+12340
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
#!/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.
|
||||
import { WebSocketServer, type WebSocket } from 'ws'
|
||||
|
||||
const PORT = Number(process.env.PORT) || 6768
|
||||
const AUTH_TOKEN = 'mock-device-token'
|
||||
|
||||
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, ws: WebSocket): void {
|
||||
if (request.deviceToken !== AUTH_TOKEN) {
|
||||
ws.send(JSON.stringify(error(request.id, 'unauthorized', 'Invalid device token')))
|
||||
return
|
||||
}
|
||||
|
||||
switch (request.method) {
|
||||
case 'status.get':
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
success(request.id, {
|
||||
runtimeId: 'mock-runtime',
|
||||
graphStatus: 'ready',
|
||||
windowCount: 1,
|
||||
tabCount: 2,
|
||||
terminalCount: 2
|
||||
})
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
case 'worktree.ps':
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
success(request.id, {
|
||||
worktrees: FAKE_WORKTREES,
|
||||
totalCount: FAKE_WORKTREES.length,
|
||||
truncated: false
|
||||
})
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
case 'terminal.list':
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
success(request.id, {
|
||||
terminals: FAKE_TERMINALS,
|
||||
totalCount: FAKE_TERMINALS.length,
|
||||
truncated: false
|
||||
})
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
case 'terminal.subscribe': {
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
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) {
|
||||
ws.send(JSON.stringify(success(request.id, { type: 'end' })))
|
||||
}
|
||||
return
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true)
|
||||
)
|
||||
)
|
||||
chunkIndex++
|
||||
}, 500)
|
||||
break
|
||||
}
|
||||
|
||||
case 'terminal.send':
|
||||
ws.send(JSON.stringify(success(request.id, { send: { handle: 'term-1', ok: true } })))
|
||||
break
|
||||
|
||||
case 'terminal.unsubscribe':
|
||||
ws.send(JSON.stringify(success(request.id, { unsubscribed: true })))
|
||||
break
|
||||
|
||||
default:
|
||||
ws.send(
|
||||
JSON.stringify(error(request.id, 'method_not_found', `Unknown method: ${request.method}`))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({ port: PORT })
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('[mock] Client connected')
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const msg = typeof data === 'string' ? data : data.toString('utf-8')
|
||||
let request: RpcRequest
|
||||
try {
|
||||
request = JSON.parse(msg) as RpcRequest
|
||||
} catch {
|
||||
ws.send(JSON.stringify(error('unknown', 'bad_request', 'Invalid JSON')))
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[mock] ${request.method} (id: ${request.id})`)
|
||||
handleRequest(request, ws)
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[mock] Client disconnected')
|
||||
})
|
||||
|
||||
ws.on('error', () => {
|
||||
ws.close()
|
||||
})
|
||||
})
|
||||
|
||||
console.log(`[mock] Orca mock server listening on ws://localhost:${PORT}`)
|
||||
console.log(`[mock] Auth token: ${AUTH_TOKEN}`)
|
||||
console.log(`[mock] Try: npx wscat -c ws://localhost:${PORT}`)
|
||||
console.log(`[mock] Then send: {"id":"1","deviceToken":"${AUTH_TOKEN}","method":"status.get"}`)
|
||||
@@ -0,0 +1,40 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import 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 {
|
||||
return JSON.parse(raw) as HostProfile[]
|
||||
} 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 updateLastConnected(hostId: string): Promise<void> {
|
||||
const hosts = await loadHosts()
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (host) {
|
||||
host.lastConnected = Date.now()
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { connect, type RpcClient } from './rpc-client'
|
||||
export { loadHosts, saveHost, removeHost, updateLastConnected } from './host-store'
|
||||
export type {
|
||||
RpcRequest,
|
||||
RpcResponse,
|
||||
RpcSuccess,
|
||||
RpcFailure,
|
||||
ConnectionState,
|
||||
HostProfile,
|
||||
PairingOffer
|
||||
} from './types'
|
||||
export { PairingOfferV1, PAIRING_OFFER_VERSION } from './types'
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PairingOfferV1, 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 PairingOfferV1.parse(parsed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { RpcRequest, RpcResponse, RpcSuccess, ConnectionState } from './types'
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (response: RpcResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
type StreamingListener = (result: unknown) => void
|
||||
|
||||
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
|
||||
|
||||
export function connect(
|
||||
endpoint: string,
|
||||
deviceToken: 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 intentionallyClosed = false
|
||||
|
||||
const pending = new Map<string, PendingRequest>()
|
||||
const streamListeners = new Map<string, StreamingListener>()
|
||||
const stateListeners = new Set<(state: ConnectionState) => void>()
|
||||
// Callers that are waiting for the socket to reach 'connected' state
|
||||
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') {
|
||||
for (const w of connectWaiters.splice(0)) w.reject(new Error('Connection closed'))
|
||||
}
|
||||
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')
|
||||
|
||||
ws = new WebSocket(endpoint)
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempt = 0
|
||||
setState('connected')
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let response: RpcResponse
|
||||
try {
|
||||
response = JSON.parse(typeof event.data === 'string' ? event.data : String(event.data))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const isStreaming = response.ok && (response as RpcSuccess).streaming === true
|
||||
|
||||
if (isStreaming) {
|
||||
const listener = streamListeners.get(response.id)
|
||||
if (listener && response.ok) {
|
||||
listener((response as RpcSuccess).result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Non-streaming: check if it's a final streaming message (type: 'end')
|
||||
// or a one-shot response
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as Record<string, unknown> | null
|
||||
if (result && result.type === 'end') {
|
||||
const listener = streamListeners.get(response.id)
|
||||
if (listener) {
|
||||
listener(result)
|
||||
streamListeners.delete(response.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Scrollback (first message from terminal.subscribe) also goes to stream listener
|
||||
if (result && result.type === 'scrollback') {
|
||||
const listener = streamListeners.get(response.id)
|
||||
if (listener) {
|
||||
listener(result)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const req = pending.get(response.id)
|
||||
if (req) {
|
||||
pending.delete(response.id)
|
||||
req.resolve(response)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
ws = null
|
||||
if (intentionallyClosed) {
|
||||
setState('disconnected')
|
||||
rejectAllPending('Connection closed')
|
||||
return
|
||||
}
|
||||
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) {
|
||||
for (const [id, req] of pending) {
|
||||
req.reject(new Error(reason))
|
||||
pending.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
function sendRaw(request: RpcRequest) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(request))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
sendRaw({ id, deviceToken, method, params })
|
||||
})
|
||||
},
|
||||
|
||||
subscribe(method: string, params: unknown, onData: StreamingListener): () => void {
|
||||
const id = nextId()
|
||||
streamListeners.set(id, onData)
|
||||
sendRaw({ id, deviceToken, method, params })
|
||||
|
||||
return () => {
|
||||
streamListeners.delete(id)
|
||||
sendRaw({
|
||||
id: nextId(),
|
||||
deviceToken,
|
||||
method: 'terminal.unsubscribe',
|
||||
params: { subscriptionId: id }
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
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 (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
setState('disconnected')
|
||||
rejectAllPending('Client closed')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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 = 1
|
||||
|
||||
export const PairingOfferV1 = z.object({
|
||||
v: z.literal(PAIRING_OFFER_VERSION),
|
||||
endpoint: z.string().min(1),
|
||||
deviceToken: z.string().min(1),
|
||||
certFingerprint: z.string().startsWith('sha256:')
|
||||
})
|
||||
|
||||
export type PairingOffer = z.infer<typeof PairingOfferV1>
|
||||
|
||||
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
|
||||
|
||||
export type HostProfile = {
|
||||
id: string
|
||||
name: string
|
||||
endpoint: string
|
||||
deviceToken: string
|
||||
certFingerprint: string
|
||||
lastConnected: number
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
@@ -87,6 +87,7 @@
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
@@ -115,6 +116,7 @@
|
||||
"@stablyai/playwright-test": "^2.1.13",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
|
||||
Generated
+148
@@ -160,6 +160,9 @@ importers:
|
||||
pdfjs-dist:
|
||||
specifier: ^5.6.205
|
||||
version: 5.6.205
|
||||
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)
|
||||
@@ -239,6 +242,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
|
||||
@@ -2819,6 +2825,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:
|
||||
@@ -3217,6 +3226,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==}
|
||||
|
||||
@@ -3298,6 +3311,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'}
|
||||
@@ -3612,6 +3628,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==}
|
||||
|
||||
@@ -3690,6 +3710,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==}
|
||||
|
||||
@@ -3981,6 +4004,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'}
|
||||
@@ -4607,6 +4634,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==}
|
||||
|
||||
@@ -5117,10 +5148,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'}
|
||||
@@ -5129,6 +5168,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==}
|
||||
|
||||
@@ -5163,6 +5206,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'}
|
||||
@@ -5231,6 +5278,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'}
|
||||
@@ -5331,6 +5382,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'}
|
||||
@@ -5477,6 +5533,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'}
|
||||
@@ -5608,6 +5667,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==}
|
||||
|
||||
@@ -6176,6 +6238,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'}
|
||||
@@ -6235,6 +6300,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'}
|
||||
@@ -6254,10 +6322,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'}
|
||||
@@ -8667,6 +8743,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
|
||||
@@ -9122,6 +9202,8 @@ snapshots:
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001780: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
@@ -9191,6 +9273,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
|
||||
@@ -9505,6 +9593,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
@@ -9569,6 +9659,8 @@ snapshots:
|
||||
|
||||
diff@8.0.3: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
dir-compare@4.2.0:
|
||||
dependencies:
|
||||
minimatch: 3.1.5
|
||||
@@ -10004,6 +10096,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
|
||||
@@ -10635,6 +10732,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: {}
|
||||
@@ -11452,10 +11553,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:
|
||||
@@ -11463,6 +11572,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: {}
|
||||
@@ -11500,6 +11611,8 @@ snapshots:
|
||||
|
||||
path-data-parser@0.1.0: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
@@ -11554,6 +11667,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: {}
|
||||
@@ -11688,6 +11803,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
|
||||
@@ -11952,6 +12073,8 @@ snapshots:
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
resedit@1.7.2:
|
||||
dependencies:
|
||||
pe-library: 0.4.1
|
||||
@@ -12115,6 +12238,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):
|
||||
@@ -12685,6 +12810,8 @@ snapshots:
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -12737,6 +12864,8 @@ snapshots:
|
||||
|
||||
xmlbuilder@15.1.1: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
@@ -12747,8 +12876,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
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { readFileSync } from 'fs'
|
||||
import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
import {
|
||||
findTransport,
|
||||
getRuntimeMetadataPath,
|
||||
type RuntimeMetadata
|
||||
} from '../../shared/runtime-bootstrap'
|
||||
import { RuntimeClientError } from './types'
|
||||
|
||||
export function readMetadata(userDataPath: string): RuntimeMetadata {
|
||||
const metadataPath = getRuntimeMetadataPath(userDataPath)
|
||||
try {
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null
|
||||
if (!metadata?.transport || !metadata.authToken) {
|
||||
if (!metadata || !findTransport(metadata, 'unix', 'named-pipe') || !metadata.authToken) {
|
||||
throw new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
`Orca runtime metadata is incomplete at ${metadataPath}`
|
||||
|
||||
@@ -7,7 +7,7 @@ export async function getCliStatus(
|
||||
userDataPath: string
|
||||
): Promise<RuntimeRpcSuccess<CliStatusResult>> {
|
||||
const metadata = tryReadMetadata(userDataPath)
|
||||
if (!metadata?.transport || !metadata.authToken) {
|
||||
if (!metadata?.transports?.length || !metadata.authToken) {
|
||||
return buildCliStatusResponse({
|
||||
app: {
|
||||
running: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createConnection } from 'net'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap'
|
||||
import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
import { 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 = ''
|
||||
const requestId = randomUUID()
|
||||
|
||||
@@ -104,7 +114,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'
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { recordPendingDaemonTransitionNotice, setAppRuntimeFlags } from './ipc/app'
|
||||
import { closeAllWatchers } from './ipc/filesystem-watcher'
|
||||
import { registerCoreHandlers } from './ipc/register-core-handlers'
|
||||
import { registerMobileHandlers } from './ipc/mobile'
|
||||
import { triggerStartupNotificationRegistration } from './ipc/notifications'
|
||||
import { OrcaRuntimeService } from './runtime/orca-runtime'
|
||||
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
|
||||
@@ -389,6 +390,7 @@ app.whenReady().then(async () => {
|
||||
runtime,
|
||||
userDataPath: app.getPath('userData')
|
||||
})
|
||||
registerMobileHandlers(runtimeRpc)
|
||||
|
||||
// Why: persistent terminal sessions (the out-of-process daemon) are gated
|
||||
// behind an experimental setting that defaults to OFF. Users on v1.3.0 had
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import QRCode from 'qrcode'
|
||||
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
|
||||
|
||||
// Why: the mobile IPC handlers provide the renderer with QR code pairing data,
|
||||
// device management, and WebSocket readiness status. They depend on the
|
||||
// OrcaRuntimeRpcServer because it owns the device registry and TLS state.
|
||||
|
||||
export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
|
||||
ipcMain.handle('mobile:getPairingQR', async () => {
|
||||
const endpoint = rpcServer.getWebSocketEndpoint()
|
||||
const fingerprint = rpcServer.getTlsFingerprint()
|
||||
const registry = rpcServer.getDeviceRegistry()
|
||||
if (!endpoint || !fingerprint || !registry) {
|
||||
return { available: false as const }
|
||||
}
|
||||
|
||||
const device = registry.addDevice(`Mobile ${new Date().toLocaleDateString()}`)
|
||||
|
||||
const url = encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint,
|
||||
deviceToken: device.token,
|
||||
certFingerprint: fingerprint
|
||||
})
|
||||
|
||||
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: [] }
|
||||
}
|
||||
return {
|
||||
devices: registry.listDevices().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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,11 @@ export class OrcaRuntimeService {
|
||||
private agentBrowserBridge: AgentBrowserBridge | null = null
|
||||
private resolvedWorktreeCache: ResolvedWorktreeCache | null = null
|
||||
private agentDetector: AgentDetector | null = null
|
||||
// Why: mobile clients subscribe to terminal output via terminal.subscribe.
|
||||
// These listeners fire on every onPtyData call, enabling real-time streaming
|
||||
// without polling. Keyed by ptyId for O(1) lookup per data event.
|
||||
private dataListeners = new Map<string, Set<(data: string) => void>>()
|
||||
private subscriptionCleanups = new Map<string, () => void>()
|
||||
|
||||
constructor(store: RuntimeStore | null = null, stats?: StatsCollector) {
|
||||
this.store = store
|
||||
@@ -378,6 +383,52 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners = this.dataListeners.get(ptyId)
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToTerminalData(ptyId: string, listener: (data: string) => void): () => void {
|
||||
let listeners = this.dataListeners.get(ptyId)
|
||||
if (!listeners) {
|
||||
listeners = new Set()
|
||||
this.dataListeners.set(ptyId, listeners)
|
||||
}
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (listeners.size === 0) {
|
||||
this.dataListeners.delete(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolveLeafForHandle(handle: string): { ptyId: string | null } | null {
|
||||
const record = this.handles.get(handle)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId))
|
||||
if (!leaf) {
|
||||
return null
|
||||
}
|
||||
return { ptyId: leaf.ptyId }
|
||||
}
|
||||
|
||||
registerSubscriptionCleanup(subscriptionId: string, cleanup: () => void): void {
|
||||
this.subscriptionCleanups.set(subscriptionId, cleanup)
|
||||
}
|
||||
|
||||
cleanupSubscription(subscriptionId: string): void {
|
||||
const cleanup = this.subscriptionCleanups.get(subscriptionId)
|
||||
if (cleanup) {
|
||||
this.subscriptionCleanups.delete(subscriptionId)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
onPtyExit(ptyId: string, exitCode: number): void {
|
||||
|
||||
@@ -13,6 +13,7 @@ export type RpcSuccess = {
|
||||
id: string
|
||||
ok: true
|
||||
result: unknown
|
||||
streaming?: true
|
||||
_meta: RpcEnvelopeMeta
|
||||
}
|
||||
|
||||
@@ -69,10 +70,53 @@ export function defineMethod<TSchema extends ZodType | null>(
|
||||
}
|
||||
}
|
||||
|
||||
export type RpcRegistry = ReadonlyMap<string, RpcMethod>
|
||||
export type RpcStreamingHandler<TParams> = (
|
||||
params: TParams,
|
||||
ctx: RpcContext,
|
||||
emit: (result: unknown) => void
|
||||
) => Promise<void>
|
||||
|
||||
export function buildRegistry(methods: readonly RpcMethod[]): RpcRegistry {
|
||||
const registry = new Map<string, RpcMethod>()
|
||||
// Why: streaming methods emit multiple responses over a long-lived connection.
|
||||
// The `stream` flag lets the dispatcher distinguish them from one-shot methods
|
||||
// and route them to the emit-based call path instead of the Promise-based one.
|
||||
export type RpcStreamingMethod = {
|
||||
readonly name: string
|
||||
readonly params: ZodType | null
|
||||
readonly stream: true
|
||||
readonly handler: (
|
||||
params: unknown,
|
||||
ctx: RpcContext,
|
||||
emit: (result: unknown) => void
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
type DefineStreamingMethodSpec<TSchema extends ZodType | null> = {
|
||||
name: string
|
||||
params: TSchema
|
||||
handler: RpcStreamingHandler<TSchema extends ZodType ? TSchema['_output'] : void>
|
||||
}
|
||||
|
||||
export function defineStreamingMethod<TSchema extends ZodType | null>(
|
||||
spec: DefineStreamingMethodSpec<TSchema>
|
||||
): RpcStreamingMethod {
|
||||
return {
|
||||
name: spec.name,
|
||||
params: spec.params,
|
||||
stream: true,
|
||||
handler: spec.handler as RpcStreamingMethod['handler']
|
||||
}
|
||||
}
|
||||
|
||||
export type RpcAnyMethod = RpcMethod | RpcStreamingMethod
|
||||
|
||||
export function isStreamingMethod(method: RpcAnyMethod): method is RpcStreamingMethod {
|
||||
return 'stream' in method && method.stream === true
|
||||
}
|
||||
|
||||
export type RpcRegistry = ReadonlyMap<string, RpcAnyMethod>
|
||||
|
||||
export function buildRegistry(methods: readonly RpcAnyMethod[]): RpcRegistry {
|
||||
const registry = new Map<string, RpcAnyMethod>()
|
||||
for (const method of methods) {
|
||||
if (registry.has(method.name)) {
|
||||
throw new Error(`duplicate_rpc_method:${method.name}`)
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
ZodError,
|
||||
buildRegistry,
|
||||
formatZodError,
|
||||
isStreamingMethod,
|
||||
type RpcAnyMethod,
|
||||
type RpcEnvelopeMeta,
|
||||
type RpcMethod,
|
||||
type RpcRegistry,
|
||||
type RpcRequest,
|
||||
type RpcResponse
|
||||
@@ -18,7 +19,7 @@ import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
|
||||
export type DispatcherOptions = {
|
||||
runtime: OrcaRuntimeService
|
||||
methods?: readonly RpcMethod[]
|
||||
methods?: readonly RpcAnyMethod[]
|
||||
}
|
||||
|
||||
export class RpcDispatcher {
|
||||
@@ -42,36 +43,107 @@ 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, { runtime: this.runtime })
|
||||
const result = await method.handler(parsedParams.value, { runtime: this.runtime })
|
||||
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() }
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -9,7 +9,7 @@ import { BROWSER_EXTRA_METHODS } from './browser-extras'
|
||||
// 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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 +77,13 @@ const TerminalStop = z.object({
|
||||
worktree: requiredString('Missing worktree selector')
|
||||
})
|
||||
|
||||
export const TERMINAL_METHODS: RpcMethod[] = [
|
||||
const TerminalSubscribe = TerminalHandle.extend({})
|
||||
|
||||
const TerminalUnsubscribe = z.object({
|
||||
subscriptionId: requiredString('Missing subscription ID')
|
||||
})
|
||||
|
||||
export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'terminal.list',
|
||||
params: TerminalListParams,
|
||||
@@ -170,5 +176,49 @@ export const TERMINAL_METHODS: RpcMethod[] = [
|
||||
handler: async (params, { runtime }) => ({
|
||||
close: await runtime.closeTerminal(params.terminal)
|
||||
})
|
||||
}),
|
||||
// Why: terminal.subscribe streams live terminal output over WebSocket.
|
||||
// It sends initial scrollback, then live data chunks as they arrive.
|
||||
// Only works over streaming-capable transports (WebSocket, not Unix socket).
|
||||
defineStreamingMethod({
|
||||
name: 'terminal.subscribe',
|
||||
params: TerminalSubscribe,
|
||||
handler: async (params, { runtime }, emit) => {
|
||||
const read = await runtime.readTerminal(params.terminal)
|
||||
emit({ type: 'scrollback', lines: read.tail, truncated: read.truncated })
|
||||
|
||||
const leaf = runtime.resolveLeafForHandle(params.terminal)
|
||||
if (!leaf?.ptyId) {
|
||||
emit({ type: 'end' })
|
||||
return
|
||||
}
|
||||
|
||||
// Why: the handler returns a Promise that never resolves (until
|
||||
// unsubscribe or disconnect). The emit callback pushes data chunks
|
||||
// as they arrive. Cleanup happens via the unsubscribe mechanism or
|
||||
// connection-scoped cleanup in the transport layer.
|
||||
await new Promise<void>((resolve) => {
|
||||
const unsubscribe = runtime.subscribeToTerminalData(leaf.ptyId!, (data) => {
|
||||
emit({ type: 'data', chunk: data })
|
||||
})
|
||||
|
||||
// Why: store the cleanup function so terminal.unsubscribe and
|
||||
// connection-close cleanup can call it.
|
||||
const subscriptionId = params.terminal
|
||||
runtime.registerSubscriptionCleanup(subscriptionId, () => {
|
||||
unsubscribe()
|
||||
emit({ type: 'end' })
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.unsubscribe',
|
||||
params: TerminalUnsubscribe,
|
||||
handler: async (params, { runtime }) => {
|
||||
runtime.cleanupSubscription(params.subscriptionId)
|
||||
return { unsubscribed: true }
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// 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.
|
||||
export type RpcTransport = {
|
||||
start(): Promise<void>
|
||||
stop(): Promise<void>
|
||||
onMessage(handler: (msg: string, reply: (response: string) => void) => void): void
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// 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.
|
||||
import { createServer, type Server, type Socket } from 'net'
|
||||
import { chmodSync, existsSync, rmSync } from 'fs'
|
||||
import type { 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
|
||||
|
||||
export type UnixSocketTransportOptions = {
|
||||
endpoint: string
|
||||
kind: 'unix' | 'named-pipe'
|
||||
}
|
||||
|
||||
export class UnixSocketTransport implements RpcTransport {
|
||||
private readonly endpoint: string
|
||||
private readonly kind: 'unix' | 'named-pipe'
|
||||
private server: Server | null = null
|
||||
private messageHandler: ((msg: string, reply: (response: string) => void) => void) | null = null
|
||||
|
||||
constructor({ endpoint, kind }: UnixSocketTransportOptions) {
|
||||
this.endpoint = endpoint
|
||||
this.kind = kind
|
||||
}
|
||||
|
||||
onMessage(handler: (msg: string, reply: (response: string) => void) => void): 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
|
||||
|
||||
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) => {
|
||||
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.messageHandler?.(rawMessage, (response) => {
|
||||
socket.write(`${response}\n`)
|
||||
})
|
||||
}
|
||||
newlineIndex = buffer.indexOf('\n')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
// Why: the WebSocket transport enables mobile clients to connect to the Orca
|
||||
// runtime over the local network. It uses wss:// with a self-signed TLS
|
||||
// certificate to prevent passive sniffing, and per-device tokens (validated
|
||||
// by the message handler in OrcaRuntimeRpcServer) instead of the shared
|
||||
// runtime auth token.
|
||||
import { createServer as createHttpsServer, type Server as HttpsServer } from 'https'
|
||||
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
|
||||
private readonly tlsKey: string
|
||||
private httpsServer: HttpsServer | null = null
|
||||
private wss: WebSocketServer | null = null
|
||||
private messageHandler: ((msg: string, reply: (response: string) => void) => void) | null = null
|
||||
|
||||
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) => void): void {
|
||||
this.messageHandler = handler
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.wss) {
|
||||
return
|
||||
}
|
||||
|
||||
const httpsServer = createHttpsServer({
|
||||
cert: this.tlsCert,
|
||||
key: this.tlsKey
|
||||
})
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
server: httpsServer,
|
||||
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)
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpsServer.once('error', reject)
|
||||
httpsServer.listen(this.port, this.host, () => {
|
||||
httpsServer.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
this.httpsServer = httpsServer
|
||||
this.wss = wss
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const wss = this.wss
|
||||
const httpsServer = this.httpsServer
|
||||
this.wss = null
|
||||
this.httpsServer = null
|
||||
|
||||
if (wss) {
|
||||
for (const client of wss.clients) {
|
||||
client.close(1001, 'Server shutting down')
|
||||
}
|
||||
wss.close()
|
||||
}
|
||||
|
||||
if (httpsServer) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpsServer.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.on('error', () => {
|
||||
ws.close()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,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
|
||||
})
|
||||
@@ -32,10 +34,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
|
||||
})
|
||||
@@ -48,7 +52,7 @@ describe('runtime metadata', () => {
|
||||
writeRuntimeMetadata(userDataPath, {
|
||||
runtimeId: 'rt_123',
|
||||
pid: 42,
|
||||
transport: null,
|
||||
transports: [],
|
||||
authToken: null,
|
||||
startedAt: 100
|
||||
})
|
||||
@@ -68,10 +72,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
|
||||
})
|
||||
|
||||
@@ -109,8 +109,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({
|
||||
@@ -157,8 +157,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()
|
||||
})
|
||||
@@ -171,7 +171,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'
|
||||
@@ -197,7 +197,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'
|
||||
@@ -222,7 +222,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'
|
||||
})
|
||||
@@ -277,7 +277,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',
|
||||
@@ -301,7 +301,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',
|
||||
@@ -314,7 +314,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',
|
||||
@@ -327,7 +327,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',
|
||||
@@ -343,7 +343,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',
|
||||
@@ -403,7 +403,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'
|
||||
@@ -444,7 +444,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',
|
||||
@@ -474,7 +474,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)
|
||||
|
||||
+150
-101
@@ -1,11 +1,9 @@
|
||||
// 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, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
@@ -13,122 +11,162 @@ import { writeRuntimeMetadata } from './runtime-metadata'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import type { RpcRequest, RpcResponse } from './rpc/core'
|
||||
import { errorResponse } from './rpc/errors'
|
||||
import type { RpcTransport } from './rpc/transport'
|
||||
import { UnixSocketTransport } from './rpc/unix-socket-transport'
|
||||
import { WebSocketTransport } from './rpc/ws-transport'
|
||||
import { loadOrCreateTlsCertificate } from './tls-certificate'
|
||||
import { DeviceRegistry } from './device-registry'
|
||||
|
||||
const DEFAULT_WS_PORT = 6768
|
||||
|
||||
type OrcaRuntimeRpcServerOptions = {
|
||||
runtime: OrcaRuntimeService
|
||||
userDataPath: string
|
||||
pid?: number
|
||||
platform?: NodeJS.Platform
|
||||
enableWebSocket?: boolean
|
||||
wsPort?: number
|
||||
}
|
||||
|
||||
const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024
|
||||
const RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS = 30_000
|
||||
const MAX_RUNTIME_RPC_CONNECTIONS = 32
|
||||
|
||||
export class OrcaRuntimeRpcServer {
|
||||
private readonly runtime: OrcaRuntimeService
|
||||
private readonly dispatcher: RpcDispatcher
|
||||
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 server: Server | null = null
|
||||
private transport: RuntimeTransportMetadata | null = null
|
||||
private deviceRegistry: DeviceRegistry | null = null
|
||||
private tlsFingerprint: string | null = null
|
||||
private activeTransports: RpcTransport[] = []
|
||||
private transports: RuntimeTransportMetadata[] = []
|
||||
|
||||
constructor({
|
||||
runtime,
|
||||
userDataPath,
|
||||
pid = process.pid,
|
||||
platform = process.platform
|
||||
platform = process.platform,
|
||||
enableWebSocket = false,
|
||||
wsPort = DEFAULT_WS_PORT
|
||||
}: OrcaRuntimeRpcServerOptions) {
|
||||
this.runtime = runtime
|
||||
this.dispatcher = new RpcDispatcher({ runtime })
|
||||
this.userDataPath = userDataPath
|
||||
this.pid = pid
|
||||
this.platform = platform
|
||||
this.enableWebSocket = enableWebSocket
|
||||
this.wsPort = wsPort
|
||||
}
|
||||
|
||||
getDeviceRegistry(): DeviceRegistry | null {
|
||||
return this.deviceRegistry
|
||||
}
|
||||
|
||||
getTlsFingerprint(): string | null {
|
||||
return this.tlsFingerprint
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
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.
|
||||
socketTransport.onMessage((msg, reply) => {
|
||||
void this.handleMessage(msg).then((response) => {
|
||||
reply(JSON.stringify(response))
|
||||
})
|
||||
})
|
||||
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 TLS with a self-signed cert and per-device tokens from the
|
||||
// device registry, not the shared runtime auth token.
|
||||
if (this.enableWebSocket) {
|
||||
try {
|
||||
const tls = loadOrCreateTlsCertificate(this.userDataPath)
|
||||
this.tlsFingerprint = tls.fingerprint
|
||||
this.deviceRegistry = new DeviceRegistry(this.userDataPath)
|
||||
|
||||
const wsTransport = new WebSocketTransport({
|
||||
host: '0.0.0.0',
|
||||
port: this.wsPort,
|
||||
tlsCert: tls.cert,
|
||||
tlsKey: tls.key
|
||||
})
|
||||
|
||||
// Why: WebSocket transport uses dispatchStreaming which can emit
|
||||
// multiple responses for subscription methods. Auth validation
|
||||
// happens before dispatch.
|
||||
wsTransport.onMessage((msg, reply) => {
|
||||
void this.handleWebSocketMessage(msg, reply)
|
||||
})
|
||||
|
||||
await wsTransport.start()
|
||||
activeTransports.push(wsTransport)
|
||||
transportsMeta.push({
|
||||
kind: 'websocket',
|
||||
endpoint: `wss://0.0.0.0:${this.wsPort}`
|
||||
})
|
||||
} 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
|
||||
@@ -136,44 +174,16 @@ export class OrcaRuntimeRpcServer {
|
||||
// bootstrap file.
|
||||
}
|
||||
|
||||
private handleConnection(socket: Socket): void {
|
||||
let buffer = ''
|
||||
|
||||
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.handleMessage(rawMessage).then((response) => {
|
||||
socket.write(`${JSON.stringify(response)}\n`)
|
||||
})
|
||||
}
|
||||
newlineIndex = buffer.indexOf('\n')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: Unix socket messages use one-shot dispatch (single response per
|
||||
// request) and the shared runtime auth token from the 0o600 metadata file.
|
||||
private async handleMessage(rawMessage: string): 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')
|
||||
}
|
||||
|
||||
let request: RpcRequest
|
||||
try {
|
||||
request = JSON.parse(rawMessage) as RpcRequest
|
||||
@@ -197,6 +207,45 @@ export class OrcaRuntimeRpcServer {
|
||||
return this.dispatcher.dispatch(request)
|
||||
}
|
||||
|
||||
// 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
|
||||
): 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
await this.dispatcher.dispatchStreaming(request, reply)
|
||||
}
|
||||
|
||||
private buildError(id: string, code: string, message: string): RpcResponse {
|
||||
return errorResponse(id, { runtimeId: this.runtime.getRuntimeId() }, code, message)
|
||||
}
|
||||
@@ -205,7 +254,7 @@ export class OrcaRuntimeRpcServer {
|
||||
const metadata: RuntimeMetadata = {
|
||||
runtimeId: this.runtime.getRuntimeId(),
|
||||
pid: this.pid,
|
||||
transport: this.transport,
|
||||
transports: this.transports,
|
||||
authToken: this.authToken,
|
||||
startedAt: this.runtime.getStartedAt()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// 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, generateKeyPairSync, createSign, randomBytes } from 'crypto'
|
||||
import { existsSync, readFileSync, writeFileSync, 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')
|
||||
return { cert, key, fingerprint: computeFingerprint(cert) }
|
||||
}
|
||||
|
||||
const { cert, key } = generateSelfSignedCert()
|
||||
|
||||
writeFileSync(keyPath, key, { mode: 0o600 })
|
||||
chmodSync(keyPath, 0o600)
|
||||
writeFileSync(certPath, cert, { mode: 0o600 })
|
||||
chmodSync(certPath, 0o600)
|
||||
|
||||
return { cert, key, fingerprint: computeFingerprint(cert) }
|
||||
}
|
||||
|
||||
function computeFingerprint(certPem: string): string {
|
||||
const derMatch = certPem.match(
|
||||
/-----BEGIN CERTIFICATE-----\n([\s\S]+?)\n-----END CERTIFICATE-----/
|
||||
)
|
||||
if (!derMatch?.[1]) {
|
||||
throw new Error('Invalid PEM certificate')
|
||||
}
|
||||
const der = Buffer.from(derMatch[1].replace(/\n/g, ''), 'base64')
|
||||
const hash = createHash('sha256').update(der).digest('hex')
|
||||
return `sha256:${hash}`
|
||||
}
|
||||
|
||||
// Why: we generate a minimal self-signed X.509v1 certificate using only
|
||||
// Node.js built-in crypto. This avoids a dependency on `selfsigned` or
|
||||
// OpenSSL CLI and keeps the cert generation auditable in one place.
|
||||
function generateSelfSignedCert(): { cert: string; key: string } {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ec', {
|
||||
namedCurve: 'prime256v1'
|
||||
})
|
||||
|
||||
const keyPem = privateKey.export({ type: 'sec1', format: 'pem' }) as string
|
||||
const pubDer = publicKey.export({ type: 'spki', format: 'der' })
|
||||
|
||||
const serialNumber = randomBytes(16)
|
||||
serialNumber[0]! &= 0x7f
|
||||
|
||||
const now = new Date()
|
||||
const notBefore = formatAsn1Time(now)
|
||||
// Why: 10 years validity — the cert is self-signed and pinned by fingerprint,
|
||||
// not trusted via a CA chain, so long validity is fine.
|
||||
const notAfter = formatAsn1Time(new Date(now.getFullYear() + 10, now.getMonth(), now.getDate()))
|
||||
|
||||
const issuerAndSubject = encodeDistinguishedName('Orca Runtime')
|
||||
|
||||
const tbs = encodeTbsCertificate({
|
||||
serialNumber,
|
||||
issuer: issuerAndSubject,
|
||||
notBefore,
|
||||
notAfter,
|
||||
subject: issuerAndSubject,
|
||||
subjectPublicKeyInfo: pubDer
|
||||
})
|
||||
|
||||
const signer = createSign('SHA256')
|
||||
signer.update(tbs)
|
||||
const signature = signer.sign(privateKey)
|
||||
|
||||
const cert = encodeX509Certificate(tbs, signature)
|
||||
const certPem = `-----BEGIN CERTIFICATE-----\n${cert
|
||||
.toString('base64')
|
||||
.match(/.{1,64}/g)!
|
||||
.join('\n')}\n-----END CERTIFICATE-----\n`
|
||||
|
||||
return { cert: certPem, key: keyPem }
|
||||
}
|
||||
|
||||
function formatAsn1Time(date: Date): string {
|
||||
const y = date.getUTCFullYear().toString().slice(2)
|
||||
const m = (date.getUTCMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getUTCDate().toString().padStart(2, '0')
|
||||
return `${y}${m}${d}000000Z`
|
||||
}
|
||||
|
||||
function encodeAsn1Length(length: number): Buffer {
|
||||
if (length < 0x80) {
|
||||
return Buffer.from([length])
|
||||
}
|
||||
if (length < 0x100) {
|
||||
return Buffer.from([0x81, length])
|
||||
}
|
||||
return Buffer.from([0x82, (length >> 8) & 0xff, length & 0xff])
|
||||
}
|
||||
|
||||
function encodeAsn1Sequence(...items: Buffer[]): Buffer {
|
||||
const content = Buffer.concat(items)
|
||||
return Buffer.concat([Buffer.from([0x30]), encodeAsn1Length(content.length), content])
|
||||
}
|
||||
|
||||
function encodeAsn1Integer(value: Buffer): Buffer {
|
||||
let buf = value
|
||||
if (buf[0]! & 0x80) {
|
||||
buf = Buffer.concat([Buffer.from([0x00]), buf])
|
||||
}
|
||||
return Buffer.concat([Buffer.from([0x02]), encodeAsn1Length(buf.length), buf])
|
||||
}
|
||||
|
||||
function encodeAsn1Utf8String(str: string): Buffer {
|
||||
const buf = Buffer.from(str, 'utf-8')
|
||||
return Buffer.concat([Buffer.from([0x0c]), encodeAsn1Length(buf.length), buf])
|
||||
}
|
||||
|
||||
function encodeAsn1UtcTime(timeStr: string): Buffer {
|
||||
const buf = Buffer.from(timeStr, 'ascii')
|
||||
return Buffer.concat([Buffer.from([0x17]), encodeAsn1Length(buf.length), buf])
|
||||
}
|
||||
|
||||
function encodeAsn1BitString(data: Buffer): Buffer {
|
||||
const content = Buffer.concat([Buffer.from([0x00]), data])
|
||||
return Buffer.concat([Buffer.from([0x03]), encodeAsn1Length(content.length), content])
|
||||
}
|
||||
|
||||
function encodeAsn1Oid(oid: number[]): Buffer {
|
||||
const bytes: number[] = [oid[0]! * 40 + oid[1]!]
|
||||
for (let i = 2; i < oid.length; i++) {
|
||||
let value = oid[i]!
|
||||
if (value < 128) {
|
||||
bytes.push(value)
|
||||
} else {
|
||||
const encoded: number[] = []
|
||||
encoded.push(value & 0x7f)
|
||||
value >>= 7
|
||||
while (value > 0) {
|
||||
encoded.push((value & 0x7f) | 0x80)
|
||||
value >>= 7
|
||||
}
|
||||
encoded.reverse()
|
||||
bytes.push(...encoded)
|
||||
}
|
||||
}
|
||||
const buf = Buffer.from(bytes)
|
||||
return Buffer.concat([Buffer.from([0x06]), encodeAsn1Length(buf.length), buf])
|
||||
}
|
||||
|
||||
function encodeDistinguishedName(commonName: string): Buffer {
|
||||
// CN attribute type OID: 2.5.4.3
|
||||
const cnOid = encodeAsn1Oid([2, 5, 4, 3])
|
||||
const cnValue = encodeAsn1Utf8String(commonName)
|
||||
const atv = encodeAsn1Sequence(cnOid, cnValue)
|
||||
// RDN is a SET of attribute-type-value
|
||||
const rdnContent = atv
|
||||
const rdn = Buffer.concat([Buffer.from([0x31]), encodeAsn1Length(rdnContent.length), rdnContent])
|
||||
return encodeAsn1Sequence(rdn)
|
||||
}
|
||||
|
||||
function encodeTbsCertificate(params: {
|
||||
serialNumber: Buffer
|
||||
issuer: Buffer
|
||||
notBefore: string
|
||||
notAfter: string
|
||||
subject: Buffer
|
||||
subjectPublicKeyInfo: Buffer
|
||||
}): Buffer {
|
||||
// ecdsaWithSHA256: 1.2.840.10045.4.3.2
|
||||
const signatureAlgorithm = encodeAsn1Sequence(encodeAsn1Oid([1, 2, 840, 10045, 4, 3, 2]))
|
||||
|
||||
const validity = encodeAsn1Sequence(
|
||||
encodeAsn1UtcTime(params.notBefore),
|
||||
encodeAsn1UtcTime(params.notAfter)
|
||||
)
|
||||
|
||||
return encodeAsn1Sequence(
|
||||
encodeAsn1Integer(params.serialNumber),
|
||||
signatureAlgorithm,
|
||||
params.issuer,
|
||||
validity,
|
||||
params.subject,
|
||||
params.subjectPublicKeyInfo
|
||||
)
|
||||
}
|
||||
|
||||
function encodeX509Certificate(tbs: Buffer, signature: Buffer): Buffer {
|
||||
const signatureAlgorithm = encodeAsn1Sequence(encodeAsn1Oid([1, 2, 840, 10045, 4, 3, 2]))
|
||||
return encodeAsn1Sequence(tbs, signatureAlgorithm, encodeAsn1BitString(signature))
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodePairingOffer, decodePairingOffer, type PairingOffer } from './pairing'
|
||||
|
||||
describe('pairing offer', () => {
|
||||
const offer: PairingOffer = {
|
||||
v: 1,
|
||||
endpoint: 'wss://192.168.1.10:6768',
|
||||
deviceToken: 'abcdef1234567890abcdef1234567890abcdef1234567890',
|
||||
certFingerprint: 'sha256:deadbeef'
|
||||
}
|
||||
|
||||
it('encode then decode round-trips correctly', () => {
|
||||
const url = encodePairingOffer(offer)
|
||||
expect(url).toMatch(/^orca:\/\/pair#/)
|
||||
|
||||
const decoded = decodePairingOffer(url)
|
||||
expect(decoded).toEqual(offer)
|
||||
})
|
||||
|
||||
it('encoded URL uses base64url (no +, /, or = characters)', () => {
|
||||
const url = encodePairingOffer(offer)
|
||||
const fragment = url.split('#')[1]!
|
||||
expect(fragment).not.toMatch(/[+/=]/)
|
||||
})
|
||||
|
||||
it('rejects URLs with wrong scheme', () => {
|
||||
expect(() => decodePairingOffer('https://example.com#abc')).toThrow('Invalid pairing URL')
|
||||
})
|
||||
|
||||
it('rejects URLs without fragment', () => {
|
||||
expect(() => decodePairingOffer('orca://pair')).toThrow('Invalid pairing URL')
|
||||
})
|
||||
|
||||
it('rejects payloads with missing fields', () => {
|
||||
const partial = { v: 1, endpoint: 'wss://host:1234' }
|
||||
const base64 = Buffer.from(JSON.stringify(partial)).toString('base64')
|
||||
expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow()
|
||||
})
|
||||
|
||||
it('rejects payloads with wrong version', () => {
|
||||
const wrong = { ...offer, v: 2 }
|
||||
const base64 = Buffer.from(JSON.stringify(wrong)).toString('base64')
|
||||
expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow()
|
||||
})
|
||||
|
||||
it('rejects payloads with invalid fingerprint prefix', () => {
|
||||
const wrong = { ...offer, certFingerprint: 'md5:abc' }
|
||||
const base64 = Buffer.from(JSON.stringify(wrong)).toString('base64')
|
||||
expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const PAIRING_OFFER_VERSION = 1
|
||||
|
||||
export const PairingOfferV1 = z.object({
|
||||
v: z.literal(PAIRING_OFFER_VERSION),
|
||||
endpoint: z.string().min(1),
|
||||
deviceToken: z.string().min(1),
|
||||
certFingerprint: z.string().startsWith('sha256:')
|
||||
})
|
||||
|
||||
export type PairingOffer = z.infer<typeof PairingOfferV1>
|
||||
|
||||
export function encodePairingOffer(offer: PairingOffer): string {
|
||||
const json = JSON.stringify(offer)
|
||||
const base64url = Buffer.from(json, 'utf-8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
return `orca://pair#${base64url}`
|
||||
}
|
||||
|
||||
export function decodePairingOffer(url: string): PairingOffer {
|
||||
const hashIndex = url.indexOf('#')
|
||||
if (!url.startsWith('orca://pair') || hashIndex === -1) {
|
||||
throw new Error('Invalid pairing URL: must start with orca://pair#')
|
||||
}
|
||||
const base64url = url.slice(hashIndex + 1)
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const json = Buffer.from(base64, 'base64').toString('utf-8')
|
||||
return PairingOfferV1.parse(JSON.parse(json))
|
||||
}
|
||||
@@ -9,15 +9,39 @@ export type RuntimeTransportMetadata =
|
||||
kind: 'named-pipe'
|
||||
endpoint: string
|
||||
}
|
||||
| {
|
||||
kind: 'websocket'
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type RuntimeMetadata = {
|
||||
runtimeId: string
|
||||
pid: number
|
||||
transport: RuntimeTransportMetadata | null
|
||||
transports: RuntimeTransportMetadata[]
|
||||
authToken: string | null
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
// Why: the CLI must handle metadata files written by older Orca versions that
|
||||
// used a singular `transport` field. This helper extracts the first transport
|
||||
// matching the given kinds from either the new `transports` array or the
|
||||
// legacy `transport` field.
|
||||
export function findTransport(
|
||||
metadata: RuntimeMetadata,
|
||||
...kinds: RuntimeTransportMetadata['kind'][]
|
||||
): RuntimeTransportMetadata | null {
|
||||
const transports = metadata.transports
|
||||
if (transports && Array.isArray(transports)) {
|
||||
return transports.find((t) => kinds.includes(t.kind)) ?? null
|
||||
}
|
||||
// Why: backward compatibility with pre-transports-array metadata files.
|
||||
const legacy = (metadata as Record<string, unknown>).transport as RuntimeTransportMetadata | null
|
||||
if (legacy && kinds.includes(legacy.kind)) {
|
||||
return legacy
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const PRIMARY_RUNTIME_METADATA_FILE = 'orca-runtime.json'
|
||||
|
||||
export function getRuntimeMetadataPath(userDataPath: string): string {
|
||||
|
||||
Reference in New Issue
Block a user