mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
+8
-1
@@ -15,13 +15,20 @@
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.stably.orca.mobile"
|
||||
"bundleIdentifier": "com.stably.orca.mobile",
|
||||
"infoPlist": {
|
||||
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsLocalNetworking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#111111"
|
||||
},
|
||||
"usesCleartextTraffic": true,
|
||||
"allowBackup": false,
|
||||
"package": "com.stably.orca.mobile"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -239,6 +239,7 @@ export default function HostScreen() {
|
||||
hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
|
||||
)
|
||||
const [client, setClient] = useState<RpcClient | null>(null)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const [connState, setConnState] = useState<ConnectionState>('disconnected')
|
||||
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
|
||||
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
|
||||
@@ -276,8 +277,10 @@ export default function HostScreen() {
|
||||
// Load persisted pins and preferences
|
||||
useEffect(() => {
|
||||
if (!hostId) return
|
||||
let stale = false
|
||||
void (async () => {
|
||||
const [pins, prefs] = await Promise.all([loadPinnedIds(hostId), loadPreferences(hostId)])
|
||||
if (stale) return
|
||||
setPinnedIds(pins)
|
||||
setSortMode(prefs.sortMode as SortMode)
|
||||
setFilters({
|
||||
@@ -288,11 +291,19 @@ export default function HostScreen() {
|
||||
setCollapsedGroups(new Set(prefs.collapsedGroups))
|
||||
setPrefsLoaded(true)
|
||||
})()
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [hostId])
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let rpcClient: RpcClient | null = null
|
||||
clientRef.current = null
|
||||
setClient(null)
|
||||
setConnState('connecting')
|
||||
setHostName('')
|
||||
setError('')
|
||||
// Why: re-seed from the current host's cache on every hostId change.
|
||||
// The useState initializer only runs on first mount, so if Expo Router
|
||||
// reuses this screen with a different hostId, we must reset here.
|
||||
@@ -331,6 +342,7 @@ export default function HostScreen() {
|
||||
return
|
||||
}
|
||||
setHostName(host.name)
|
||||
clientRef.current = rpcClient
|
||||
setClient(rpcClient)
|
||||
|
||||
await updateLastConnected(host.id)
|
||||
@@ -341,14 +353,20 @@ export default function HostScreen() {
|
||||
disposed = true
|
||||
cancelAnimationFrame(rafId)
|
||||
rpcClient?.close()
|
||||
if (clientRef.current === rpcClient) {
|
||||
clientRef.current = null
|
||||
}
|
||||
}
|
||||
}, [hostId])
|
||||
|
||||
const fetchWorktrees = useCallback(async () => {
|
||||
if (!client || connState !== 'connected') return
|
||||
const requestClient = client
|
||||
const requestHostId = hostId
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('worktree.ps')
|
||||
const response = await requestClient.sendRequest('worktree.ps')
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId) return
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { worktrees: Worktree[] }
|
||||
setWorktrees(result.worktrees)
|
||||
|
||||
@@ -167,6 +167,7 @@ export default function SessionScreen() {
|
||||
// the current value to avoid re-fitting while a fit is already in progress.
|
||||
const fitPendingRef = useRef(false)
|
||||
fitPendingRef.current = fitPending
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
// Why: tracks terminals the user explicitly restored to desktop size.
|
||||
// Without this, switching away and back would auto-fit them again.
|
||||
const manuallyRestoredRef = useRef<Set<string>>(new Set())
|
||||
@@ -283,9 +284,17 @@ export default function SessionScreen() {
|
||||
async (handle: string, rpcClient: RpcClient) => {
|
||||
const clientId = deviceTokenRef.current
|
||||
if (!clientId) return
|
||||
const seq = subscribeSeqRef.current.get(handle)
|
||||
|
||||
const dims = await getTerminalRef(handle)?.measureFitDimensions()
|
||||
if (!dims) return
|
||||
if (
|
||||
clientRef.current !== rpcClient ||
|
||||
!terminalRefs.current.has(handle) ||
|
||||
subscribeSeqRef.current.get(handle) !== seq
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = await rpcClient.sendRequest('terminal.resizeForClient', {
|
||||
terminal: handle,
|
||||
@@ -295,6 +304,13 @@ export default function SessionScreen() {
|
||||
clientId
|
||||
})
|
||||
if (!response.ok) return
|
||||
if (
|
||||
clientRef.current !== rpcClient ||
|
||||
!terminalRefs.current.has(handle) ||
|
||||
subscribeSeqRef.current.get(handle) !== seq
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
updateFittedHandles((prev) => new Set(prev).add(handle))
|
||||
resubscribeWithoutFocus(handle)
|
||||
@@ -303,44 +319,58 @@ export default function SessionScreen() {
|
||||
[getTerminalRef, resubscribeWithoutFocus, updateFittedHandles]
|
||||
)
|
||||
|
||||
const handleFitToPhone = useCallback(async () => {
|
||||
if (!client || !activeHandle || fitPending) return
|
||||
const handleFitToPhone = useCallback(
|
||||
async (targetHandle = activeHandle) => {
|
||||
if (!client || !targetHandle || fitPending) return
|
||||
|
||||
manuallyRestoredRef.current.delete(activeHandle)
|
||||
setFitPending(true)
|
||||
try {
|
||||
await fitToPhoneCore(activeHandle, client)
|
||||
} finally {
|
||||
setFitPending(false)
|
||||
}
|
||||
}, [client, activeHandle, fitPending, fitToPhoneCore])
|
||||
manuallyRestoredRef.current.delete(targetHandle)
|
||||
setFitPending(true)
|
||||
try {
|
||||
await fitToPhoneCore(targetHandle, client)
|
||||
} finally {
|
||||
setFitPending(false)
|
||||
}
|
||||
},
|
||||
[client, activeHandle, fitPending, fitToPhoneCore]
|
||||
)
|
||||
|
||||
const handleRestoreDesktopSize = useCallback(async () => {
|
||||
if (!client || !activeHandle) return
|
||||
const handle = activeHandle
|
||||
const clientId = deviceTokenRef.current
|
||||
if (!clientId) return
|
||||
const handleRestoreDesktopSize = useCallback(
|
||||
async (targetHandle = activeHandle) => {
|
||||
if (!client || !targetHandle) return
|
||||
const handle = targetHandle
|
||||
const clientId = deviceTokenRef.current
|
||||
if (!clientId) return
|
||||
const seq = subscribeSeqRef.current.get(handle)
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('terminal.resizeForClient', {
|
||||
terminal: handle,
|
||||
mode: 'restore',
|
||||
clientId
|
||||
})
|
||||
if (!response.ok) return
|
||||
try {
|
||||
const response = await client.sendRequest('terminal.resizeForClient', {
|
||||
terminal: handle,
|
||||
mode: 'restore',
|
||||
clientId
|
||||
})
|
||||
if (!response.ok) return
|
||||
if (
|
||||
clientRef.current !== client ||
|
||||
!terminalRefs.current.has(handle) ||
|
||||
subscribeSeqRef.current.get(handle) !== seq
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
manuallyRestoredRef.current.add(handle)
|
||||
updateFittedHandles((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(handle)
|
||||
return next
|
||||
})
|
||||
resubscribeWithoutFocus(handle)
|
||||
setTimeout(() => getTerminalRef(handle)?.resetZoom(), 500)
|
||||
} catch {
|
||||
// Restore failed — keep current state.
|
||||
}
|
||||
}, [client, activeHandle, getTerminalRef, resubscribeWithoutFocus, updateFittedHandles])
|
||||
manuallyRestoredRef.current.add(handle)
|
||||
updateFittedHandles((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(handle)
|
||||
return next
|
||||
})
|
||||
resubscribeWithoutFocus(handle)
|
||||
setTimeout(() => getTerminalRef(handle)?.resetZoom(), 500)
|
||||
} catch {
|
||||
// Restore failed — keep current state.
|
||||
}
|
||||
},
|
||||
[client, activeHandle, getTerminalRef, resubscribeWithoutFocus, updateFittedHandles]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
@@ -360,12 +390,16 @@ export default function SessionScreen() {
|
||||
return
|
||||
}
|
||||
setClient(rpcClient)
|
||||
clientRef.current = rpcClient
|
||||
})()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
clearTerminalCache()
|
||||
rpcClient?.close()
|
||||
if (clientRef.current === rpcClient) {
|
||||
clientRef.current = null
|
||||
}
|
||||
}
|
||||
}, [clearTerminalCache, hostId])
|
||||
|
||||
@@ -1028,8 +1062,11 @@ export default function SessionScreen() {
|
||||
label: 'Restore Desktop Size',
|
||||
icon: Monitor,
|
||||
onPress: () => {
|
||||
const target = actionTarget
|
||||
setActionTarget(null)
|
||||
void handleRestoreDesktopSize()
|
||||
if (target) {
|
||||
void handleRestoreDesktopSize(target.handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1038,8 +1075,11 @@ export default function SessionScreen() {
|
||||
label: fitPending ? 'Fitting…' : 'Fit to Phone',
|
||||
icon: Smartphone,
|
||||
onPress: () => {
|
||||
const target = actionTarget
|
||||
setActionTarget(null)
|
||||
void handleFitToPhone()
|
||||
if (target) {
|
||||
void handleFitToPhone(target.handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
]),
|
||||
|
||||
+57
-6
@@ -4,7 +4,6 @@ import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter, useFocusEffect } from 'expo-router'
|
||||
import {
|
||||
Monitor,
|
||||
MoreHorizontal,
|
||||
QrCode,
|
||||
Settings,
|
||||
Bot,
|
||||
@@ -21,12 +20,11 @@ import { subscribeToDesktopNotifications } from '../src/notifications/mobile-not
|
||||
import type { ConnectionState, HostProfile } from '../src/transport/types'
|
||||
import { triggerMediumImpact } from '../src/platform/haptics'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { StatusDot } from '../src/components/StatusDot'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ActionSheetModal } from '../src/components/ActionSheetModal'
|
||||
import { ConfirmModal } from '../src/components/ConfirmModal'
|
||||
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { colors, spacing, radii } from '../src/theme/mobile-theme'
|
||||
|
||||
function endpointLabel(endpoint: string): string {
|
||||
try {
|
||||
@@ -104,6 +102,18 @@ function fetchWorktreeInfo(
|
||||
) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
const markLoaded = () => {
|
||||
setInfo((prev) => ({
|
||||
...prev,
|
||||
[hostId]: {
|
||||
hostId,
|
||||
totalWorktrees: 0,
|
||||
activeCount: 0,
|
||||
lastActiveWorktree: null
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
client
|
||||
.sendRequest('worktree.ps')
|
||||
.then((response) => {
|
||||
@@ -124,9 +134,13 @@ function fetchWorktreeInfo(
|
||||
lastActiveWorktree: lastActive
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
markLoaded()
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => {
|
||||
if (!disposed()) markLoaded()
|
||||
})
|
||||
}
|
||||
|
||||
// Why: repo names get a stable color derived from hashing, matching the
|
||||
@@ -260,6 +274,20 @@ export default function HomeScreen() {
|
||||
return null
|
||||
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
|
||||
|
||||
const resumeLoading = useMemo(
|
||||
() =>
|
||||
sortedHosts.some((host) => {
|
||||
const state = hostStates[host.id] ?? 'connecting'
|
||||
return (
|
||||
state === 'connecting' ||
|
||||
state === 'handshaking' ||
|
||||
state === 'reconnecting' ||
|
||||
(state === 'connected' && !worktreeInfo[host.id])
|
||||
)
|
||||
}),
|
||||
[sortedHosts, hostStates, worktreeInfo]
|
||||
)
|
||||
|
||||
async function handleRename(newName: string) {
|
||||
if (!renameTarget) return
|
||||
try {
|
||||
@@ -423,7 +451,7 @@ export default function HomeScreen() {
|
||||
ListFooterComponent={
|
||||
<View>
|
||||
{/* ─── Resume card ─── */}
|
||||
{resumeWorktree && (
|
||||
{resumeWorktree ? (
|
||||
<>
|
||||
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Resume</Text>
|
||||
<Pressable
|
||||
@@ -458,7 +486,18 @@ export default function HomeScreen() {
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
) : hosts.length > 0 && resumeLoading ? (
|
||||
<>
|
||||
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Resume</Text>
|
||||
<View style={styles.resumeCard}>
|
||||
<View style={[styles.resumeIcon, styles.skeletonBlock]} />
|
||||
<View style={styles.resumeMain}>
|
||||
<View style={[styles.skeletonLine, { width: '55%' }]} />
|
||||
<View style={[styles.skeletonLine, { width: '35%', marginTop: 6 }]} />
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ─── Quick actions ─── */}
|
||||
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
|
||||
@@ -774,6 +813,18 @@ const styles = StyleSheet.create({
|
||||
flex: 1
|
||||
},
|
||||
|
||||
/* ─── Skeleton ─── */
|
||||
skeletonBlock: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
opacity: 0.5
|
||||
},
|
||||
skeletonLine: {
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.bgRaised,
|
||||
opacity: 0.5
|
||||
},
|
||||
|
||||
/* ─── Quick actions ─── */
|
||||
quickActions: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -49,12 +49,13 @@ export default function PairScanScreen() {
|
||||
|
||||
async function testAndSave(offer: PairingOffer) {
|
||||
setStatus('connecting')
|
||||
|
||||
const client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64)
|
||||
let client: ReturnType<typeof connect> | null = null
|
||||
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64)
|
||||
const response = await client.sendRequest('status.get')
|
||||
client.close()
|
||||
client = null
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.error.code === 'unauthorized') {
|
||||
@@ -86,6 +87,8 @@ export default function PairScanScreen() {
|
||||
setStatus('error')
|
||||
setErrorMessage('Cannot connect — check that your computer is on the same network')
|
||||
processingRef.current = false
|
||||
} finally {
|
||||
client?.close()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -114,6 +114,12 @@ export default function TroubleshootScreen() {
|
||||
const [checks, setChecks] = useState<CheckResult[]>([])
|
||||
const abortRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleSection = useCallback((id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id))
|
||||
}, [])
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
ScrollView,
|
||||
Keyboard
|
||||
Keyboard,
|
||||
BackHandler
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
@@ -91,6 +92,16 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
|
||||
}
|
||||
}, [visible, insets.bottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
onClose()
|
||||
return true
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [visible, onClose])
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
@@ -145,28 +156,41 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, pointerStyle]}>
|
||||
<Animated.View style={[styles.overlay, pointerStyle]} accessibilityViewIsModal aria-modal>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<Animated.View style={[styles.backdrop, backdropStyle]}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.anchor} pointerEvents="box-none">
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={[styles.drawer, { paddingBottom: insets.bottom + spacing.lg }, drawerStyle]}
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
{
|
||||
maxHeight: screenHeight - insets.top - spacing.lg,
|
||||
paddingBottom: insets.bottom + spacing.lg
|
||||
},
|
||||
drawerStyle
|
||||
]}
|
||||
>
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
<View style={styles.bottomExtension} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
<View style={styles.bottomExtension} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Animated.View>
|
||||
@@ -210,10 +234,13 @@ const styles = StyleSheet.create({
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.textMuted,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
opacity: 0.4
|
||||
},
|
||||
handleHitArea: {
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.md
|
||||
},
|
||||
bottomExtension: {
|
||||
position: 'absolute',
|
||||
bottom: -500,
|
||||
|
||||
@@ -269,7 +269,12 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client) return
|
||||
if (!visible) {
|
||||
setShowRepoPicker(false)
|
||||
setShowAgentPicker(false)
|
||||
return
|
||||
}
|
||||
if (!client) return
|
||||
let stale = false
|
||||
setName('')
|
||||
setNote('')
|
||||
@@ -526,7 +531,7 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
{/* Sub-modals for pickers — rendered outside the main modal so they
|
||||
layer on top and scroll without touch conflicts. */}
|
||||
<PickerListModal
|
||||
visible={showRepoPicker}
|
||||
visible={visible && showRepoPicker}
|
||||
title="Repository"
|
||||
items={repos.map((r) => ({ id: r.id, label: r.displayName, _repo: r }))}
|
||||
selectedId={selectedRepo?.id ?? ''}
|
||||
@@ -535,7 +540,7 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
/>
|
||||
|
||||
<PickerListModal
|
||||
visible={showAgentPicker}
|
||||
visible={visible && showAgentPicker}
|
||||
title="Agent"
|
||||
items={ALL_AGENTS}
|
||||
selectedId={selectedAgent.id}
|
||||
|
||||
@@ -70,26 +70,42 @@ export function subscribeToDesktopNotifications(client: RpcClient): () => void {
|
||||
configureNotificationChannel()
|
||||
|
||||
let subscriptionId: string | null = null
|
||||
let disposed = false
|
||||
function unsubscribeServer(id: string) {
|
||||
if (client.getState() === 'connected') {
|
||||
client.sendRequest('notifications.unsubscribe', { subscriptionId: id }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => {
|
||||
const event = data as NotificationEvent | SubscribeResult | { type: 'end' }
|
||||
if (event.type === 'ready') {
|
||||
subscriptionId = (event as SubscribeResult).subscriptionId
|
||||
if (disposed) {
|
||||
unsubscribeServer(subscriptionId)
|
||||
unsubscribeStream()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'end') return
|
||||
if (event.type === 'end') {
|
||||
if (disposed) unsubscribeStream()
|
||||
return
|
||||
}
|
||||
if (disposed) return
|
||||
if (event.type === 'notification') {
|
||||
void showLocalNotification(event as NotificationEvent)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribeStream()
|
||||
disposed = true
|
||||
// Why: the client may already be closed when this cleanup runs (component
|
||||
// unmount races with disconnect). sendRequest rejects immediately on a
|
||||
// closed client — swallow it since server-side cleanup happens via
|
||||
// connection-close anyway.
|
||||
if (subscriptionId && client.getState() === 'connected') {
|
||||
client.sendRequest('notifications.unsubscribe', { subscriptionId }).catch(() => {})
|
||||
if (subscriptionId) {
|
||||
unsubscribeStream()
|
||||
unsubscribeServer(subscriptionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,25 @@ const DEFAULT_PREFS: HostPreferences = {
|
||||
collapsedGroups: [],
|
||||
selectedRepos: []
|
||||
}
|
||||
const SORT_MODES = new Set(['smart', 'recent', 'name'])
|
||||
const FILTER_MODES = new Set(['all', 'active'])
|
||||
const GROUP_MODES = new Set(['none', 'repo', 'prStatus'])
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: []
|
||||
}
|
||||
|
||||
function allowedString(value: unknown, allowed: Set<string>, fallback: string): string {
|
||||
return typeof value === 'string' && allowed.has(value) ? value : fallback
|
||||
}
|
||||
|
||||
export async function loadPinnedIds(hostId: string): Promise<Set<string>> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(PINS_PREFIX + hostId)
|
||||
if (!raw) return new Set()
|
||||
return new Set(JSON.parse(raw) as string[])
|
||||
return new Set(stringArray(JSON.parse(raw)))
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
@@ -52,7 +65,14 @@ export async function loadPreferences(hostId: string): Promise<HostPreferences>
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(PREFS_PREFIX + hostId)
|
||||
if (!raw) return DEFAULT_PREFS
|
||||
return { ...DEFAULT_PREFS, ...(JSON.parse(raw) as Partial<HostPreferences>) }
|
||||
const parsed = JSON.parse(raw) as Partial<HostPreferences>
|
||||
return {
|
||||
sortMode: allowedString(parsed.sortMode, SORT_MODES, DEFAULT_PREFS.sortMode),
|
||||
filterMode: allowedString(parsed.filterMode, FILTER_MODES, DEFAULT_PREFS.filterMode),
|
||||
groupMode: allowedString(parsed.groupMode, GROUP_MODES, DEFAULT_PREFS.groupMode),
|
||||
collapsedGroups: stringArray(parsed.collapsedGroups),
|
||||
selectedRepos: stringArray(parsed.selectedRepos)
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_PREFS
|
||||
}
|
||||
|
||||
@@ -324,8 +324,10 @@ const XTERM_HTML = `<!DOCTYPE html>
|
||||
} else if (msg.type === 'write') {
|
||||
write(msg.data);
|
||||
} else if (msg.type === 'clear') {
|
||||
terminalGeneration++;
|
||||
writeQueue = [];
|
||||
afterDrainCallbacks = [];
|
||||
writesDraining = false;
|
||||
if (term) { term.clear(); term.reset(); }
|
||||
} else if (msg.type === 'measure') {
|
||||
measureFitDimensions();
|
||||
@@ -573,6 +575,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
measureFitDimensions(): Promise<{ cols: number; rows: number } | null> {
|
||||
if (!isWebReadyRef.current) return Promise.resolve(null)
|
||||
return new Promise((resolve) => {
|
||||
measureResolveRef.current?.(null)
|
||||
measureResolveRef.current = resolve
|
||||
sendToWebView({ type: 'measure' })
|
||||
// Why: if the WebView doesn't respond within 2s (e.g., xterm
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { HostProfile } from './types'
|
||||
import { HostProfileSchema, type HostProfile } from './types'
|
||||
|
||||
const STORAGE_KEY = 'orca:hosts'
|
||||
|
||||
@@ -7,7 +7,12 @@ export async function loadHosts(): Promise<HostProfile[]> {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
try {
|
||||
return JSON.parse(raw) as HostProfile[]
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.flatMap((item) => {
|
||||
const result = HostProfileSchema.safeParse(item)
|
||||
return result.success ? [result.data] : []
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -66,8 +66,10 @@ export function connect(
|
||||
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'))
|
||||
} else if (next === 'disconnected' || next === 'auth-failed') {
|
||||
const reason =
|
||||
next === 'auth-failed' ? 'Unauthorized — pairing may be revoked' : 'Connection closed'
|
||||
for (const w of connectWaiters.splice(0)) w.reject(new Error(reason))
|
||||
}
|
||||
for (const listener of stateListeners) {
|
||||
listener(next)
|
||||
@@ -224,6 +226,7 @@ export function connect(
|
||||
rejectAllPending('Connection closed')
|
||||
return
|
||||
}
|
||||
rejectAllPending('Connection interrupted')
|
||||
setState('reconnecting')
|
||||
scheduleReconnect()
|
||||
}
|
||||
@@ -249,10 +252,12 @@ export function connect(
|
||||
}
|
||||
}
|
||||
|
||||
function sendEncrypted(request: RpcRequest) {
|
||||
function sendEncrypted(request: RpcRequest): boolean {
|
||||
if (ws && ws.readyState === WebSocket.OPEN && sharedKey) {
|
||||
ws.send(encrypt(JSON.stringify(request), sharedKey))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
openConnection()
|
||||
@@ -279,7 +284,11 @@ export function connect(
|
||||
}
|
||||
})
|
||||
|
||||
sendEncrypted({ id, deviceToken, method, params })
|
||||
if (!sendEncrypted({ id, deviceToken, method, params })) {
|
||||
pending.delete(id)
|
||||
clearTimeout(timeout)
|
||||
reject(new Error('Connection interrupted'))
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -294,20 +303,21 @@ export function connect(
|
||||
return () => {
|
||||
const stream = streamListeners.get(id)
|
||||
streamListeners.delete(id)
|
||||
sendEncrypted({
|
||||
id: nextId(),
|
||||
deviceToken,
|
||||
method: 'terminal.unsubscribe',
|
||||
// Why: the runtime keys terminal subscription cleanup by terminal
|
||||
// handle, not by the RPC request id used to open the stream.
|
||||
params:
|
||||
stream?.method === 'terminal.subscribe' &&
|
||||
stream.params &&
|
||||
typeof stream.params === 'object' &&
|
||||
typeof (stream.params as { terminal?: unknown }).terminal === 'string'
|
||||
? { subscriptionId: (stream.params as { terminal: string }).terminal }
|
||||
: { subscriptionId: id }
|
||||
})
|
||||
if (
|
||||
stream?.method === 'terminal.subscribe' &&
|
||||
stream.params &&
|
||||
typeof stream.params === 'object' &&
|
||||
typeof (stream.params as { terminal?: unknown }).terminal === 'string'
|
||||
) {
|
||||
sendEncrypted({
|
||||
id: nextId(),
|
||||
deviceToken,
|
||||
method: 'terminal.unsubscribe',
|
||||
// Why: the runtime keys terminal subscription cleanup by terminal
|
||||
// handle, not by the RPC request id used to open the stream.
|
||||
params: { subscriptionId: (stream.params as { terminal: string }).terminal }
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -51,3 +51,12 @@ export type HostProfile = {
|
||||
publicKeyB64: string
|
||||
lastConnected: number
|
||||
}
|
||||
|
||||
export const HostProfileSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
endpoint: z.string().min(1),
|
||||
deviceToken: z.string().min(1),
|
||||
publicKeyB64: z.string().min(1),
|
||||
lastConnected: z.number().finite()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user