From f938ff185f8f0097563d43dcf2789cad6e02f321 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 5 May 2026 16:09:16 -0700 Subject: [PATCH] feat(mobile): account switcher and rate-limit usage on mobile (#1467) Co-authored-by: Orca --- mobile/app/h/[hostId]/accounts.tsx | 423 ++++++++++++++++++ mobile/app/h/[hostId]/index.tsx | 14 +- mobile/app/h/_layout.tsx | 1 + mobile/app/index.tsx | 318 +++++++++++-- mobile/src/cache/home-snapshot-cache.ts | 66 +++ mobile/src/components/AccountUsage.tsx | 155 +++++++ mobile/src/components/AgentIcons.tsx | 30 ++ mobile/src/components/NewWorktreeModal.tsx | 27 +- src/main/index.ts | 1 + src/main/rate-limits/service.ts | 18 +- src/main/runtime/orca-runtime.ts | 136 +++++- src/main/runtime/rpc/core.ts | 6 + src/main/runtime/rpc/dispatcher.ts | 17 +- src/main/runtime/rpc/methods/accounts.ts | 111 +++++ src/main/runtime/rpc/methods/index.ts | 4 +- src/main/runtime/rpc/methods/notifications.ts | 26 +- src/main/runtime/rpc/ws-transport.ts | 22 +- src/main/runtime/runtime-rpc.ts | 42 +- 18 files changed, 1335 insertions(+), 82 deletions(-) create mode 100644 mobile/app/h/[hostId]/accounts.tsx create mode 100644 mobile/src/cache/home-snapshot-cache.ts create mode 100644 mobile/src/components/AccountUsage.tsx create mode 100644 mobile/src/components/AgentIcons.tsx create mode 100644 src/main/runtime/rpc/methods/accounts.ts diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx new file mode 100644 index 000000000..9319b616e --- /dev/null +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -0,0 +1,423 @@ +import { useEffect, useState, useCallback, useRef } from 'react' +import { + View, + Text, + StyleSheet, + Pressable, + ScrollView, + ActivityIndicator, + RefreshControl, + Alert +} from 'react-native' +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native' +import { connect, type RpcClient } from '../../../src/transport/rpc-client' +import { loadHosts } from '../../../src/transport/host-store' +import type { ConnectionState, RpcSuccess } from '../../../src/transport/types' +import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme' +import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons' +import { + type AccountsSnapshot, + type ProviderKey, + getActiveProviderRateLimits, + getInactiveProviderUsage, + UsageBar +} from '../../../src/components/AccountUsage' + +export default function AccountsScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const { hostId } = useLocalSearchParams<{ hostId: string }>() + + const [client, setClient] = useState(null) + const [connState, setConnState] = useState('connecting') + const [hostName, setHostName] = useState('') + const [snapshot, setSnapshot] = useState(null) + const [error, setError] = useState(null) + const [refreshing, setRefreshing] = useState(false) + const [busyAccountId, setBusyAccountId] = useState(null) + const clientRef = useRef(null) + + // Why: connect to the host's WebSocket on mount and tear down on + // unmount. Mirrors the connection lifecycle used in + // /h/[hostId]/index.tsx so reconnect/auth-failed states are handled + // identically. + useEffect(() => { + if (!hostId) return + let cancelled = false + let rpcClient: RpcClient | null = null + + void (async () => { + const hosts = await loadHosts() + const host = hosts.find((h) => h.id === hostId) + if (!host) { + if (!cancelled) setError('Host not found') + return + } + if (!cancelled) setHostName(host.name) + rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => { + if (!cancelled) setConnState(state) + }) + clientRef.current = rpcClient + if (!cancelled) setClient(rpcClient) + })() + + return () => { + cancelled = true + if (rpcClient) rpcClient.close() + clientRef.current = null + } + }, [hostId]) + + // Why: subscribe to streaming snapshot updates so usage bars refresh in + // place when the desktop's rate-limit poll completes (every 5 min) or + // when the user switches accounts. Falls back to a one-shot accounts.list + // if the subscription stream errors. + useEffect(() => { + if (!client || connState !== 'connected') return + const unsubscribe = client.subscribe('accounts.subscribe', null, (payload) => { + if (!payload || typeof payload !== 'object') return + const evt = payload as { type?: string; snapshot?: AccountsSnapshot } + if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) { + setSnapshot(evt.snapshot) + setError(null) + } + }) + return unsubscribe + }, [client, connState]) + + const refresh = useCallback(async () => { + if (!client) return + setRefreshing(true) + try { + const res = await client.sendRequest('accounts.list') + if (res.ok) { + setSnapshot((res as RpcSuccess).result as AccountsSnapshot) + setError(null) + } else { + setError(res.error.message) + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setRefreshing(false) + } + }, [client]) + + const selectAccount = useCallback( + async (provider: ProviderKey, accountId: string | null) => { + if (!client) return + setBusyAccountId(accountId ?? `${provider}:default`) + const method = provider === 'claude' ? 'accounts.selectClaude' : 'accounts.selectCodex' + try { + const res = await client.sendRequest(method, { accountId }) + if (!res.ok) { + Alert.alert('Could not switch account', res.error.message) + } else { + // Why: optimistic refresh — the streaming subscription will also + // emit, but a one-shot keeps the UI responsive even if the stream + // is temporarily disconnected. + await refresh() + } + } catch (e) { + Alert.alert('Could not switch account', e instanceof Error ? e.message : String(e)) + } finally { + setBusyAccountId(null) + } + }, + [client, refresh] + ) + + const renderProviderSection = (provider: ProviderKey, title: string) => { + if (!snapshot) return null + const state = provider === 'claude' ? snapshot.claude : snapshot.codex + const activeUsage = getActiveProviderRateLimits(snapshot, provider) + const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon + return ( + + + + {title} + + + {/* System default row */} + [styles.row, pressed && styles.rowPressed]} + onPress={() => selectAccount(provider, null)} + disabled={busyAccountId !== null || connState !== 'connected'} + > + + System default + Use the agent's own login + + + {state.activeAccountId === null ? ( + + ) : busyAccountId === `${provider}:default` ? ( + + ) : null} + + + + {state.accounts.map((account) => { + const isActive = state.activeAccountId === account.id + const inactiveEntry = !isActive + ? getInactiveProviderUsage(snapshot, provider, account.id) + : null + const usage = isActive ? activeUsage : (inactiveEntry?.claude ?? null) + const isFetching = + (isActive && usage?.status === 'fetching') || + (!isActive && inactiveEntry?.isFetching === true) + const session = usage?.session + const weekly = usage?.weekly + return ( + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => selectAccount(provider, account.id)} + disabled={busyAccountId !== null || connState !== 'connected' || isActive} + > + + + {account.email} + + + + + + {usage?.error ? ( + + {usage.error} + + ) : null} + + + {isActive ? ( + + ) : busyAccountId === account.id ? ( + + ) : null} + + + + ) + })} + + + ) + } + + return ( + + + router.back()}> + + + + Accounts + {hostName ? ( + + {hostName} + + ) : null} + + + {refreshing ? ( + + ) : ( + + )} + + + + + } + > + {connState !== 'connected' && !snapshot ? ( + + + Connecting to {hostName || 'host'}… + + ) : error && !snapshot ? ( + + {error} + + ) : !snapshot ? ( + + + Loading accounts… + + ) : ( + <> + {renderProviderSection('claude', 'Claude')} + {renderProviderSection('codex', 'Codex')} + + + + Add or re-authenticate accounts from desktop Settings → Accounts. + + + + )} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm, + gap: spacing.sm + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center' + }, + iconButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center' + }, + titleWrap: { + flex: 1 + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + subheading: { + fontSize: typography.metaSize, + color: colors.textSecondary, + marginTop: 1 + }, + scroll: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm + }, + section: { + marginBottom: spacing.xl + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.sm + }, + sectionHeading: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5 + }, + card: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden' + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowMain: { + flex: 1, + gap: 4 + }, + // Why: fixed-width trailing slot so the usage bars in `rowMain` keep the + // same width whether or not the row is currently selected (otherwise the + // checkmark on the active account squeezes the bars narrower than the + // inactive rows above/below it). + rowTrailing: { + width: 24, + alignItems: 'flex-end', + justifyContent: 'center', + marginLeft: spacing.sm + }, + rowTitle: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowSubtitle: { + fontSize: typography.metaSize, + color: colors.textSecondary + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + usageRow: { + flexDirection: 'row', + gap: spacing.md, + marginTop: 4 + }, + errorText: { + fontSize: typography.metaSize, + color: colors.statusRed + }, + placeholder: { + paddingVertical: spacing.xl * 2, + alignItems: 'center', + gap: spacing.sm + }, + placeholderText: { + fontSize: typography.bodySize, + color: colors.textSecondary + }, + footerHint: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm, + paddingHorizontal: spacing.sm, + paddingTop: spacing.sm + }, + footerHintText: { + flex: 1, + fontSize: typography.metaSize, + color: colors.textMuted, + lineHeight: 18 + } +}) diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index e428d70e5..e5c9c3182 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -24,7 +24,8 @@ import { Plus, Moon, Filter, - Check + Check, + UserCircle } from 'lucide-react-native' import { connect, type RpcClient } from '../../../src/transport/rpc-client' import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store' @@ -682,6 +683,17 @@ export default function HostScreen() { + router.push(`/h/${hostId}/accounts`)} + disabled={connState !== 'connected'} + > + + + setShowNewWorktree(true)} diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx index 59bbf7eea..1bd3fec41 100644 --- a/mobile/app/h/_layout.tsx +++ b/mobile/app/h/_layout.tsx @@ -10,6 +10,7 @@ export default function HostGroupLayout() { }} > + ) diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 7f01cecb6..19f25eefd 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -13,6 +13,13 @@ import { Terminal, Plus } from 'lucide-react-native' +import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons' +import { + type AccountsSnapshot, + type ProviderKey, + getActiveProviderRateLimits, + UsageBar +} from '../src/components/AccountUsage' import AsyncStorage from '@react-native-async-storage/async-storage' import { loadHosts, removeHost, renameHost } from '../src/transport/host-store' import { connect, type RpcClient } from '../src/transport/rpc-client' @@ -24,6 +31,7 @@ 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 { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache' import { colors, spacing, radii } from '../src/theme/mobile-theme' function endpointLabel(endpoint: string): string { @@ -102,16 +110,24 @@ function fetchWorktreeInfo( ) => void, disposed: () => boolean ) { - const markLoaded = () => { - setInfo((prev) => ({ - ...prev, - [hostId]: { - hostId, - totalWorktrees: 0, - activeCount: 0, - lastActiveWorktree: null + // Why: only seed an empty zeroed entry when this host has no prior info + // at all (e.g., first ever load before any cache hydration). On a + // transient failure for a host that already has cached data, leave the + // cached entry alone so the Resume card and host-meta line don't + // momentarily flip to "0 worktrees" / disappear during reconnects. + const markLoadedIfMissing = () => { + setInfo((prev) => { + if (prev[hostId]) return prev + return { + ...prev, + [hostId]: { + hostId, + totalWorktrees: 0, + activeCount: 0, + lastActiveWorktree: null + } } - })) + }) } client @@ -135,14 +151,34 @@ function fetchWorktreeInfo( } })) } else { - markLoaded() + markLoadedIfMissing() } }) .catch(() => { - if (!disposed()) markLoaded() + if (!disposed()) markLoadedIfMissing() }) } +function fetchAccountsSnapshot( + client: RpcClient, + hostId: string, + setSnapshots: ( + updater: (prev: Record) => Record + ) => void, + disposed: () => boolean +) { + client + .sendRequest('accounts.list') + .then((response) => { + if (disposed()) return + if (response.ok) { + const snapshot = response.result as AccountsSnapshot + setSnapshots((prev) => ({ ...prev, [hostId]: snapshot })) + } + }) + .catch(() => {}) +} + // Why: repo names get a stable color derived from hashing, matching the // host detail page's colored dots for visual consistency. const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'] @@ -164,11 +200,53 @@ export default function HomeScreen() { const [hostStates, setHostStates] = useState>({}) const [stats, setStats] = useState(null) const [worktreeInfo, setWorktreeInfo] = useState>({}) + const [accountsByHost, setAccountsByHost] = useState>({}) const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>( null ) const clientsRef = useRef>([]) + // Why: hydrate the home page from a persisted snapshot on cold-start so + // Resume + Account-usage cards paint immediately with last-known data + // instead of flashing empty for ~1s while the WebSocket reconnects. + // Stream/list responses overwrite this seed in place when they arrive. + const hydratedRef = useRef(false) + useEffect(() => { + if (hydratedRef.current) return + hydratedRef.current = true + let cancelled = false + void loadHomeSnapshot().then((snap) => { + if (cancelled || !snap) return + setWorktreeInfo((prev) => (Object.keys(prev).length > 0 ? prev : snap.worktreeInfo)) + setAccountsByHost((prev) => (Object.keys(prev).length > 0 ? prev : snap.accountsByHost)) + for (const [hostId, info] of Object.entries(snap.worktreeInfo)) { + const wt = info.lastActiveWorktree + if (wt) { + // Why: also seed the in-memory worktree cache so resumeWorktree's + // lastVisited fast-path can find the cached worktree object. + setCachedWorktrees(hostId, [wt]) + } + } + }) + return () => { + cancelled = true + } + }, []) + + // Why: persist the merged snapshot whenever either piece updates so the + // next cold-start has fresh seed data. The cache module debounces writes + // internally so a flurry of streamed updates doesn't hammer disk. + useEffect(() => { + if (Object.keys(worktreeInfo).length === 0 && Object.keys(accountsByHost).length === 0) { + return + } + saveHomeSnapshot({ + worktreeInfo, + accountsByHost, + savedAt: Date.now() + }) + }, [worktreeInfo, accountsByHost]) + useFocusEffect( useCallback(() => { let stale = false @@ -185,6 +263,7 @@ export default function HomeScreen() { if (entry.client.getState() === 'connected') { fetchStats(entry.client, setStats, () => stale) fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) + fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale) } } return () => { @@ -222,25 +301,43 @@ export default function HomeScreen() { } let unsubNotif: (() => void) | null = null + let unsubAccounts: (() => void) | null = null let statsFetched = false const unsubState = client.onStateChange((state) => { if (state === 'connected') { if (!unsubNotif) { unsubNotif = subscribeToDesktopNotifications(client) } + if (!unsubAccounts) { + unsubAccounts = client.subscribe('accounts.subscribe', null, (payload) => { + if (disposed || !payload || typeof payload !== 'object') return + const evt = payload as { type?: string; snapshot?: AccountsSnapshot } + if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) { + const snap = evt.snapshot + setAccountsByHost((prev) => ({ ...prev, [host.id]: snap })) + } + }) + } if (!statsFetched) { statsFetched = true fetchStats(client, setStats, () => disposed) fetchWorktreeInfo(client, host.id, setWorktreeInfo, () => disposed) } - } else if (unsubNotif) { - unsubNotif() - unsubNotif = null + } else { + if (unsubNotif) { + unsubNotif() + unsubNotif = null + } + if (unsubAccounts) { + unsubAccounts() + unsubAccounts = null + } } }) notifCleanups.push(() => { unsubState() unsubNotif?.() + unsubAccounts?.() }) return [{ hostId: host.id, client }] @@ -259,12 +356,20 @@ export default function HomeScreen() { // Why: prefer the worktree the user last opened on this device so the // "Resume" card reflects their mobile session history, not just the // desktop's most-recently-outputting worktree. + // Why: rendering used to be gated on hostStates === 'connected', which + // caused the Resume card to vanish for ~1s on every cold-start / + // resume-from-background while the WebSocket reconnected, even though we + // had perfectly good cached worktree data. Now the card stays visible as + // long as we have a cached lastActiveWorktree for any known host; the + // tap target is still the same and a fresher snapshot from the live RPC + // overwrites the card's contents in place when it lands. const resumeWorktree = useMemo(() => { - if (lastVisited && hostStates[lastVisited.hostId] === 'connected') { + if (lastVisited && sortedHosts.some((h) => h.id === lastVisited.hostId)) { const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId) if (match) return { hostId: lastVisited.hostId, worktree: match } } + // Prefer a currently-connected host's data when we have it. for (const host of sortedHosts) { if (hostStates[host.id] !== 'connected') continue const info = worktreeInfo[host.id] @@ -272,6 +377,14 @@ export default function HomeScreen() { return { hostId: host.id, worktree: info.lastActiveWorktree } } } + // Fall back to whichever known host has cached data, regardless of + // current connection state. + for (const host of sortedHosts) { + const info = worktreeInfo[host.id] + if (info?.lastActiveWorktree) { + return { hostId: host.id, worktree: info.lastActiveWorktree } + } + } return null }, [sortedHosts, hostStates, worktreeInfo, lastVisited]) @@ -289,6 +402,23 @@ export default function HomeScreen() { [sortedHosts, hostStates, worktreeInfo] ) + // Why: only show the Account usage section for hosts that have at least + // one Claude or Codex account configured. Render whenever cached data + // exists, regardless of current connection state, so the cards don't + // disappear for ~1s on resume while the WebSocket reconnects. Streamed + // updates from the live RPC overwrite the snapshot in place when ready. + const accountsHosts = useMemo(() => { + const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = [] + for (const host of sortedHosts) { + const snap = accountsByHost[host.id] + if (!snap) continue + const hasClaude = snap.claude.accounts.length > 0 + const hasCodex = snap.codex.accounts.length > 0 + if (hasClaude || hasCodex) items.push({ host, snapshot: snap }) + } + return items + }, [sortedHosts, hostStates, accountsByHost]) + async function handleRename(newName: string) { if (!renameTarget) return try { @@ -503,6 +633,85 @@ export default function HomeScreen() { ) : null} + {/* ─── Account usage ─── */} + {accountsHosts.length > 0 ? ( + <> + + Account usage + + {accountsHosts.map(({ host, snapshot }) => { + const claudeActiveId = snapshot.claude.activeAccountId + const claudeActive = + snapshot.claude.accounts.find((a) => a.id === claudeActiveId) ?? null + const codexActiveId = snapshot.codex.activeAccountId + const codexActive = + snapshot.codex.accounts.find((a) => a.id === codexActiveId) ?? null + const showHostName = accountsHosts.length > 1 + return ( + [ + styles.accountsCard, + pressed && styles.hostCardPressed + ]} + onPress={() => router.push(`/h/${host.id}/accounts`)} + > + {showHostName ? ( + + {host.name} + + ) : null} + {(['claude', 'codex'] as ProviderKey[]).map((provider) => { + const active = provider === 'claude' ? claudeActive : codexActive + const accounts = + provider === 'claude' + ? snapshot.claude.accounts + : snapshot.codex.accounts + if (accounts.length === 0) return null + const limits = getActiveProviderRateLimits(snapshot, provider) + const isFetching = + limits?.status === 'fetching' || limits?.status === 'idle' + const unavailable = + limits == null || + limits.status === 'unavailable' || + limits.status === 'error' + return ( + + + {provider === 'claude' ? ( + + ) : ( + + )} + + + + {active?.email ?? 'System default'} + + + + + + + + ) + })} + + ) + })} + + ) : null} + {/* ─── Quick actions ─── */} Quick Actions @@ -511,7 +720,7 @@ export default function HomeScreen() { onPress={() => router.push('/pair-scan')} > - + Pair Desktop @@ -525,7 +734,7 @@ export default function HomeScreen() { }} > - + New Worktree @@ -667,20 +876,21 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: colors.borderSubtle, borderRadius: 10, - padding: spacing.md + paddingVertical: 10, + paddingHorizontal: spacing.md }, statIcon: { - width: 30, - height: 30, - borderRadius: 7, + width: 26, + height: 26, + borderRadius: 6, backgroundColor: 'rgba(255,255,255,0.04)', alignItems: 'center', justifyContent: 'center', - marginBottom: 10 + marginBottom: 6 }, statValue: { color: colors.textPrimary, - fontSize: 20, + fontSize: 18, fontWeight: '700', letterSpacing: -0.3 }, @@ -688,7 +898,7 @@ const styles = StyleSheet.create({ color: colors.textMuted, fontSize: 11, fontWeight: '500', - marginTop: 3 + marginTop: 2 }, /* ─── Section heading ─── */ @@ -817,6 +1027,53 @@ const styles = StyleSheet.create({ flex: 1 }, + /* ─── Account usage ─── */ + accountsCard: { + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + gap: spacing.sm, + marginBottom: spacing.sm + }, + accountsHostLabel: { + fontSize: 11, + color: colors.textMuted, + fontWeight: '500', + textTransform: 'uppercase', + letterSpacing: 0.4 + }, + accountsRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2 + }, + accountsIcon: { + width: 32, + height: 32, + borderRadius: 9, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center' + }, + accountsInfo: { + flex: 1, + minWidth: 0, + gap: 2 + }, + accountsEmail: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary + }, + accountsBars: { + flexDirection: 'row', + gap: spacing.md, + marginTop: 4 + }, + /* ─── Skeleton ─── */ skeletonBlock: { backgroundColor: colors.bgRaised, @@ -836,18 +1093,20 @@ const styles = StyleSheet.create({ }, quickAction: { flex: 1, + flexDirection: 'row', backgroundColor: colors.bgPanel, borderWidth: 1, borderColor: colors.borderSubtle, borderRadius: radii.card, - padding: spacing.lg, + paddingVertical: 10, + paddingHorizontal: 12, alignItems: 'center', gap: 10 }, quickActionIcon: { - width: 40, - height: 40, - borderRadius: 12, + width: 28, + height: 28, + borderRadius: 9, backgroundColor: 'rgba(255,255,255,0.04)', alignItems: 'center', justifyContent: 'center' @@ -855,8 +1114,7 @@ const styles = StyleSheet.create({ quickActionLabel: { fontSize: 12, fontWeight: '600', - color: colors.textSecondary, - textAlign: 'center' + color: colors.textSecondary }, /* ─── Empty state ─── */ diff --git a/mobile/src/cache/home-snapshot-cache.ts b/mobile/src/cache/home-snapshot-cache.ts new file mode 100644 index 000000000..57256be8e --- /dev/null +++ b/mobile/src/cache/home-snapshot-cache.ts @@ -0,0 +1,66 @@ +// Why: persist the data needed to render the home page so cold-start / +// resume-from-background paints instantly with the last known good +// values, then updates in place when fresh RPC data arrives. Without +// this, Resume and Account-usage cards flash empty for ~1s while the +// WebSocket reconnects and the first responses come back. +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { AccountsSnapshot } from '../components/AccountUsage' + +const STORAGE_KEY = 'orca:home-snapshot:v1' + +type WorktreeSummary = { + worktreeId: string + repo: string + branch: string + displayName: string + liveTerminalCount: number + status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' +} + +type HostWorktreeInfo = { + hostId: string + totalWorktrees: number + activeCount: number + lastActiveWorktree: WorktreeSummary | null +} + +export type HomeSnapshot = { + worktreeInfo: Record + accountsByHost: Record + savedAt: number +} + +let memoryCache: HomeSnapshot | null = null +let writeTimer: ReturnType | null = null + +export async function loadHomeSnapshot(): Promise { + if (memoryCache) return memoryCache + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as HomeSnapshot + if ( + typeof parsed !== 'object' || + parsed === null || + typeof parsed.worktreeInfo !== 'object' || + typeof parsed.accountsByHost !== 'object' + ) { + return null + } + memoryCache = parsed + return parsed + } catch { + return null + } +} + +// Why: throttle writes so a flurry of streamed account-snapshot updates +// (one per provider fetch finishing) doesn't hammer AsyncStorage. +export function saveHomeSnapshot(snapshot: HomeSnapshot): void { + memoryCache = snapshot + if (writeTimer) clearTimeout(writeTimer) + writeTimer = setTimeout(() => { + writeTimer = null + void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)).catch(() => {}) + }, 250) +} diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx new file mode 100644 index 000000000..0ff706638 --- /dev/null +++ b/mobile/src/components/AccountUsage.tsx @@ -0,0 +1,155 @@ +import { View, Text, StyleSheet, ActivityIndicator } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +// Why: keep these shapes in lockstep with src/shared/types.ts and +// src/shared/rate-limit-types.ts. We don't import from desktop here because +// the mobile bundle must not pull in Electron-coupled type files. +export type RateLimitWindow = { + usedPercent: number + windowMinutes: number + resetsAt: number | null + resetDescription: string | null +} + +export type ProviderRateLimits = { + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' + session: RateLimitWindow | null + weekly: RateLimitWindow | null + monthly?: RateLimitWindow | null + updatedAt: number + error: string | null + status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable' +} + +export type InactiveAccountUsage = { + accountId: string + claude: ProviderRateLimits | null + updatedAt: number + isFetching: boolean +} + +export type ClaudeAccountSummary = { + id: string + email: string + organizationName?: string | null +} + +export type CodexAccountSummary = { + id: string + email: string + workspaceLabel?: string | null +} + +export type AccountsSnapshot = { + claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null } + codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null } + rateLimits: { + claude: ProviderRateLimits | null + codex: ProviderRateLimits | null + inactiveClaudeAccounts: InactiveAccountUsage[] + inactiveCodexAccounts: InactiveAccountUsage[] + } +} + +export type ProviderKey = 'claude' | 'codex' + +export function getActiveProviderRateLimits( + snapshot: AccountsSnapshot, + provider: ProviderKey +): ProviderRateLimits | null { + return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex +} + +export function getInactiveProviderUsage( + snapshot: AccountsSnapshot, + provider: ProviderKey, + accountId: string +): InactiveAccountUsage | null { + const list = + provider === 'claude' + ? snapshot.rateLimits.inactiveClaudeAccounts + : snapshot.rateLimits.inactiveCodexAccounts + return list.find((u) => u.accountId === accountId) ?? null +} + +// Why: matches desktop StatusBar convention — bars show percent remaining +// (so a fresh account renders full, a depleted one renders empty), not +// percent used. Color thresholds invert accordingly. +export function UsageBar({ + label, + usedPercent, + unavailable, + loading +}: { + label: string + usedPercent: number | null + unavailable: boolean + loading?: boolean +}) { + const remaining = usedPercent == null ? null : Math.max(0, Math.min(100, 100 - usedPercent)) + const barColor = + remaining == null + ? colors.textMuted + : remaining <= 10 + ? colors.statusRed + : remaining <= 30 + ? colors.statusAmber + : colors.statusGreen + return ( + + {label} + + + + {loading ? ( + + ) : ( + + {unavailable || remaining == null ? '—' : `${Math.round(remaining)}%`} + + )} + + ) +} + +const styles = StyleSheet.create({ + usageBar: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + flex: 1 + }, + usageLabel: { + fontSize: typography.metaSize, + color: colors.textMuted, + width: 22 + }, + usageTrack: { + flex: 1, + height: 6, + borderRadius: 3, + backgroundColor: colors.bgRaised, + overflow: 'hidden' + }, + usageFill: { + height: '100%', + borderRadius: 3 + }, + usageValue: { + fontSize: typography.metaSize, + color: colors.textSecondary, + width: 36, + textAlign: 'right' + }, + usageSpinner: { + width: 36 + } +}) diff --git a/mobile/src/components/AgentIcons.tsx b/mobile/src/components/AgentIcons.tsx new file mode 100644 index 000000000..714fe76e0 --- /dev/null +++ b/mobile/src/components/AgentIcons.tsx @@ -0,0 +1,30 @@ +import Svg, { Path } from 'react-native-svg' +import { colors } from '../theme/mobile-theme' + +// Why: SVG paths sourced from the desktop codebase +// (src/renderer/src/components/status-bar/icons.tsx) so mobile and desktop +// stay visually identical for Claude/Codex branding. + +export function ClaudeIcon({ size = 16 }: { size?: number }) { + return ( + + + + ) +} + +export function OpenAIIcon({ size = 16, color }: { size?: number; color?: string }) { + return ( + + + + ) +} diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index f08410547..a7e7d2579 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -16,6 +16,7 @@ import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' import { colors, spacing, radii, typography } from '../theme/mobile-theme' import { BottomDrawer } from './BottomDrawer' +import { ClaudeIcon, OpenAIIcon } from './AgentIcons' import { getSuggestedCreatureName } from './worktree-name-suggestion' type Repo = { @@ -91,34 +92,10 @@ const AGENT_COMMANDS: Record = { // ── Agent icons ───────────────────────────────────────────────────── // SVG paths sourced from the desktop codebase: -// Claude & OpenAI: src/renderer/src/components/status-bar/icons.tsx +// Claude & OpenAI: shared in ./AgentIcons.tsx // Pi & Aider: src/renderer/src/lib/agent-catalog.tsx // Agents with a faviconDomain use Google's favicon service (same as desktop). -function ClaudeIcon({ size = 16 }: { size?: number }) { - return ( - - - - ) -} - -function OpenAIIcon({ size = 16 }: { size?: number }) { - return ( - - - - ) -} - function PiIcon({ size = 16 }: { size?: number }) { return ( diff --git a/src/main/index.ts b/src/main/index.ts index 790f4cb21..84553104e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -439,6 +439,7 @@ app.whenReady().then(async () => { // and defeat the teardown helper's prefix sweep (design §4.3 wire-up). getLocalProvider: () => getLocalPtyProvider() }) + runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) starNag = new StarNagService(store, stats) starNag.start() starNag.registerIpcHandlers() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index ef827a820..b90568567 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -74,9 +74,17 @@ export class RateLimitService { private inactiveCodexFetching = new Set() private lastInactiveClaudeFetchAt = 0 private lastInactiveCodexFetchAt = 0 + private stateListeners = new Set<(state: RateLimitState) => void>() constructor() {} + onStateChange(listener: (state: RateLimitState) => void): () => void { + this.stateListeners.add(listener) + return () => { + this.stateListeners.delete(listener) + } + } + setCodexHomePathResolver(resolver: () => string | null): void { this.codexHomePathResolver = resolver } @@ -757,9 +765,17 @@ export class RateLimitService { } private pushToRenderer(): void { + const state = this.getState() + for (const listener of this.stateListeners) { + try { + listener(state) + } catch { + // ignore — one bad listener must not break the others + } + } if (!this.mainWindow || this.mainWindow.isDestroyed()) { return } - this.mainWindow.webContents.send('rateLimits:update', this.getState()) + this.mainWindow.webContents.send('rateLimits:update', state) } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1af46a117..8ea72e381 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -134,6 +134,23 @@ import { HeadlessEmulator } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import type { IPtyProvider } from '../providers/types' +import type { ClaudeAccountService } from '../claude-accounts/service' +import type { CodexAccountService } from '../codex-accounts/service' +import type { RateLimitService } from '../rate-limits/service' +import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' +import type { RateLimitState } from '../../shared/rate-limit-types' + +type RuntimeAccountServices = { + claudeAccounts: ClaudeAccountService + codexAccounts: CodexAccountService + rateLimits: RateLimitService +} + +export type AccountsSnapshot = { + claude: ClaudeRateLimitAccountsState + codex: CodexRateLimitAccountsState + rateLimits: RateLimitState +} type RuntimeStore = { getRepos: Store['getRepos'] @@ -357,6 +374,12 @@ export class OrcaRuntimeService { Set<(event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void> >() private subscriptionCleanups = new Map void>() + // Why: index of subscriptionIds by per-WebSocket connectionId so the + // server can sweep all subscriptions for a closing socket without + // touching subscriptions on other live sockets that share the same + // deviceToken (multi-screen mobile). + private subscriptionsByConnection = new Map>() + private subscriptionConnectionByEntry = new Map() // Why: mobile clients subscribe to desktop notifications via // notifications.subscribe. This set enables fan-out — each connected // mobile client gets its own listener, and dispatchMobileNotification @@ -511,6 +534,7 @@ export class OrcaRuntimeService { private fetchInflight = new Map>() private fetchLastCompletedAt = new Map() private readonly getLocalProviderFn: (() => IPtyProvider) | null + private accountServices: RuntimeAccountServices | null = null constructor( store: RuntimeStore | null = null, @@ -1130,7 +1154,11 @@ export class OrcaRuntimeService { return { ptyId: leaf.ptyId } } - registerSubscriptionCleanup(subscriptionId: string, cleanup: () => void): void { + registerSubscriptionCleanup( + subscriptionId: string, + cleanup: () => void, + connectionId?: string + ): void { // Why: mobile clients reconnect frequently (phone lock, network switch). // The RPC client re-sends terminal.subscribe on reconnect, creating a new // handler before the old one is cleaned up. Without this, the old data @@ -1138,18 +1166,57 @@ export class OrcaRuntimeService { const existing = this.subscriptionCleanups.get(subscriptionId) if (existing) { existing() + // Why: existing() already evicts itself from the per-connection index + // via cleanupSubscription, so no extra bookkeeping is needed here. } this.subscriptionCleanups.set(subscriptionId, cleanup) + if (connectionId) { + let set = this.subscriptionsByConnection.get(connectionId) + if (!set) { + set = new Set() + this.subscriptionsByConnection.set(connectionId, set) + } + set.add(subscriptionId) + this.subscriptionConnectionByEntry.set(subscriptionId, connectionId) + } } cleanupSubscription(subscriptionId: string): void { const cleanup = this.subscriptionCleanups.get(subscriptionId) if (cleanup) { this.subscriptionCleanups.delete(subscriptionId) + const connectionId = this.subscriptionConnectionByEntry.get(subscriptionId) + if (connectionId) { + this.subscriptionConnectionByEntry.delete(subscriptionId) + const set = this.subscriptionsByConnection.get(connectionId) + if (set) { + set.delete(subscriptionId) + if (set.size === 0) { + this.subscriptionsByConnection.delete(connectionId) + } + } + } cleanup() } } + // Why: invoked from the WebSocket transport's on-close hook so streaming + // listeners registered for this exact socket get torn down even when other + // sockets sharing the same deviceToken are still alive (multi-screen + // mobile). Without this sweep, listeners leak across every reconnect. + cleanupSubscriptionsForConnection(connectionId: string): void { + const set = this.subscriptionsByConnection.get(connectionId) + if (!set) { + return + } + // Why: snapshot the ids before iterating because cleanupSubscription + // mutates both the set and the index map. + const ids = Array.from(set) + for (const id of ids) { + this.cleanupSubscription(id) + } + } + // Why: mobile clients subscribe via notifications.subscribe streaming RPC. // Each subscriber gets its own listener. Returns an unsubscribe function // that the subscription cleanup mechanism calls on disconnect. @@ -1170,6 +1237,73 @@ export class OrcaRuntimeService { } } + // ─── Account Services (mobile RPC bridge) ───────────────────── + + setAccountServices(services: RuntimeAccountServices): void { + this.accountServices = services + } + + private requireAccountServices(): RuntimeAccountServices { + if (!this.accountServices) { + throw new Error('Account services are not configured on this runtime') + } + return this.accountServices + } + + getAccountsSnapshot(): AccountsSnapshot { + const { claudeAccounts, codexAccounts, rateLimits } = this.requireAccountServices() + return { + claude: claudeAccounts.listAccounts(), + codex: codexAccounts.listAccounts(), + rateLimits: rateLimits.getState() + } + } + + // Why: RateLimitService polls only when the Electron window is visible AND + // focused, and the inactive-account caches fill lazily when the user opens + // the desktop AccountsPane. Mobile has neither trigger, so without this the + // phone shows 0% / "—" against a backgrounded desktop. Errors swallowed + // because partial usage is still useful for the rest of the snapshot. + async refreshAccountsForMobile(): Promise { + const { rateLimits } = this.requireAccountServices() + await Promise.allSettled([ + rateLimits.refresh(), + rateLimits.fetchInactiveClaudeAccountsOnOpen(), + rateLimits.fetchInactiveCodexAccountsOnOpen() + ]) + } + + selectClaudeAccount(accountId: string | null): Promise { + return this.requireAccountServices().claudeAccounts.selectAccount(accountId) + } + + selectCodexAccount(accountId: string | null): Promise { + return this.requireAccountServices().codexAccounts.selectAccount(accountId) + } + + removeClaudeAccount(accountId: string): Promise { + return this.requireAccountServices().claudeAccounts.removeAccount(accountId) + } + + removeCodexAccount(accountId: string): Promise { + return this.requireAccountServices().codexAccounts.removeAccount(accountId) + } + + // Why: rate-limit polling fires every 5 minutes and on account switch. + // Mobile clients subscribe to receive a fresh AccountsSnapshot whenever + // RateLimitService pushes new usage data, mirroring the existing + // `rateLimits:update` IPC channel desktop already uses. + onAccountsChanged(listener: (snapshot: AccountsSnapshot) => void): () => void { + const services = this.requireAccountServices() + return services.rateLimits.onStateChange(() => { + listener({ + claude: services.claudeAccounts.listAccounts(), + codex: services.codexAccounts.listAccounts(), + rateLimits: services.rateLimits.getState() + }) + }) + } + // ─── Mobile Fit Override Management ───────────────────────── resizeForClient( diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 9c6eab721..034056193 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -46,6 +46,12 @@ export type RpcContext = { // runtime-rpc transport (direct in-process callers don't need it). // See design doc §3.1 counter-lifecycle. signal?: AbortSignal + // Why: streaming handlers (notifications/accounts/terminal subscribe) + // register cleanup callbacks against the runtime so reconnects don't leak + // listeners. Keying those cleanups by per-WebSocket connectionId lets the + // server reap all subscriptions for a closing socket, even when other + // sockets for the same deviceToken stay alive (multi-screen mobile). + connectionId?: string } export type RpcHandler = (params: TParams, ctx: RpcContext) => Promise | unknown diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index faa8c57e3..bdd2f181b 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -74,7 +74,11 @@ export class RpcDispatcher { // 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 { + async dispatchStreaming( + request: RpcRequest, + reply: (response: string) => void, + options?: { connectionId?: string } + ): Promise { const meta = this.meta() const method = this.registry.get(request.method) if (!method) { @@ -94,7 +98,10 @@ export class RpcDispatcher { if (!isStreamingMethod(method)) { try { - const result = await method.handler(parsedParams.value, { runtime: this.runtime }) + const result = await method.handler(parsedParams.value, { + runtime: this.runtime, + connectionId: options?.connectionId + }) reply(JSON.stringify(successResponse(request.id, meta, result))) } catch (error) { reply(JSON.stringify(this.mapError(request, meta, error))) @@ -109,7 +116,11 @@ export class RpcDispatcher { } try { - await method.handler(parsedParams.value, { runtime: this.runtime }, emit) + await method.handler( + parsedParams.value, + { runtime: this.runtime, connectionId: options?.connectionId }, + emit + ) } catch (error) { reply(JSON.stringify(this.mapError(request, meta, error))) } diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts new file mode 100644 index 000000000..21612a707 --- /dev/null +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -0,0 +1,111 @@ +import { z } from 'zod' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' + +// Why: monotonically increasing per-process counter avoids the Date.now() +// collision that fired when two near-simultaneous accounts.subscribe calls +// collided on the same millisecond and one evicted the other through +// registerSubscriptionCleanup's existing-key eviction path. +let accountsSubscriptionSeq = 0 + +const SelectAccountParams = z.object({ + accountId: z + .union([z.string().min(1, 'Missing accountId'), z.null()]) + .transform((v) => (v === null ? null : v)) +}) + +const RemoveAccountParams = z.object({ + accountId: z.string().min(1, 'Missing accountId') +}) + +const AccountsUnsubscribeParams = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) + +// Why: bridges the desktop ClaudeAccountService / CodexAccountService / +// RateLimitService into the mobile WebSocket RPC. Read + switch + remove +// only — interactive add/re-auth flows spawn `claude login` / `codex login` +// PTYs that need a desktop browser, so they intentionally remain +// desktop-only. See plan in spec doc for issue #1438. +export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [ + defineMethod({ + name: 'accounts.list', + params: null, + handler: async (_params, { runtime }) => { + // Why: ensure the snapshot reflects the latest provider state before + // returning. Desktop polling pauses when the window is unfocused and + // inactive-account caches only fill on AccountsPane open, so without + // this the mobile UI would render stale nulls / zeroes. + await runtime.refreshAccountsForMobile() + return runtime.getAccountsSnapshot() + } + }), + defineMethod({ + name: 'accounts.selectClaude', + params: SelectAccountParams, + handler: async (params, { runtime }) => runtime.selectClaudeAccount(params.accountId) + }), + defineMethod({ + name: 'accounts.selectCodex', + params: SelectAccountParams, + handler: async (params, { runtime }) => runtime.selectCodexAccount(params.accountId) + }), + defineMethod({ + name: 'accounts.removeClaude', + params: RemoveAccountParams, + handler: async (params, { runtime }) => runtime.removeClaudeAccount(params.accountId) + }), + defineMethod({ + name: 'accounts.removeCodex', + params: RemoveAccountParams, + handler: async (params, { runtime }) => runtime.removeCodexAccount(params.accountId) + }), + // Why: streaming counterpart so mobile usage bars refresh in place when the + // desktop's 5-minute rate-limit poll completes or when the user switches + // accounts on either side. Mirrors the notifications.subscribe pattern. + defineStreamingMethod({ + name: 'accounts.subscribe', + params: null, + handler: async (_params, { runtime, connectionId }, emit) => { + await new Promise((resolve) => { + const unsubscribe = runtime.onAccountsChanged((snapshot) => { + emit({ type: 'snapshot', snapshot }) + }) + + // Why: scope the id by connectionId so two sockets from the same + // device (host + accounts screen) cannot evict each other through + // registerSubscriptionCleanup's "existing key" branch, and append a + // per-process counter so two concurrent subscribes on the same + // socket also can't collide. + const seq = ++accountsSubscriptionSeq + const subscriptionId = `accounts-${connectionId ?? 'inproc'}-${seq}` + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + unsubscribe() + emit({ type: 'end' }) + resolve() + }, + connectionId + ) + + // Why: emit the current snapshot synchronously so the phone has + // something to render immediately, then kick a forced refresh that + // will broadcast a fresh snapshot through the listener once each + // provider fetch completes. + emit({ type: 'ready', subscriptionId, snapshot: runtime.getAccountsSnapshot() }) + void runtime.refreshAccountsForMobile() + }) + } + }), + defineMethod({ + name: 'accounts.unsubscribe', + params: AccountsUnsubscribeParams, + handler: async (params, { runtime }) => { + runtime.cleanupSubscription(params.subscriptionId) + return { unsubscribed: true } + } + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index cf3fbf34f..bce5a50c4 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -8,6 +8,7 @@ import { BROWSER_EXTRA_METHODS } from './browser-extras' import { ORCHESTRATION_METHODS } from './orchestration' import { NOTIFICATION_METHODS } from './notifications' import { STATS_METHODS } from './stats' +import { ACCOUNT_METHODS } from './accounts' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when @@ -21,5 +22,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...BROWSER_EXTRA_METHODS, ...ORCHESTRATION_METHODS, ...NOTIFICATION_METHODS, - ...STATS_METHODS + ...STATS_METHODS, + ...ACCOUNT_METHODS ] diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts index 607474bb9..1d63f26d7 100644 --- a/src/main/runtime/rpc/methods/notifications.ts +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -1,6 +1,11 @@ import { z } from 'zod' import { defineStreamingMethod, defineMethod, type RpcAnyMethod } from '../core' +// Why: monotonically increasing per-process counter eliminates the +// Date.now() collision that could fire when two near-simultaneous +// notifications.subscribe calls landed on the same millisecond. +let notificationsSubscriptionSeq = 0 + const NotificationUnsubscribeParams = z.object({ subscriptionId: z .unknown() @@ -16,18 +21,25 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [ defineStreamingMethod({ name: 'notifications.subscribe', params: null, - handler: async (_params, { runtime }, emit) => { + handler: async (_params, { runtime, connectionId }, emit) => { await new Promise((resolve) => { const unsubscribe = runtime.onNotificationDispatched((event) => { emit({ type: 'notification', ...event }) }) - const subscriptionId = `notifications-${Date.now()}` - runtime.registerSubscriptionCleanup(subscriptionId, () => { - unsubscribe() - emit({ type: 'end' }) - resolve() - }) + // Why: scope by per-ws connectionId + per-process counter so + // concurrent subscribes never collide on the cleanup map. + const seq = ++notificationsSubscriptionSeq + const subscriptionId = `notifications-${connectionId ?? 'inproc'}-${seq}` + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + unsubscribe() + emit({ type: 'end' }) + resolve() + }, + connectionId + ) emit({ type: 'ready', subscriptionId }) }) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 218bdfe28..2d5881324 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -28,7 +28,9 @@ export class WebSocketTransport implements RpcTransport { private messageHandler: | ((msg: string, reply: (response: string) => void, ws: WebSocket) => void) | null = null - private connectionCloseHandler: ((clientId: string) => void) | null = null + private connectionCloseHandler: + | ((clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void) + | null = null // Why: maps each WebSocket to the clientId (deviceToken) that authenticated it, // so ws.on('close') can notify the runtime which mobile client disconnected. private wsClientIds = new Map() @@ -46,7 +48,15 @@ export class WebSocketTransport implements RpcTransport { this.messageHandler = handler } - onConnectionClose(handler: (clientId: string) => void): void { + // Why: handlers receive the closing `ws` so per-connection state can be + // targeted exactly (one paired device may hold multiple concurrent sockets, + // e.g. host screen + accounts screen). `hasOtherConnections` tells the + // runtime whether other sockets for the same deviceToken are still alive, + // so client-scoped teardown (mobile-fit overrides, etc.) only fires on the + // last disconnect. + onConnectionClose( + handler: (clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void + ): void { this.connectionCloseHandler = handler } @@ -178,7 +188,13 @@ export class WebSocketTransport implements RpcTransport { const clientId = this.wsClientIds.get(ws) this.wsClientIds.delete(ws) if (clientId) { - this.connectionCloseHandler?.(clientId) + // Why: a paired device may have multiple concurrent sockets open + // (e.g. one per app screen). Per-client teardown must only fire when + // the last socket for this token closes — otherwise closing the + // accounts-screen socket would clobber the host-screen socket's + // state and strand it in a non-functional state until re-paired. + const hasOtherConnections = Array.from(this.wsClientIds.values()).includes(clientId) + this.connectionCloseHandler?.(clientId, ws, hasOtherConnections) } }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index b2e4cf276..c1f946e69 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -88,6 +88,10 @@ export class OrcaRuntimeRpcServer { // Why: each WebSocket connection has its own E2EE channel that manages the // handshake and encrypt/decrypt lifecycle. Keyed by WebSocket instance. private e2eeChannels = new Map() + // Why: stable per-WebSocket id used as the cleanup key for streaming + // subscriptions, so the server can reap a closing socket's subscriptions + // without affecting other live sockets that share the same deviceToken. + private wsConnectionIds = new Map() // Why: separate from Node's server.maxConnections because we need to count // only long-running dispatches, not every in-flight short RPC. See §3.1 + // §7 risk #2. @@ -220,6 +224,11 @@ export class OrcaRuntimeRpcServer { wsTransport.onMessage((msg, _reply, ws) => { let channel = this.e2eeChannels.get(ws) if (!channel) { + // Why: stable per-ws id used as the cleanup-index key for + // streaming subscriptions, so the server can reap them exactly + // when this socket closes (without affecting other live sockets + // that share the same deviceToken). + this.wsConnectionIds.set(ws, randomBytes(8).toString('hex')) channel = new E2EEChannel(ws, { serverSecretKey: this.e2eeKeypair!.secretKey, validateToken: (token) => this.deviceRegistry?.validateToken(token) != null, @@ -251,16 +260,28 @@ export class OrcaRuntimeRpcServer { // Why: when a mobile client disconnects, the runtime must clean up // connection-scoped state like mobile-fit overrides and the E2EE - // channel to prevent orphaned state. - wsTransport.onConnectionClose((clientId) => { - for (const [ws, channel] of this.e2eeChannels) { - if (channel.deviceToken === clientId) { - channel.destroy() - this.e2eeChannels.delete(ws) - break - } + // channel to prevent orphaned state. A single paired device can hold + // multiple concurrent sockets (host screen + accounts screen, etc.), + // so destroy the channel for THIS exact ws and skip the per-client + // teardown when other sockets for the same token are still alive. + wsTransport.onConnectionClose((clientId, ws, hasOtherConnections) => { + // Why: sweep streaming subscriptions for THIS ws regardless of + // hasOtherConnections, so per-ws listeners (notifications, + // accounts, terminal) don't leak across reconnects. This is + // independent of the deviceToken-scoped onClientDisconnected. + const connectionId = this.wsConnectionIds.get(ws) + if (connectionId) { + this.runtime.cleanupSubscriptionsForConnection(connectionId) + this.wsConnectionIds.delete(ws) + } + const channel = this.e2eeChannels.get(ws) + if (channel) { + channel.destroy() + this.e2eeChannels.delete(ws) + } + if (!hasOtherConnections) { + this.runtime.onClientDisconnected(clientId) } - this.runtime.onClientDisconnected(clientId) }) await wsTransport.start() @@ -429,7 +450,8 @@ export class OrcaRuntimeRpcServer { wsTransport.setClientId(ws, token) } - await this.dispatcher.dispatchStreaming(request, reply) + const connectionId = ws ? this.wsConnectionIds.get(ws) : undefined + await this.dispatcher.dispatchStreaming(request, reply, { connectionId }) } private buildError(id: string, code: string, message: string): RpcResponse {