Merge branch 'fix-storage' into fix-integrate

This commit is contained in:
Jinwoo-H
2026-09-07 04:48:37 -04:00
23 changed files with 710 additions and 464 deletions
+42 -204
View File
@@ -1,39 +1,18 @@
import { ConnectionDiagnosticsView } from '../src/diagnostics/connection-diagnostics-view'
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { View, Text, Pressable, Platform } from 'react-native'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { View, Text, Pressable } from 'react-native'
import { useLocalSearchParams, useRouter } from 'expo-router'
import * as Clipboard from 'expo-clipboard'
import Constants from 'expo-constants'
import { loadHosts } from '../src/transport/host-store'
import { connectionLogStore } from '../src/transport/persisted-connection-log-store'
import { useHostClient, useRpcClientContext } from '../src/transport/client-context'
import {
useConnectionPathStatus,
useReconnectAttempt
} from '../src/transport/client-context-connection-metrics'
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
import { mobileWebDiagnosticsStore } from '../src/mobile-web/mobile-web-diagnostics-store'
import {
diagnoseConnection,
getReportableConnectionIncidentId
} from '../src/diagnostics/connection-diagnostics-analysis'
import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission'
import {
readHydratedConnectionLog,
readConnectionDiagnosticsSnapshot,
resolveDiagnosticsHostId,
getDiagnosticsSubmissionState,
updateDiagnosticsSubmissionState,
type DiagnosticsSubmissionStates
} from '../src/diagnostics/connection-diagnostics-screen-data'
import { useHostStatusGates } from '../src/transport/host-status-gates'
import { loadHostAppVersion } from '../src/transport/host-app-version-store'
import { ConnectionDiagnosticsScreen } from '../src/diagnostics/connection-diagnostics-screen'
import { createNativeDiagnosticsOperations } from '../src/diagnostics/native-diagnostics-operations'
import {
resolveDiagnosticsHostId,
type DiagnosticsHostSelection
} from '../src/diagnostics/connection-diagnostics-screen-data'
import { connectionDiagnosticsScreenStyles as styles } from '../src/diagnostics/connection-diagnostics-screen-styles'
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
// Why: getSnapshot must be referentially stable when there's no data —
// a fresh [] per call would make useSyncExternalStore re-render forever.
const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
import type { HostProfile } from '../src/transport/types'
// Why: reading the log is most needed while a host is failing, so this
// screen also *acquires* the host client — opening it kicks a dial and the
@@ -44,21 +23,14 @@ export default function ConnectionLogScreen() {
const params = useLocalSearchParams<{ hostId?: string }>()
const routeKey = useMemo(() => ({}), [params.hostId])
const [hosts, setHosts] = useState<HostProfile[]>([])
const [manualSelection, setManualSelection] = useState<{
hostId: string
requestedHostId: string | undefined
routeKey: object
} | null>(null)
const [copiedHostId, setCopiedHostId] = useState<string | null>(null)
const [submissionStates, setSubmissionStates] = useState<DiagnosticsSubmissionStates>({})
const [manualSelection, setManualSelection] = useState<DiagnosticsHostSelection | null>(null)
useEffect(() => {
let stale = false
void loadHosts().then((loaded) => {
if (stale) {
return
if (!stale) {
setHosts(loaded)
}
setHosts(loaded)
})
return () => {
stale = true
@@ -66,179 +38,45 @@ export default function ConnectionLogScreen() {
}, [])
const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey)
const selected = hosts.find((h) => h.id === selectedId) ?? null
const selected = hosts.find((host) => host.id === selectedId) ?? null
const { client, state } = useHostClient(selected?.id)
const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({
hostId: selected?.id,
client,
connState: state
})
const reconnectAttempts = useReconnectAttempt(selected?.id)
const { activePath, pendingPath } = useConnectionPathStatus(selected?.id)
useEffect(() => {
if (selectedId) {
void readHydratedConnectionLog(connectionLogStore, selectedId)
}
}, [selectedId])
const subscribe = useCallback(
(listener: () => void) =>
selectedId ? connectionLogStore.subscribe(selectedId, listener) : () => {},
[selectedId]
// Why: refreshes the persisted desktop version the diagnostics report reads back.
useHostStatusGates({ hostId: selected?.id, client, connState: state })
const device = useMemo(
() => (selected ? createNativeDiagnosticsOperations(selected, clientContext) : null),
[selected, clientContext]
)
const getSnapshot = useCallback(
() => (selectedId ? connectionLogStore.get(selectedId) : EMPTY_ENTRIES),
[selectedId]
const select = useCallback(
(hostId: string) => setManualSelection({ hostId, requestedHostId: params.hostId, routeKey }),
[params.hostId, routeKey]
)
const entries = useSyncExternalStore(subscribe, getSnapshot)
const subscribeMobileWeb = useCallback(
(listener: () => void) => mobileWebDiagnosticsStore.subscribe(listener),
[]
)
const getMobileWebSnapshot = useCallback(
() => mobileWebDiagnosticsStore.get(selectedId),
[selectedId]
)
const mobileWebDiagnostics = useSyncExternalStore(subscribeMobileWeb, getMobileWebSnapshot)
const diagnosis = selected
? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries })
: null
const incidentId = selected
? getReportableConnectionIncidentId({
endpoint: selected.endpoint,
state,
activePath,
pendingPath,
entries
})
: null
const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null
const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey)
const copied = copiedHostId === selectedId
const copyDiagnostics = useCallback(async () => {
if (!selected) {
return
}
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
const snapshot = await readConnectionDiagnosticsSnapshot(
clientContext,
connectionLogStore,
selected.id
)
const report = buildConnectionDiagnosticsReport({
endpoint: selected.endpoint,
state: snapshot.state,
reconnectAttempts: snapshot.reconnectAttempts,
lastConnectedAt: snapshot.lastConnectedAt,
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
appVersion: Constants.expoConfig?.version ?? 'unknown',
desktopAppVersion,
entries: snapshot.entries,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath,
mobileWeb: mobileWebDiagnostics
})
await Clipboard.setStringAsync(report)
setCopiedHostId(selected.id)
setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000)
}, [selected, liveDesktopAppVersion, clientContext, mobileWebDiagnostics])
const sendDiagnostics = useCallback(async () => {
if (!selected || !submissionKey || submissionState === 'sending') {
return
}
const startedKey = submissionKey
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending'))
const appVersion = Constants.expoConfig?.version ?? 'unknown'
const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim()
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
const snapshot = await readConnectionDiagnosticsSnapshot(
clientContext,
connectionLogStore,
selected.id
)
const currentIncidentId = getReportableConnectionIncidentId({
endpoint: selected.endpoint,
state: snapshot.state,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath,
entries: snapshot.entries
})
if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) {
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null))
return
}
const report = buildConnectionDiagnosticsReport({
endpoint: selected.endpoint,
state: snapshot.state,
reconnectAttempts: snapshot.reconnectAttempts,
lastConnectedAt: snapshot.lastConnectedAt,
platform,
appVersion,
desktopAppVersion,
entries: snapshot.entries,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath,
mobileWeb: mobileWebDiagnostics
})
const result = await submitConnectionDiagnostics({ report, appVersion, platform })
setSubmissionStates((states) =>
updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed')
)
}, [
selected,
submissionKey,
submissionState,
liveDesktopAppVersion,
clientContext,
mobileWebDiagnostics
])
return (
<ConnectionDiagnosticsView
<ConnectionDiagnosticsScreen
key={selectedId ?? 'none'}
device={device}
hostName={selected?.name ?? null}
state={state}
reconnectAttempts={reconnectAttempts}
copied={copied}
copyDiagnostics={copyDiagnostics}
diagnosis={diagnosis}
submissionState={submissionState}
sendDiagnostics={sendDiagnostics}
entries={entries}
writeClipboard={(report) => Clipboard.setStringAsync(report)}
onBack={() => router.back()}
hostPicker={
<>
{' '}
{hosts.length > 1 && (
<View style={styles.hostPicker}>
{hosts.map((host) => (
<Pressable
key={host.id}
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
onPress={() =>
setManualSelection({
hostId: host.id,
requestedHostId: params.hostId,
routeKey
})
}
hosts.length > 1 ? (
<View style={styles.hostPicker}>
{hosts.map((host) => (
<Pressable
key={host.id}
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
onPress={() => select(host.id)}
>
<Text
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
numberOfLines={1}
>
<Text
style={[
styles.hostChipText,
host.id === selectedId && styles.hostChipTextActive
]}
numberOfLines={1}
>
{host.name}
</Text>
</Pressable>
))}
</View>
)}
</>
{host.name}
</Text>
</Pressable>
))}
</View>
) : null
}
/>
)
+3 -31
View File
@@ -1,19 +1,9 @@
import { useCallback, useRef, useState } from 'react'
import { View, Text, StyleSheet, Pressable, Linking, ActivityIndicator } from 'react-native'
import { useFocusEffect, useRouter } from 'expo-router'
import {
Info,
Bell,
Wrench,
Shield,
LifeBuoy,
Mic,
Globe,
MessageSquare,
Terminal as TerminalIcon,
KeyRound
} from 'lucide-react-native'
import { Shield, LifeBuoy, KeyRound } from 'lucide-react-native'
import { MobileSettingsFrame, MobileSettingsSection } from '../src/settings/mobile-settings-menu'
import { mobileSettingsMenuItems } from '../src/settings/mobile-settings-menu-items'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import {
loadPendingHostCredentialCleanup,
@@ -83,25 +73,7 @@ export default function SettingsScreen() {
return (
<MobileSettingsFrame>
<MobileSettingsSection
items={[
{
label: 'Terminal',
icon: TerminalIcon,
onPress: () => router.push('/terminal-settings')
},
{
label: 'Chat UI',
icon: MessageSquare,
onPress: () => router.push('/native-chat-settings')
},
{ label: 'Browser', icon: Globe, onPress: () => router.push('/browser-settings') },
{ label: 'Voice', icon: Mic, onPress: () => router.push('/voice-settings') },
{ label: 'Notifications', icon: Bell, onPress: () => router.push('/notifications') },
{ label: 'Troubleshooting', icon: Wrench, onPress: () => router.push('/troubleshoot') },
{ label: 'About', icon: Info, onPress: () => router.push('/about') }
]}
/>
<MobileSettingsSection items={mobileSettingsMenuItems((route) => router.push(route))} />
{showCredentialCleanup ? (
<View style={[styles.section, styles.sectionSpacer]}>
+6 -3
View File
@@ -1,6 +1,6 @@
import { useRouter } from 'expo-router'
import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel'
import { HostedConnectionDiagnosticsScreen } from '../src/diagnostics/hosted-connection-diagnostics-screen'
import { ConnectionDiagnosticsScreen } from '../src/diagnostics/connection-diagnostics-screen'
export default function HostedConnectionLogRoute() {
const router = useRouter()
@@ -8,10 +8,13 @@ export default function HostedConnectionLogRoute() {
if (!shell.client) {
return null
}
const client = shell.client
return (
<HostedConnectionDiagnosticsScreen
<ConnectionDiagnosticsScreen
key={shell.context?.shellSessionId ?? 'pending'}
client={shell.client}
device={client.native.diagnosticsDevice}
hostName="Paired desktop"
writeClipboard={(report) => client.native.clipboardWrite(report)}
onBack={() => (router.canGoBack() ? router.back() : router.replace('/settings'))}
/>
)
+6 -53
View File
@@ -3,24 +3,14 @@ import { useState } from 'react'
import { Text } from 'react-native'
import { colors, typography, spacing } from '../src/theme/mobile-theme'
import { useRouter } from 'expo-router'
import {
Globe,
MessageSquare,
Terminal,
Mic,
Bell,
Activity,
Info,
Shield,
LifeBuoy
} from 'lucide-react-native'
import { Shield, LifeBuoy } from 'lucide-react-native'
import { MobileSettingsFrame, MobileSettingsSection } from '../src/settings/mobile-settings-menu'
import { mobileSettingsMenuItems } from '../src/settings/mobile-settings-menu-items'
export default function HostedSettingsRoute() {
const router = useRouter()
const [linkError, setLinkError] = useState<string | null>(null)
const shell = useMobileWebNativeShell()
const disabled = !(shell.client?.native.supports('pagePreferences') ?? false)
const linksDisabled = !(shell.client?.native.supports('openExternal') ?? false)
const openExternal = (url: string) => {
setLinkError(null)
@@ -39,47 +29,10 @@ export default function HostedSettingsRoute() {
}}
>
<MobileSettingsSection
items={[
{
label: 'Terminal',
icon: Terminal,
disabled: !shell.client,
onPress: () => {
router.push('/terminal-settings')
}
},
{
label: 'Chat UI',
disabled,
icon: MessageSquare,
onPress: () => router.push('/native-chat-settings')
},
{
label: 'Browser',
disabled,
icon: Globe,
onPress: () => router.push('/browser-settings')
},
{
label: 'Voice',
icon: Mic,
disabled: !shell.client,
onPress: () => router.push('/voice-settings')
},
{
label: 'Notifications',
icon: Bell,
disabled: !shell.client,
onPress: () => router.push('/notifications')
},
{
label: 'Troubleshooting',
icon: Activity,
disabled: !shell.client,
onPress: () => router.push('/troubleshoot')
},
{ label: 'About', icon: Info, onPress: () => router.push('/about') }
]}
items={mobileSettingsMenuItems((route) => router.push(route), {
shell: Boolean(shell.client),
pagePreferences: shell.client?.native.supports('pagePreferences') ?? false
})}
/>
<MobileSettingsSection
spaced
@@ -0,0 +1,106 @@
import { createElement, useEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ConnectionDiagnosticsScreen } from './connection-diagnostics-screen'
import type { DiagnosticsDeviceOperations } from './diagnostics-device-operations'
import type { DiagnosticsSnapshot } from '../../../src/shared/mobile-web/diagnostics-device-contract'
vi.mock('react-native', () => ({
View: 'View',
Text: 'Text',
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 }
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0 })
}))
vi.mock('expo-router', () => ({
useFocusEffect: (callback: () => void | (() => void)) => useEffect(callback, [callback])
}))
vi.mock('lucide-react-native', () => ({
ChevronLeft: 'Icon',
Copy: 'Icon',
Check: 'Icon',
Send: 'Icon'
}))
vi.mock('../components/ConnectionLog', () => ({ ConnectionLog: () => null }))
const SNAPSHOT: DiagnosticsSnapshot = {
state: 'disconnected',
reconnectAttempts: 3,
lastConnectedAt: null,
activePath: 'lan',
pendingPath: null,
endpointIsTailscale: false,
platform: 'ios 18',
appVersion: '1.0.0',
desktopAppVersion: '2.0.0',
entries: [],
mobileWeb: {}
}
let renderer: ReactTestRenderer | undefined
afterEach(() => {
act(() => renderer?.unmount())
renderer = undefined
})
function device(overrides: Partial<DiagnosticsDeviceOperations> = {}) {
return {
snapshot: vi.fn().mockResolvedValue(SNAPSHOT),
probe: vi.fn().mockResolvedValue({ reachable: true }),
submit: vi.fn().mockResolvedValue({ ok: true }),
...overrides
} as unknown as DiagnosticsDeviceOperations
}
async function mount(props: Parameters<typeof ConnectionDiagnosticsScreen>[0]) {
await act(async () => {
renderer = create(createElement(ConnectionDiagnosticsScreen, props))
})
}
function texts(): string[] {
return renderer!.root
.findAllByType('Text')
.flatMap((node) => node.children.filter((child): child is string => typeof child === 'string'))
}
describe('shared connection diagnostics screen', () => {
it('reports either host through one device operations seam', async () => {
const write = vi.fn().mockResolvedValue(undefined)
const operations = device()
await mount({
device: operations,
hostName: 'Desk',
writeClipboard: write,
onBack: () => {}
})
expect(texts()).toContain('disconnected')
expect(texts()).toContain(' · attempt 3')
expect(operations.snapshot).toHaveBeenCalled()
await act(async () => {
await renderer!.root.findAllByType('Pressable')[1]!.props.onPress()
})
expect(write).toHaveBeenCalledWith(expect.stringContaining('2.0.0'))
})
it('surfaces a device failure instead of a permanent loading state', async () => {
await mount({
device: device({ snapshot: vi.fn().mockRejectedValue(new Error('unreachable')) }),
hostName: 'Desk',
writeClipboard: vi.fn(),
onBack: () => {}
})
expect(texts().some((text) => text.includes('Could not load network diagnostics'))).toBe(true)
})
it('renders no host rather than loading when no host is selected', async () => {
await mount({ device: null, hostName: null, writeClipboard: vi.fn(), onBack: () => {} })
expect(texts()).not.toContain('Loading network diagnostics…')
})
})
@@ -1,7 +1,6 @@
import { useCallback, useState } from 'react'
import { useCallback, useState, type ReactNode } from 'react'
import { Text } from 'react-native'
import { useFocusEffect } from 'expo-router'
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
import type { DiagnosticsSnapshot } from '../../../src/shared/mobile-web/diagnostics-device-contract'
import type { ConnectionLogEntry } from '../transport/types'
import type { MobileWebDiagnosticsSnapshot } from '../mobile-web/mobile-web-diagnostics-store'
@@ -17,21 +16,40 @@ import {
type DiagnosticsSubmissionStates
} from './connection-diagnostics-screen-data'
import { connectionDiagnosticsScreenStyles as styles } from './connection-diagnostics-screen-styles'
import type { DiagnosticsDeviceOperations } from './diagnostics-device-operations'
export function HostedConnectionDiagnosticsScreen({
client,
onBack
function reportable(snapshot: DiagnosticsSnapshot) {
return {
...snapshot,
entries: snapshot.entries as ConnectionLogEntry[],
mobileWeb: snapshot.mobileWeb as MobileWebDiagnosticsSnapshot
}
}
// Why: reading the log matters most while a host is failing, so the screen
// re-polls the device instead of rendering a snapshot taken on mount.
export function ConnectionDiagnosticsScreen({
device,
hostName,
writeClipboard,
onBack,
hostPicker
}: {
client: MobileWebBridgeClient
device: DiagnosticsDeviceOperations | null
hostName: string | null
writeClipboard: (report: string) => Promise<unknown>
onBack: () => void
hostPicker?: ReactNode
}) {
const device = client.native.diagnosticsDevice
const [snapshot, setSnapshot] = useState<DiagnosticsSnapshot | null>(null)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [submissions, setSubmissions] = useState<DiagnosticsSubmissionStates>({})
useFocusEffect(
useCallback(() => {
if (!device) {
return
}
let active = true
let pending = false
const refresh = async () => {
@@ -61,44 +79,31 @@ export function HostedConnectionDiagnosticsScreen({
}
}, [device])
)
const data = snapshot
? {
...snapshot,
entries: snapshot.entries as ConnectionLogEntry[],
mobileWeb: snapshot.mobileWeb as MobileWebDiagnosticsSnapshot
}
: null
const data = snapshot ? reportable(snapshot) : null
const diagnosis = data ? diagnoseConnection(data) : null
const incident = data ? getReportableConnectionIncidentId(data) : null
const submissionState = getDiagnosticsSubmissionState(submissions, incident)
const copyDiagnostics = async () => {
if (!device) {
return
}
try {
const fresh = await device.snapshot()
await client.native.clipboardWrite(
buildConnectionDiagnosticsReport({
...fresh,
entries: fresh.entries as ConnectionLogEntry[],
mobileWeb: fresh.mobileWeb as MobileWebDiagnosticsSnapshot
})
)
await writeClipboard(buildConnectionDiagnosticsReport(reportable(await device.snapshot())))
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
setError('Could not copy the report. Try again.')
}
}
const sendDiagnostics = async () => {
if (!incident || submissionState === 'sending') {
if (!device || !incident || submissionState === 'sending') {
return
}
const started = incident
setSubmissions((states) => updateDiagnosticsSubmissionState(states, started, 'sending'))
try {
const fresh = await device.snapshot()
const reportData = {
...fresh,
entries: fresh.entries as ConnectionLogEntry[],
mobileWeb: fresh.mobileWeb as MobileWebDiagnosticsSnapshot
}
const reportData = reportable(fresh)
if (getReportableConnectionIncidentId(reportData) !== started) {
setSubmissions((states) => updateDiagnosticsSubmissionState(states, started, null))
return
@@ -117,8 +122,8 @@ export function HostedConnectionDiagnosticsScreen({
}
return (
<ConnectionDiagnosticsView
loading={!snapshot}
hostName="Paired desktop"
loading={Boolean(device) && !snapshot}
hostName={hostName}
state={snapshot?.state ?? 'disconnected'}
reconnectAttempts={snapshot?.reconnectAttempts ?? 0}
entries={data?.entries ?? []}
@@ -129,11 +134,14 @@ export function HostedConnectionDiagnosticsScreen({
sendDiagnostics={sendDiagnostics}
onBack={onBack}
hostPicker={
error ? (
<Text accessibilityRole="alert" style={styles.emptyText}>
{error}
</Text>
) : null
<>
{hostPicker}
{error ? (
<Text accessibilityRole="alert" style={styles.emptyText}>
{error}
</Text>
) : null}
</>
}
/>
)
@@ -22,6 +22,47 @@ describe('hosted page preference adapter', () => {
])
})
it('folds concurrent reads of one tick into a single bridge request', async () => {
const pagePreferences = vi.fn(
async (payload: { action: string; keys?: string[] }): Promise<unknown> =>
payload.action === 'read'
? { entries: payload.keys!.map((key) => [key, `${key}-value`]) }
: { updated: true }
)
setMobileWebPagePreferencesClient({
native: { pagePreferences }
} as unknown as MobileWebBridgeClient)
const keys = ['a', 'b', 'c', 'd', 'e']
expect(await Promise.all(keys.map((key) => storage.getItem(key)))).toEqual(
keys.map((key) => `${key}-value`)
)
expect(pagePreferences.mock.calls).toEqual([
[{ namespace: 'expo.preferences', action: 'read', keys }]
])
})
it('keeps writes ordered after reads and splits past the request key limit', async () => {
const pagePreferences = vi.fn(
async (payload: { action: string; keys?: string[] }): Promise<unknown> =>
payload.action === 'read'
? { entries: (payload.keys ?? []).map((key) => [key, null]) }
: { updated: true }
)
setMobileWebPagePreferencesClient({
native: { pagePreferences }
} as unknown as MobileWebBridgeClient)
const many = Array.from({ length: 70 }, (_, index) => `k${index}`)
await Promise.all([storage.getItem('a'), storage.setItem('b', '1'), storage.multiGet(many)])
expect(
pagePreferences.mock.calls.map(([payload]) => [payload.action, payload.keys?.length ?? 1])
).toEqual([
['read', 1],
['write', 1],
['read', 64],
['read', 6]
])
})
it('fails clearly on old shells rather than pretending writes persisted', async () => {
const error = new Error('unsupported_capability')
setMobileWebPagePreferencesClient({
@@ -34,10 +75,12 @@ describe('hosted page preference adapter', () => {
it('discards a response after document replacement', async () => {
const pending = Promise.withResolvers<unknown>()
const pagePreferences = vi.fn(() => pending.promise)
setMobileWebPagePreferencesClient({
native: { pagePreferences: () => pending.promise }
native: { pagePreferences }
} as unknown as MobileWebBridgeClient)
const read = storage.getItem('setting')
await vi.waitFor(() => expect(pagePreferences).toHaveBeenCalled())
setMobileWebPagePreferencesClient(null)
pending.resolve({ entries: [['setting', 'from-old-host']] })
await expect(read).rejects.toMatchObject({ code: 'cancelled' })
@@ -1,7 +1,129 @@
import { requestMobileWebPagePreferences } from '../../../src/mobile-web/src/mobile-web-page-preferences-channel'
import type {
MobileWebPagePreferencesPayload,
MobileWebPagePreferencesResult
} from '../../../src/shared/mobile-web/page-preferences-contract'
type Callback<T = void> = (error: Error | null, result?: T) => void
type Action = MobileWebPagePreferencesPayload['action']
type Operation = {
action: Action
keys: readonly string[]
entries: readonly [string, string][]
settle: (result: MobileWebPagePreferencesResult) => void
reject: (error: unknown) => void
}
const namespace = 'expo.preferences'
// Keys/entries per request accepted by MobileWebPagePreferencesPayloadSchema.
const maxBatchItems = 64
const queue: Operation[] = []
let scheduled = false
let draining = false
function chunk<Item>(items: readonly Item[]): Item[][] {
const parts: Item[][] = []
for (let index = 0; index < items.length; index += maxBatchItems) {
parts.push(items.slice(index, index + maxBatchItems))
}
return parts
}
function enqueue<T>(
request: { action: Action; keys?: readonly string[]; entries?: readonly [string, string][] },
settle: (result: MobileWebPagePreferencesResult) => T
): Promise<T> {
return new Promise<T>((resolve, reject) => {
queue.push({
action: request.action,
keys: request.keys ?? [],
entries: request.entries ?? [],
settle: (result) => resolve(settle(result)),
reject
})
if (!scheduled && !draining) {
scheduled = true
queueMicrotask(() => {
scheduled = false
void drain()
})
}
})
}
// Adjacent same-action calls fold into one bridge request so a screen's
// concurrent preference reads stay inside the shell's request grant.
function takeBatch(): Operation[] {
const first = queue.shift()!
const batch = [first]
if (first.action === 'keys' || first.action === 'clear') {
return batch
}
let items = first.keys.length + first.entries.length
while (queue[0]?.action === first.action) {
const next = queue[0]!
const combined = items + next.keys.length + next.entries.length
if (combined > maxBatchItems) {
break
}
items = combined
batch.push(queue.shift()!)
}
return batch
}
function buildPayload(batch: readonly Operation[]): MobileWebPagePreferencesPayload {
const first = batch[0]!
if (first.action === 'keys' || first.action === 'clear') {
return { namespace, action: first.action }
}
if (first.action === 'write') {
const entries = new Map<string, string>()
for (const operation of batch) {
for (const [key, value] of operation.entries) {
entries.set(key, value)
}
}
return { namespace, action: 'write', entries: [...entries] }
}
const keys = new Set<string>()
for (const operation of batch) {
for (const key of operation.keys) {
keys.add(key)
}
}
return first.action === 'read'
? { namespace, action: 'read', keys: [...keys] }
: { namespace, action: 'remove', keys: [...keys] }
}
async function drain(): Promise<void> {
if (draining) {
return
}
draining = true
try {
while (queue.length > 0) {
const batch = takeBatch()
try {
const result = await requestMobileWebPagePreferences(buildPayload(batch))
for (const operation of batch) {
try {
operation.settle(result)
} catch (error: unknown) {
operation.reject(error)
}
}
} catch (error: unknown) {
for (const operation of batch) {
operation.reject(error)
}
}
}
} finally {
draining = false
}
}
function callbackResult<T>(work: Promise<T>, callback?: Callback<T>): Promise<T> {
return work.then(
@@ -16,22 +138,30 @@ function callbackResult<T>(work: Promise<T>, callback?: Callback<T>): Promise<T>
}
)
}
async function multiGet(keys: readonly string[]): Promise<[string, string | null][]> {
const result = await requestMobileWebPagePreferences({
namespace,
action: 'read',
keys: [...keys]
})
function readEntries(result: MobileWebPagePreferencesResult, keys: readonly string[]) {
if (!('entries' in result)) {
throw new Error('Invalid page preference response')
}
return result.entries
const values = new Map(result.entries)
return keys.map((key): [string, string | null] => [key, values.get(key) ?? null])
}
async function multiGet(keys: readonly string[]): Promise<[string, string | null][]> {
const parts = await Promise.all(
chunk(keys).map((part) =>
enqueue({ action: 'read', keys: part }, (result) => readEntries(result, part))
)
)
return parts.flat()
}
async function multiSet(entries: readonly [string, string][]): Promise<void> {
await requestMobileWebPagePreferences({ namespace, action: 'write', entries: [...entries] })
await Promise.all(
chunk(entries).map((part) => enqueue({ action: 'write', entries: part }, () => {}))
)
}
async function multiRemove(keys: readonly string[]): Promise<void> {
await requestMobileWebPagePreferences({ namespace, action: 'remove', keys: [...keys] })
await Promise.all(chunk(keys).map((part) => enqueue({ action: 'remove', keys: part }, () => {})))
}
const storage = {
getItem(key: string, callback?: Callback<string | null>) {
@@ -48,13 +178,13 @@ const storage = {
},
clear(callback?: Callback) {
return callbackResult(
requestMobileWebPagePreferences({ namespace, action: 'clear' }).then(() => {}),
enqueue({ action: 'clear' }, () => {}),
callback
)
},
getAllKeys(callback?: Callback<string[]>) {
return callbackResult(
requestMobileWebPagePreferences({ namespace, action: 'keys' }).then((result) => {
enqueue({ action: 'keys' }, (result) => {
if (!('keys' in result)) {
throw new Error('Invalid page preference response')
}
@@ -84,7 +214,9 @@ const storage = {
callback
)
},
flushGetRequests() {}
flushGetRequests() {
void drain()
}
}
export function useAsyncStorage(key: string) {
return {
@@ -5,6 +5,10 @@ import { describe, expect, it } from 'vitest'
const nativeLayout = readFileSync(new URL('../../app/_layout.tsx', import.meta.url), 'utf8')
const nativeSettings = readFileSync(new URL('../../app/settings.tsx', import.meta.url), 'utf8')
const settingsMenuItems = readFileSync(
new URL('../settings/mobile-settings-menu-items.ts', import.meta.url),
'utf8'
)
const nativeHome = readFileSync(new URL('../home/MobileHomeHostList.tsx', import.meta.url), 'utf8')
const hybridShell = readFileSync(new URL('../../app/hybrid.tsx', import.meta.url), 'utf8')
const hybridPresentation = readFileSync(
@@ -43,8 +47,9 @@ describe('mobile native shell route ownership', () => {
for (const routeName of NATIVE_ROUTE_NAMES) {
expect(nativeLayout).toContain(`name="${routeName}"`)
}
expect(nativeSettings).toContain("router.push('/troubleshoot')")
expect(nativeSettings).toContain("router.push('/about')")
expect(settingsMenuItems).toContain("push('/troubleshoot')")
expect(settingsMenuItems).toContain("push('/about')")
expect(nativeSettings).toContain('mobileSettingsMenuItems((route) => router.push(route))')
expect(nativeSettings).toContain("Linking.openURL('https://www.onorca.dev/privacy')")
})
@@ -83,6 +83,38 @@ describe('bounded host-scoped page preferences', () => {
expect(f.values.get(mobileWebPagePreferencesStorageKey('limited'))).toBe(before)
})
it('keeps a reset and a read working over a corrupt or oversized blob', async () => {
const f = fixture()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const key = mobileWebPagePreferencesStorageKey('broken')
f.values.set(key, 'bad-json')
expect(
await f.run('broken', { namespace: 'settings', action: 'read', keys: ['a', 'b'] })
).toEqual({
entries: [
['a', null],
['b', null]
]
})
expect(f.values.get(key)).toBe('bad-json')
await expect(f.run('broken', { namespace: 'settings', action: 'clear' })).resolves.toEqual({
updated: true
})
await expect(
f.run('broken', { namespace: 'settings', action: 'write', entries: [['a', 'saved']] })
).resolves.toEqual({ updated: true })
f.values.set(key, JSON.stringify([['settings', [['a', 'x'.repeat(3 * 1024 * 1024)]]]]))
expect(await f.run('broken', { namespace: 'settings', action: 'read', keys: ['a'] })).toEqual({
entries: [['a', null]]
})
await expect(f.run('broken', { namespace: 'settings', action: 'clear' })).resolves.toEqual({
updated: true
})
expect(warn).toHaveBeenCalledOnce()
warn.mockRestore()
})
it('does not overwrite unreadable storage and recovers after a failed write', async () => {
const f = fixture()
const key = mobileWebPagePreferencesStorageKey('broken')
@@ -21,6 +21,11 @@ export function mobileWebPagePreferencesStorageKey(hostIdentity: string): string
return `orca:hosted-page-preferences:v1:${hash}`
}
// Unpairing owns no page session, so the blob can go without the request queue.
export async function deleteMobileWebPagePreferences(hostIdentity: string): Promise<void> {
await AsyncStorage.removeItem(mobileWebPagePreferencesStorageKey(hostIdentity))
}
export function runMobileWebPagePreferences(
hostIdentity: string,
input: MobileWebPagePreferencesPayload,
@@ -53,21 +58,19 @@ async function applyPreferences(
storage: Storage
): Promise<MobileWebPagePreferencesResult> {
const raw = await storage.getItem(key)
if (raw && byteLength(raw) > MOBILE_WEB_PAGE_PREFERENCES_MAX_BYTES) {
throw new MobileWebBrokerError('too_large')
}
let stored
try {
stored = MobileWebPagePreferencesStoredSchema.parse(raw ? JSON.parse(raw) : [])
} catch {
throw new MobileWebBrokerError('unavailable')
}
const namespaces = new Map(stored.map(([namespace, entries]) => [namespace, new Map(entries)]))
if (
namespaces.size !== stored.length ||
stored.some(([, entries]) => new Map(entries).size !== entries.length)
) {
throw new MobileWebBrokerError('invalid_message')
const oversized = raw !== null && byteLength(raw) > MOBILE_WEB_PAGE_PREFERENCES_MAX_BYTES
const namespaces = oversized ? null : readNamespaces(raw)
if (!namespaces) {
// A reset and a read must survive a blob this device can no longer use.
if (payload.action === 'clear') {
await storage.setItem(key, '[]')
return { updated: true }
}
if (payload.action === 'read') {
warnUnreadablePreferences()
return { entries: payload.keys.map((entryKey) => [entryKey, null]) }
}
throw new MobileWebBrokerError(oversized ? 'too_large' : 'unavailable')
}
const values = namespaces.get(payload.namespace) ?? new Map<string, string>()
if (payload.action === 'read') {
@@ -107,6 +110,28 @@ async function applyPreferences(
return { updated: true }
}
function readNamespaces(raw: string | null): Map<string, Map<string, string>> | null {
let stored
try {
stored = MobileWebPagePreferencesStoredSchema.parse(raw ? JSON.parse(raw) : [])
} catch {
return null
}
const namespaces = new Map(stored.map(([namespace, entries]) => [namespace, new Map(entries)]))
const duplicated =
namespaces.size !== stored.length ||
stored.some(([, entries]) => new Map(entries).size !== entries.length)
return duplicated ? null : namespaces
}
let warnedUnreadablePreferences = false
function warnUnreadablePreferences(): void {
if (!warnedUnreadablePreferences) {
warnedUnreadablePreferences = true
console.warn('[mobile-web] page preferences unreadable — serving empty values')
}
}
function byteLength(value: string): number {
return new TextEncoder().encode(value).byteLength
}
@@ -0,0 +1,46 @@
import { Bell, Globe, Info, MessageSquare, Mic, Terminal, Wrench } from 'lucide-react-native'
import type { MobileSettingsMenuItem } from './mobile-settings-menu'
// The hosted route reaches these screens through the shell, so a missing shell
// or page-preference capability disables the rows that depend on it.
export type MobileSettingsMenuAvailability = {
shell?: boolean
pagePreferences?: boolean
}
export function mobileSettingsMenuItems(
push: (route: string) => void,
availability: MobileSettingsMenuAvailability = {}
): MobileSettingsMenuItem[] {
const shell = availability.shell ?? true
const preferences = availability.pagePreferences ?? true
return [
{
label: 'Terminal',
icon: Terminal,
disabled: !shell,
onPress: () => push('/terminal-settings')
},
{
label: 'Chat UI',
icon: MessageSquare,
disabled: !preferences,
onPress: () => push('/native-chat-settings')
},
{
label: 'Browser',
icon: Globe,
disabled: !preferences,
onPress: () => push('/browser-settings')
},
{ label: 'Voice', icon: Mic, disabled: !shell, onPress: () => push('/voice-settings') },
{ label: 'Notifications', icon: Bell, disabled: !shell, onPress: () => push('/notifications') },
{
label: 'Troubleshooting',
icon: Wrench,
disabled: !shell,
onPress: () => push('/troubleshoot')
},
{ label: 'About', icon: Info, onPress: () => push('/about') }
]
}
+2 -2
View File
@@ -37,7 +37,7 @@ export function MobileSettingsFrame({
)
}
type SettingsMenuItem = {
export type MobileSettingsMenuItem = {
label: string
icon: LucideIcon
onPress: () => void
@@ -49,7 +49,7 @@ export function MobileSettingsSection({
items,
spaced = false
}: {
items: SettingsMenuItem[]
items: MobileSettingsMenuItem[]
spaced?: boolean
}) {
return (
@@ -3,12 +3,15 @@ import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-w
import { webVoiceSettingsOperations } from './web-voice-settings-operations'
describe('hosted voice settings', () => {
it('requires configuration acknowledgement without replaying a mismatched receipt', async () => {
it('returns the desktop receipt for a configuration change without a second request', async () => {
const request = vi.fn().mockResolvedValue({ enabled: false, models: [] })
const operations = webVoiceSettingsOperations({
host: { request }
} as unknown as MobileWebBridgeClient)
await expect(operations.configure({ enabled: true })).rejects.toThrow('not confirmed')
await expect(operations.configure({ enabled: true })).resolves.toEqual({
enabled: false,
models: []
})
expect(request).toHaveBeenCalledOnce()
})
it('does not replay an ambiguous model mutation through native speech', async () => {
@@ -7,17 +7,8 @@ export function webVoiceSettingsOperations(client: MobileWebBridgeClient): Voice
client.host.request({ method, params })
return {
load: async () => (await request('speech.models.list', {})) as MobileSpeechSetup,
configure: async (params) => {
const result = (await request('speech.dictation.setup', params)) as MobileSpeechSetup
if (
(params.enabled !== undefined && result.enabled !== params.enabled) ||
(params.modelId !== undefined && result.selectedModelId !== params.modelId) ||
(params.dictationMode !== undefined && result.dictationMode !== params.dictationMode)
) {
throw new Error('Voice settings update was not confirmed')
}
return result
},
configure: async (params) =>
(await request('speech.dictation.setup', params)) as MobileSpeechSetup,
download: async (modelId) => {
await request('speech.models.download', { modelId })
},
@@ -1,44 +1,38 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nativeTerminalSettingsHost } from './native-terminal-settings-host'
import {
webTerminalSettingsHost,
webTerminalSettingsOperations
} from './web-terminal-settings-operations'
import hostedPageStorage from '../mobile-web/hosted-page-async-storage'
import { setMobileWebPagePreferencesClient } from '../../../src/mobile-web/src/mobile-web-page-preferences-channel'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: vi.fn(async () => null) }
}))
afterEach(() => setMobileWebPagePreferencesClient(null))
describe('terminal restore settings adapters', () => {
it('shares the single native accessory-read slot across settings sections', async () => {
const client = {
native: {
supports: () => true,
terminalAccessoryPreferences: vi.fn().mockResolvedValue({
customKeys: [],
orderedBuiltInIds: ['escape'],
visibleBuiltInIds: []
})
}
}
const settings = webTerminalSettingsOperations(client as unknown as MobileWebBridgeClient)
await Promise.all([settings.loadKeys(), settings.loadLayout()])
expect(client.native.terminalAccessoryPreferences).toHaveBeenCalledTimes(1)
})
it('loads all settings sections within the page preference concurrency grant', async () => {
// The page bundle resolves async storage to the hosted bridge adapter.
vi.mocked(AsyncStorage.getItem).mockImplementation((key: string) =>
hostedPageStorage.getItem(key)
)
let inFlight = 0
vi.mocked(AsyncStorage.getItem).mockImplementation(async () => {
const pagePreferences = vi.fn(async (payload: { keys?: string[] }) => {
if (++inFlight > 4) {
inFlight--
throw new Error('page preference concurrency exhausted')
}
await new Promise((resolve) => setTimeout(resolve, 1))
inFlight--
return null
return { entries: (payload.keys ?? []).map((key) => [key, null]) }
})
setMobileWebPagePreferencesClient({
native: { pagePreferences }
} as unknown as MobileWebBridgeClient)
const client = {
native: {
terminalPreferences: async () => ({
@@ -58,6 +52,7 @@ describe('terminal restore settings adapters', () => {
await expect(
Promise.all([settings.loadPreferences(), settings.loadKeys(), settings.loadLayout()])
).resolves.toHaveLength(3)
expect(pagePreferences.mock.calls.length).toBeLessThan(4)
} finally {
vi.mocked(AsyncStorage.getItem).mockImplementation(async () => null)
}
@@ -59,11 +59,11 @@ describe('terminal settings state', () => {
expect(state.busy).toBe(false)
expect(state.autocompleteEnabled).toBe(true)
})
it('keeps controls disabled when storage cannot be read', async () => {
it('releases the controls when storage cannot be read', async () => {
const operations = fixture()
vi.mocked(operations.loadPreferences).mockRejectedValue(new Error('storage unavailable'))
await mount(operations)
expect(state.busy).toBe(true)
expect(state.busy).toBe(false)
expect(state.error).toContain('Could not load')
})
it('preserves the confirmed setting when saving fails', async () => {
@@ -35,13 +35,17 @@ export function useTerminalSettingsState(
}
setTextScale(preferences.textScale)
setAutocompleteEnabled(preferences.autocompleteEnabled)
setBusy(false)
})
.catch(() => {
if (active) {
setError('Could not load terminal preferences. Go back and try again.')
}
})
.finally(() => {
if (active) {
setBusy(false)
}
})
return () => {
active = false
mounted.current = false
+12 -12
View File
@@ -10,22 +10,22 @@ import { loadTerminalAccessoryLayout } from './terminal-accessory-layout'
export async function loadWebHostTerminalPreferences(client: MobileWebBridgeClient) {
const native = await client.native.terminalPreferences()
// Settings sections mount together; keep their combined reads below the bridge grant.
const textScale = await loadTerminalTextScale({
fallback: native.textScale,
rejectReadFailure: true
})
const autocompleteEnabled = await loadTerminalAutocompleteEnabled({
fallback: native.autocompleteEnabled,
rejectReadFailure: true
})
const linkOpenMode = await loadTerminalLinkOpenMode(native.linkOpenMode)
const [textScale, autocompleteEnabled, linkOpenMode] = await Promise.all([
loadTerminalTextScale({ fallback: native.textScale, rejectReadFailure: true }),
loadTerminalAutocompleteEnabled({
fallback: native.autocompleteEnabled,
rejectReadFailure: true
}),
loadTerminalLinkOpenMode(native.linkOpenMode)
])
return { textScale: textScale as MobileWebTerminalTextScale, autocompleteEnabled, linkOpenMode }
}
export async function loadWebHostTerminalAccessoryPreferences(client: MobileWebBridgeClient) {
const native = await client.native.terminalAccessoryPreferences()
const customKeys = await loadCustomKeys({ fallback: native.customKeys, rejectReadFailure: true })
const layout = await loadTerminalAccessoryLayout({ fallback: native, rejectReadFailure: true })
const [customKeys, layout] = await Promise.all([
loadCustomKeys({ fallback: native.customKeys, rejectReadFailure: true }),
loadTerminalAccessoryLayout({ fallback: native, rejectReadFailure: true })
])
return {
customKeys,
orderedBuiltInIds: layout.orderedBuiltInIds,
@@ -12,19 +12,11 @@ import {
export function webTerminalSettingsOperations(
client: MobileWebBridgeClient
): TerminalSettingsOperations {
let accessoryRead: ReturnType<typeof loadWebHostTerminalAccessoryPreferences> | undefined
const readAccessories = () => {
// The two settings sections share the shell's single accessory-read slot.
accessoryRead ??= loadWebHostTerminalAccessoryPreferences(client).finally(() => {
accessoryRead = undefined
})
return accessoryRead
}
return {
...nativeTerminalSettingsOperations,
loadPreferences: () => loadWebHostTerminalPreferences(client),
loadKeys: async () => (await readAccessories()).customKeys,
loadLayout: readAccessories
loadKeys: async () => (await loadWebHostTerminalAccessoryPreferences(client)).customKeys,
loadLayout: () => loadWebHostTerminalAccessoryPreferences(client)
}
}
const fitMethods = ['terminal.getAutoRestoreFit', 'terminal.setAutoRestoreFit']
@@ -0,0 +1,32 @@
import * as hostListLoads from './host-list-load-sharing'
import { readStoredHostProfilesForMutation, writeStoredHostProfiles } from './host-metadata-store'
import type { StoredHostProfile } from './types'
// Why: serialize host metadata RMW so concurrent writers cannot drop updates.
let hostListMutation: Promise<void> = Promise.resolve()
// Why: writers hold the chain across their full RMW; readers wait so a load doesn't race a half-written list.
export function waitForHostListMutations(): Promise<void> {
return hostListMutation
}
export function enqueueHostListMutation(operation: () => Promise<void>): Promise<void> {
const mutation = hostListMutation.then(operation)
hostListMutation = mutation.catch(() => {})
return mutation
}
export function mutateStoredHosts(
update: (hosts: StoredHostProfile[]) => StoredHostProfile[] | Promise<StoredHostProfile[]>
): Promise<void> {
return enqueueHostListMutation(async () => {
const current = await readStoredHostProfilesForMutation()
await writeStoredHostProfiles(await update(current))
hostListLoads.dropSharedHostListLoad()
})
}
/** Test-only: drain the module mutation chain between cases. */
export function resetHostListMutationQueueForTests(): void {
hostListMutation = Promise.resolve()
}
@@ -0,0 +1,69 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mobileWebPagePreferencesStorageKey } from '../mobile-web/mobile-web-page-preferences-store'
import { removeHost, resetHostStoreForTests } from './host-store'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }
}))
vi.mock('expo-secure-store', () => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn(),
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY'
}))
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('./host-credential-cleanup', () => ({
cancelPendingHostCredentialCleanup: vi.fn(),
recordHostCredentialCleanupIntent: vi.fn(),
scheduleHostCredentialCleanup: vi.fn(),
retryPendingHostCredentialCleanups: vi.fn()
}))
const HOST = {
id: 'host-1',
name: 'Desk',
endpoint: 'ws://127.0.0.1:1',
publicKeyB64: 'pk',
lastConnected: 1
}
describe('unpairing device-local host state', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset().mockResolvedValue(undefined)
vi.mocked(AsyncStorage.removeItem).mockReset().mockResolvedValue(undefined)
resetHostStoreForTests()
})
it('drops the removed pairing hosted page preferences', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify([HOST]))
await removeHost(HOST.id)
expect(AsyncStorage.removeItem).toHaveBeenCalledWith(
mobileWebPagePreferencesStorageKey(HOST.publicKeyB64)
)
})
it('keeps preferences a remaining host with the same pairing key still owns', async () => {
const sibling = { ...HOST, id: 'host-2', name: 'Laptop' }
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify([HOST, sibling]))
await removeHost(sibling.id)
expect(AsyncStorage.removeItem).not.toHaveBeenCalledWith(
mobileWebPagePreferencesStorageKey(HOST.publicKeyB64)
)
})
it('keeps the removal authoritative when the preference delete fails', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify([HOST]))
vi.mocked(AsyncStorage.removeItem).mockRejectedValue(new Error('storage unavailable'))
await expect(removeHost(HOST.id)).resolves.toBeUndefined()
})
})
+46 -49
View File
@@ -1,3 +1,4 @@
import { deleteMobileWebPagePreferences } from '../mobile-web/mobile-web-page-preferences-store'
import { HostProfileSchema } from './types'
import type { HostCatalogEntry, HostProfile, StoredHostProfile } from './types'
import { getNextHostNameFromHosts } from './host-names'
@@ -27,9 +28,9 @@ import { createUnpairedHostCredentialDeletion } from './unpaired-host-credential
import {
loadStoredHostProfiles,
readStoredHostProfilesForMutation,
toStoredHostProfile,
writeStoredHostProfiles
toStoredHostProfile
} from './host-metadata-store'
import * as hostListMutations from './host-list-mutation-queue'
async function commitDeviceToken(hostId: string, token: string): Promise<void> {
markHostCredentialWrite(hostId)
@@ -40,16 +41,12 @@ async function commitDeviceToken(hostId: string, token: string): Promise<void> {
// Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove.
const tokenCache = new Map<string, string>()
// Why: serialize host metadata RMW so concurrent writers cannot drop updates.
let hostListMutation: Promise<void> = Promise.resolve()
export const loadHosts = async (): Promise<HostProfile[]> => (await loadHostListSnapshot()).profiles
export const loadHostCatalog = async (): Promise<HostCatalogEntry[]> =>
(await loadHostListSnapshot()).catalog
async function loadHostListSnapshot(): Promise<hostListLoads.HostListSnapshot> {
// Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list.
await hostListMutation
await hostListMutations.waitForHostListMutations()
// Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass.
return hostListLoads.shareHostListLoad(doLoadHostListSnapshot)
}
@@ -85,7 +82,7 @@ export async function resolvePairingHostIdentity(
newHostId: string
): Promise<{ id: string; name: string }> {
// Why: one durable read both preserves an existing identity and names a new host, avoiding duplicate cards.
await hostListMutation
await hostListMutations.waitForHostListMutations()
const hosts = await readStoredHostProfilesForMutation()
const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64)
return match
@@ -94,7 +91,7 @@ export async function resolvePairingHostIdentity(
}
const deleteUnpairedHostCredentials = createUnpairedHostCredentialDeletion({
waitForHostMutations: () => hostListMutation,
waitForHostMutations: hostListMutations.waitForHostListMutations,
hasStoredHost: async (hostId) =>
(await readStoredHostProfilesForMutation()).some(({ id }) => id === hostId),
onDeleted: (hostId) => {
@@ -111,36 +108,33 @@ function scheduleUnpairedHostCredentialCleanup(hostId: string): Promise<void> {
}
function cancelCleanupForStoredHost(hostId: string): void {
const cancellation = hostListMutation.then(async () => {
const hosts = await readStoredHostProfilesForMutation()
if (hosts.some(({ id }) => id === hostId)) {
// Register before later removals enqueue their intent, without blocking host loads on cleanup storage.
void cancelPendingHostCredentialCleanup(hostId).catch(() => undefined)
}
})
hostListMutation = cancellation.catch(() => {})
void hostListMutations
.enqueueHostListMutation(async () => {
const hosts = await readStoredHostProfilesForMutation()
if (hosts.some(({ id }) => id === hostId)) {
// Register before later removals enqueue their intent, without blocking host loads on cleanup storage.
void cancelPendingHostCredentialCleanup(hostId).catch(() => undefined)
}
})
.catch(() => undefined)
}
async function cancelCleanupForDurablyStoredHosts(hostIds: Iterable<string>): Promise<void> {
const targets = [...hostIds]
return enqueueHostListMutation(async () => {
const storedIds = new Set((await readStoredHostProfilesForMutation()).map(({ id }) => id))
await Promise.all(
targets
.filter((hostId) => storedIds.has(hostId))
.map((hostId) => cancelPendingHostCredentialCleanup(hostId).catch(() => undefined))
)
}).catch(() => undefined)
}
function enqueueHostListMutation(operation: () => Promise<void>): Promise<void> {
const mutation = hostListMutation.then(operation)
hostListMutation = mutation.catch(() => {})
return mutation
return hostListMutations
.enqueueHostListMutation(async () => {
const storedIds = new Set((await readStoredHostProfilesForMutation()).map(({ id }) => id))
await Promise.all(
targets
.filter((hostId) => storedIds.has(hostId))
.map((hostId) => cancelPendingHostCredentialCleanup(hostId).catch(() => undefined))
)
})
.catch(() => undefined)
}
function removeOrphanOverlayIfUnpaired(hostId: string): Promise<void> {
return enqueueHostListMutation(async () => {
return hostListMutations.enqueueHostListMutation(async () => {
const hosts = await readStoredHostProfilesForMutation()
if (!hosts.some(({ id }) => id === hostId)) {
await removeMobileRelayHostOverlay(hostId)
@@ -148,17 +142,6 @@ function removeOrphanOverlayIfUnpaired(hostId: string): Promise<void> {
})
}
async function mutateStoredHosts(
update: (hosts: StoredHostProfile[]) => StoredHostProfile[] | Promise<StoredHostProfile[]>
): Promise<void> {
return enqueueHostListMutation(async () => {
const current = await readStoredHostProfilesForMutation()
const next = await update(current)
await writeStoredHostProfiles(next)
hostListLoads.dropSharedHostListLoad()
})
}
export class MobileRelayUpgradeHostRemovedError extends Error {}
export const saveHost = (host: HostProfile): Promise<void> => persistHost(host, false)
@@ -174,7 +157,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
let cleanupIntentRecordedBeforeMetadata = false
let tokenCommittedBeforeMetadata = false
try {
await mutateStoredHosts(async (hosts) => {
await hostListMutations.mutateStoredHosts(async (hosts) => {
const index = hosts.findIndex((h) => h.id === stored.id)
for (const candidate of hosts) {
if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) {
@@ -256,15 +239,22 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
export async function removeHost(hostId: string): Promise<void> {
let cleanupIntentRecorded = false
let orphanedPublicKeyB64: string | null = null
try {
await mutateStoredHosts(async (hosts) => {
await hostListMutations.mutateStoredHosts(async (hosts) => {
try {
await recordHostCredentialCleanupIntent(hostId)
cleanupIntentRecorded = true
} catch {
// Removal remains authoritative when cleanup intent storage is unavailable.
}
return hosts.filter((h) => h.id !== hostId)
const removedKey = hosts.find((h) => h.id === hostId)?.publicKeyB64
const remaining = hosts.filter((h) => h.id !== hostId)
// Hosted page preferences are keyed by a pairing key a sibling row may still hold.
if (removedKey && !remaining.some((h) => h.publicKeyB64 === removedKey)) {
orphanedPublicKeyB64 = removedKey
}
return remaining
})
} catch (error) {
if (cleanupIntentRecorded) {
@@ -279,6 +269,13 @@ export async function removeHost(hostId: string): Promise<void> {
} catch {
// Base removal is authoritative; a retained overlay can't resurrect the host and is cleaned on a later retry.
}
try {
if (orphanedPublicKeyB64) {
await deleteMobileWebPagePreferences(orphanedPublicKeyB64)
}
} catch {
// Base removal is authoritative; an orphaned preference blob is inert without the pairing.
}
// Why: keychain delete can stall/reject; await only the durable cleanup intent so removeHost can't freeze the UI.
try {
await scheduleUnpairedHostCredentialCleanup(hostId)
@@ -302,7 +299,7 @@ export async function updateHostNameAndEndpoint(
hostId: string,
updates: { name?: string; endpoint?: string }
): Promise<void> {
await mutateStoredHosts((hosts) => {
await hostListMutations.mutateStoredHosts((hosts) => {
const index = hosts.findIndex((host) => host.id === hostId)
if (index === -1) {
throw new Error('Host not found')
@@ -319,7 +316,7 @@ export async function updateHostNameAndEndpoint(
export async function updateLastConnected(hostId: string): Promise<void> {
try {
await mutateStoredHosts((hosts) => {
await hostListMutations.mutateStoredHosts((hosts) => {
const index = hosts.findIndex((h) => h.id === hostId)
if (index === -1) {
return hosts
@@ -335,7 +332,7 @@ export async function updateLastConnected(hostId: string): Promise<void> {
/** Test-only: drain module mutation chain between cases. */
export function resetHostStoreForTests(): void {
hostListMutation = Promise.resolve()
hostListMutations.resetHostListMutationQueueForTests()
tokenCache.clear()
resetHostCredentialWriteRevisionsForTests()
hostListLoads.dropSharedHostListLoad()