diff --git a/mobile/app.json b/mobile/app.json index 7764de41217..6b248dcb279 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -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" }, diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index a5d0a79bac0..3a2cc2f0659 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -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(null) + const clientRef = useRef(null) const [connState, setConnState] = useState('disconnected') const [worktrees, setWorktrees] = useState(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) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index adba95e797b..60fccf736b9 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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(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>(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) + } } } ]), diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index ea520020180..046e8ddb01a 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -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={ {/* ─── Resume card ─── */} - {resumeWorktree && ( + {resumeWorktree ? ( <> Resume - )} + ) : hosts.length > 0 && resumeLoading ? ( + <> + Resume + + + + + + + + + ) : null} {/* ─── Quick actions ─── */} Quick Actions @@ -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', diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 81c12be81b4..ca522e7ddae 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -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 | 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() } } diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index d79c7b919d6..be800b74c31 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -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([]) const abortRef = useRef(false) + useEffect(() => { + return () => { + abortRef.current = true + } + }, []) + const toggleSection = useCallback((id: string) => { setExpandedId((prev) => (prev === id ? null : id)) }, []) diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index d046b2ccf72..2698353fb92 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -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 ( - + - - - - + + - {children} - - - - + + + + + {children} + + + @@ -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, diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 48a00716c2a..63d00cf8b0a 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -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. */} ({ id: r.id, label: r.displayName, _repo: r }))} selectedId={selectedRepo?.id ?? ''} @@ -535,7 +540,7 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props) /> 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) } } } diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index facbec1528b..efd4dc75c9b 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -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, fallback: string): string { + return typeof value === 'string' && allowed.has(value) ? value : fallback +} export async function loadPinnedIds(hostId: string): Promise> { 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 try { const raw = await AsyncStorage.getItem(PREFS_PREFIX + hostId) if (!raw) return DEFAULT_PREFS - return { ...DEFAULT_PREFS, ...(JSON.parse(raw) as Partial) } + const parsed = JSON.parse(raw) as Partial + 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 } diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 1033b0d26b9..e2461ce8180 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -324,8 +324,10 @@ const XTERM_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(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 diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index d1df0590158..7f35fb742a2 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -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 { 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 [] } diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 45ac2770fe6..5fa79115889 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -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 } + }) + } } }, diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 9cb7a1f45fd..c73489a9751 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -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() +})