feat(app): bootstrap default localhost connection once

This commit is contained in:
Mohamed Boudra
2026-02-12 20:31:18 +07:00
parent d771e17efd
commit 6d4d74fa3a
2 changed files with 382 additions and 200 deletions
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import {
hostHasDirectEndpoint,
registryHasDirectEndpoint,
type HostProfile,
} from './daemon-registry-context'
function makeHost(input: Partial<HostProfile> & Pick<HostProfile, 'serverId'>): HostProfile {
const now = '2026-01-01T00:00:00.000Z'
return {
serverId: input.serverId,
label: input.label ?? input.serverId,
connections: input.connections ?? [],
preferredConnectionId: input.preferredConnectionId ?? null,
createdAt: input.createdAt ?? now,
updatedAt: input.updatedAt ?? now,
}
}
describe('hostHasDirectEndpoint', () => {
it('returns true when host has matching direct endpoint', () => {
const host = makeHost({
serverId: 'srv_local',
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }],
preferredConnectionId: 'direct:localhost:6767',
})
expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(true)
})
it('returns false when only relay connections exist', () => {
const host = makeHost({
serverId: 'srv_relay',
connections: [
{
id: 'relay:relay.example:443',
type: 'relay',
relayEndpoint: 'relay.example:443',
daemonPublicKeyB64: 'abcd',
},
],
preferredConnectionId: 'relay:relay.example:443',
})
expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(false)
})
})
describe('registryHasDirectEndpoint', () => {
it('returns true when any host contains the direct endpoint', () => {
const hosts: HostProfile[] = [
makeHost({
serverId: 'srv_one',
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }],
preferredConnectionId: 'direct:127.0.0.1:7777',
}),
makeHost({
serverId: 'srv_two',
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }],
preferredConnectionId: 'direct:localhost:6767',
}),
]
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(true)
})
it('returns false when no host has the endpoint', () => {
const hosts: HostProfile[] = [
makeHost({
serverId: 'srv_one',
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }],
preferredConnectionId: 'direct:127.0.0.1:7777',
}),
]
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
})
})
@@ -1,97 +1,131 @@
import { createContext, useCallback, useContext } from "react"; import { createContext, useCallback, useContext, useEffect, useRef } from 'react'
import type { ReactNode } from "react"; import type { ReactNode } from 'react'
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from '@react-native-async-storage/async-storage'
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from '@tanstack/react-query'
import { import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
decodeOfferFragmentPayload, import { probeConnection } from '@/utils/test-daemon-connection'
normalizeHostPort, import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
} from "@/utils/daemon-endpoints";
import {
ConnectionOfferSchema,
type ConnectionOffer,
} from "@server/shared/connection-offer";
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry"; const REGISTRY_STORAGE_KEY = '@paseo:daemon-registry'
const DAEMON_REGISTRY_QUERY_KEY = ["daemon-registry"]; const DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry']
const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767'
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1'
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500
const E2E_STORAGE_KEY = '@paseo:e2e'
export type DirectHostConnection = { export type DirectHostConnection = {
id: string; id: string
type: "direct"; type: 'direct'
endpoint: string; // host:port endpoint: string // host:port
};
export type RelayHostConnection = {
id: string;
type: "relay";
relayEndpoint: string; // host:port
daemonPublicKeyB64: string;
};
export type HostConnection = DirectHostConnection | RelayHostConnection;
export type HostProfile = {
serverId: string;
label: string;
connections: HostConnection[];
preferredConnectionId: string | null;
createdAt: string;
updatedAt: string;
};
export type UpdateHostInput = Partial<Omit<HostProfile, "serverId" | "createdAt">>;
interface DaemonRegistryContextValue {
daemons: HostProfile[];
isLoading: boolean;
error: unknown | null;
upsertDirectConnection: (input: {
serverId: string;
endpoint: string;
label?: string;
}) => Promise<HostProfile>;
upsertRelayConnection: (input: {
serverId: string;
relayEndpoint: string;
daemonPublicKeyB64: string;
label?: string;
}) => Promise<HostProfile>;
updateHost: (serverId: string, updates: UpdateHostInput) => Promise<void>;
removeHost: (serverId: string) => Promise<void>;
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
upsertDaemonFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>;
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
} }
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null); export type RelayHostConnection = {
id: string
type: 'relay'
relayEndpoint: string // host:port
daemonPublicKeyB64: string
}
export type HostConnection = DirectHostConnection | RelayHostConnection
export type HostProfile = {
serverId: string
label: string
connections: HostConnection[]
preferredConnectionId: string | null
createdAt: string
updatedAt: string
}
export type UpdateHostInput = Partial<Omit<HostProfile, 'serverId' | 'createdAt'>>
interface DaemonRegistryContextValue {
daemons: HostProfile[]
isLoading: boolean
error: unknown | null
upsertDirectConnection: (input: {
serverId: string
endpoint: string
label?: string
}) => Promise<HostProfile>
upsertRelayConnection: (input: {
serverId: string
relayEndpoint: string
daemonPublicKeyB64: string
label?: string
}) => Promise<HostProfile>
updateHost: (serverId: string, updates: UpdateHostInput) => Promise<void>
removeHost: (serverId: string) => Promise<void>
removeConnection: (serverId: string, connectionId: string) => Promise<void>
upsertDaemonFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>
}
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null)
function normalizeEndpointOrNull(endpoint: string): string | null {
try {
return normalizeHostPort(endpoint)
} catch {
return null
}
}
function isDefaultLocalhostConnection(connection: HostConnection): boolean {
return connection.type === 'direct' && connection.endpoint === DEFAULT_LOCALHOST_ENDPOINT
}
export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
const normalized = normalizeEndpointOrNull(endpoint)
if (!normalized) {
return false
}
return host.connections.some(
(connection) => connection.type === 'direct' && connection.endpoint === normalized
)
}
export function registryHasDirectEndpoint(hosts: HostProfile[], endpoint: string): boolean {
return hosts.some((host) => hostHasDirectEndpoint(host, endpoint))
}
export function useDaemonRegistry(): DaemonRegistryContextValue { export function useDaemonRegistry(): DaemonRegistryContextValue {
const ctx = useContext(DaemonRegistryContext); const ctx = useContext(DaemonRegistryContext)
if (!ctx) { if (!ctx) {
throw new Error("useDaemonRegistry must be used within DaemonRegistryProvider"); throw new Error('useDaemonRegistry must be used within DaemonRegistryProvider')
} }
return ctx; return ctx
} }
export function DaemonRegistryProvider({ children }: { children: ReactNode }) { export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient()
const { data: daemons = [], isPending, error } = useQuery({ const localhostBootstrapAttemptedRef = useRef(false)
const {
data: daemons = [],
isPending,
error,
} = useQuery({
queryKey: DAEMON_REGISTRY_QUERY_KEY, queryKey: DAEMON_REGISTRY_QUERY_KEY,
queryFn: loadDaemonRegistryFromStorage, queryFn: loadDaemonRegistryFromStorage,
staleTime: Infinity, staleTime: Infinity,
gcTime: Infinity, gcTime: Infinity,
}); })
const persist = useCallback( const persist = useCallback(
async (profiles: HostProfile[]) => { async (profiles: HostProfile[]) => {
queryClient.setQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles); queryClient.setQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles)
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles)); await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles))
}, },
[queryClient] [queryClient]
); )
const readDaemons = useCallback(() => { const readDaemons = useCallback(() => {
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons; return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons
}, [queryClient, daemons]); }, [queryClient, daemons])
const markDefaultLocalhostBootstrapHandled = useCallback(async () => {
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
}, [])
const updateHost = useCallback( const updateHost = useCallback(
async (serverId: string, updates: UpdateHostInput) => { async (serverId: string, updates: UpdateHostInput) => {
@@ -103,68 +137,78 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
: daemon : daemon
); )
await persist(next); await persist(next)
}, },
[persist, readDaemons] [persist, readDaemons]
); )
const removeHost = useCallback( const removeHost = useCallback(
async (serverId: string) => { async (serverId: string) => {
const remaining = readDaemons().filter((daemon) => daemon.serverId !== serverId); const existing = readDaemons()
await persist(remaining); const removedHost = existing.find((daemon) => daemon.serverId === serverId) ?? null
const remaining = existing.filter((daemon) => daemon.serverId !== serverId)
await persist(remaining)
if (removedHost && hostHasDirectEndpoint(removedHost, DEFAULT_LOCALHOST_ENDPOINT)) {
await markDefaultLocalhostBootstrapHandled()
}
}, },
[persist, readDaemons] [markDefaultLocalhostBootstrapHandled, persist, readDaemons]
); )
const removeConnection = useCallback( const removeConnection = useCallback(
async (serverId: string, connectionId: string) => { async (serverId: string, connectionId: string) => {
const now = new Date().toISOString(); const existing = readDaemons()
const next = readDaemons() const removedConnection =
existing
.find((daemon) => daemon.serverId === serverId)
?.connections.find((connection) => connection.id === connectionId) ?? null
const now = new Date().toISOString()
const next = existing
.map((daemon) => { .map((daemon) => {
if (daemon.serverId !== serverId) return daemon; if (daemon.serverId !== serverId) return daemon
const remaining = daemon.connections.filter((conn) => conn.id !== connectionId); const remaining = daemon.connections.filter((conn) => conn.id !== connectionId)
if (remaining.length === 0) { if (remaining.length === 0) {
return null; return null
} }
const preferred = const preferred =
daemon.preferredConnectionId === connectionId daemon.preferredConnectionId === connectionId
? remaining[0]?.id ?? null ? (remaining[0]?.id ?? null)
: daemon.preferredConnectionId; : daemon.preferredConnectionId
return { return {
...daemon, ...daemon,
connections: remaining, connections: remaining,
preferredConnectionId: preferred, preferredConnectionId: preferred,
updatedAt: now, updatedAt: now,
} satisfies HostProfile; } satisfies HostProfile
}) })
.filter((entry): entry is HostProfile => entry !== null); .filter((entry): entry is HostProfile => entry !== null)
await persist(next); await persist(next)
if (removedConnection && isDefaultLocalhostConnection(removedConnection)) {
await markDefaultLocalhostBootstrapHandled()
}
}, },
[persist, readDaemons] [markDefaultLocalhostBootstrapHandled, persist, readDaemons]
); )
const upsertHostConnection = useCallback( const upsertHostConnection = useCallback(
async ( async (
input: { input: {
serverId: string; serverId: string
label?: string; label?: string
} & ( } & ({ connection: DirectHostConnection } | { connection: RelayHostConnection })
| { connection: DirectHostConnection }
| { connection: RelayHostConnection }
)
) => { ) => {
const existing = readDaemons(); const existing = readDaemons()
const now = new Date().toISOString(); const now = new Date().toISOString()
const serverId = input.serverId.trim(); const serverId = input.serverId.trim()
if (!serverId) { if (!serverId) {
throw new Error("serverId is required"); throw new Error('serverId is required')
} }
const labelTrimmed = input.label?.trim() ?? ""; const labelTrimmed = input.label?.trim() ?? ''
const derivedLabel = labelTrimmed || serverId; const derivedLabel = labelTrimmed || serverId
const idx = existing.findIndex((d) => d.serverId === serverId); const idx = existing.findIndex((d) => d.serverId === serverId)
if (idx === -1) { if (idx === -1) {
const profile: HostProfile = { const profile: HostProfile = {
serverId, serverId,
@@ -173,18 +217,18 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
preferredConnectionId: input.connection.id, preferredConnectionId: input.connection.id,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; }
const next = [...existing, profile]; const next = [...existing, profile]
await persist(next); await persist(next)
return profile; return profile
} }
const prev = existing[idx]!; const prev = existing[idx]!
const connectionIdx = prev.connections.findIndex((c) => c.id === input.connection.id); const connectionIdx = prev.connections.findIndex((c) => c.id === input.connection.id)
const nextConnections = const nextConnections =
connectionIdx === -1 connectionIdx === -1
? [...prev.connections, input.connection] ? [...prev.connections, input.connection]
: prev.connections.map((c, i) => (i === connectionIdx ? input.connection : c)); : prev.connections.map((c, i) => (i === connectionIdx ? input.connection : c))
const nextProfile: HostProfile = { const nextProfile: HostProfile = {
...prev, ...prev,
@@ -192,54 +236,118 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
connections: nextConnections, connections: nextConnections,
preferredConnectionId: prev.preferredConnectionId ?? input.connection.id, preferredConnectionId: prev.preferredConnectionId ?? input.connection.id,
updatedAt: now, updatedAt: now,
}; }
const next = [...existing]; const next = [...existing]
next[idx] = nextProfile; next[idx] = nextProfile
await persist(next); await persist(next)
return nextProfile; return nextProfile
}, },
[persist, readDaemons] [persist, readDaemons]
); )
const upsertDirectConnection = useCallback( const upsertDirectConnection = useCallback(
async (input: { serverId: string; endpoint: string; label?: string }) => { async (input: { serverId: string; endpoint: string; label?: string }) => {
const endpoint = normalizeHostPort(input.endpoint); const endpoint = normalizeHostPort(input.endpoint)
const connection: DirectHostConnection = { const connection: DirectHostConnection = {
id: `direct:${endpoint}`, id: `direct:${endpoint}`,
type: "direct", type: 'direct',
endpoint, endpoint,
}; }
return upsertHostConnection({ return upsertHostConnection({
serverId: input.serverId, serverId: input.serverId,
label: input.label, label: input.label,
connection, connection,
}); })
}, },
[upsertHostConnection] [upsertHostConnection]
); )
useEffect(() => {
if (isPending) return
if (localhostBootstrapAttemptedRef.current) return
localhostBootstrapAttemptedRef.current = true
let cancelled = false
const bootstrapDefaultLocalhost = async () => {
try {
const [isE2E, alreadyHandled] = await Promise.all([
AsyncStorage.getItem(E2E_STORAGE_KEY),
AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY),
])
if (cancelled || isE2E || alreadyHandled) {
return
}
const existing = readDaemons()
if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) {
await markDefaultLocalhostBootstrapHandled()
return
}
try {
const { serverId, hostname } = await probeConnection(
{
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: 'direct',
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
)
if (cancelled) return
await upsertDirectConnection({
serverId,
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
label: hostname ?? undefined,
})
await markDefaultLocalhostBootstrapHandled()
} catch {
// Best-effort bootstrap only; keep startup resilient if localhost isn't reachable.
}
} catch (bootstrapError) {
if (cancelled) return
console.warn(
'[DaemonRegistry] Failed to bootstrap default localhost connection',
bootstrapError
)
}
}
void bootstrapDefaultLocalhost()
return () => {
cancelled = true
}
}, [isPending, markDefaultLocalhostBootstrapHandled, readDaemons, upsertDirectConnection])
const upsertRelayConnection = useCallback( const upsertRelayConnection = useCallback(
async (input: { serverId: string; relayEndpoint: string; daemonPublicKeyB64: string; label?: string }) => { async (input: {
const relayEndpoint = normalizeHostPort(input.relayEndpoint); serverId: string
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim(); relayEndpoint: string
daemonPublicKeyB64: string
label?: string
}) => {
const relayEndpoint = normalizeHostPort(input.relayEndpoint)
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim()
if (!daemonPublicKeyB64) { if (!daemonPublicKeyB64) {
throw new Error("daemonPublicKeyB64 is required"); throw new Error('daemonPublicKeyB64 is required')
} }
const connection: RelayHostConnection = { const connection: RelayHostConnection = {
id: `relay:${relayEndpoint}`, id: `relay:${relayEndpoint}`,
type: "relay", type: 'relay',
relayEndpoint, relayEndpoint,
daemonPublicKeyB64, daemonPublicKeyB64,
}; }
return upsertHostConnection({ return upsertHostConnection({
serverId: input.serverId, serverId: input.serverId,
label: input.label, label: input.label,
connection, connection,
}); })
}, },
[upsertHostConnection] [upsertHostConnection]
); )
const upsertDaemonFromOffer = useCallback( const upsertDaemonFromOffer = useCallback(
async (offer: ConnectionOffer) => { async (offer: ConnectionOffer) => {
@@ -247,28 +355,28 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
serverId: offer.serverId, serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint, relayEndpoint: offer.relay.endpoint,
daemonPublicKeyB64: offer.daemonPublicKeyB64, daemonPublicKeyB64: offer.daemonPublicKeyB64,
}); })
}, },
[upsertRelayConnection] [upsertRelayConnection]
); )
const upsertDaemonFromOfferUrl = useCallback( const upsertDaemonFromOfferUrl = useCallback(
async (offerUrlOrFragment: string) => { async (offerUrlOrFragment: string) => {
const marker = "#offer="; const marker = '#offer='
const idx = offerUrlOrFragment.indexOf(marker); const idx = offerUrlOrFragment.indexOf(marker)
if (idx === -1) { if (idx === -1) {
throw new Error("Missing #offer= fragment"); throw new Error('Missing #offer= fragment')
} }
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim(); const encoded = offerUrlOrFragment.slice(idx + marker.length).trim()
if (!encoded) { if (!encoded) {
throw new Error("Offer payload is empty"); throw new Error('Offer payload is empty')
} }
const payload = decodeOfferFragmentPayload(encoded); const payload = decodeOfferFragmentPayload(encoded)
const offer = ConnectionOfferSchema.parse(payload); const offer = ConnectionOfferSchema.parse(payload)
return upsertDaemonFromOffer(offer); return upsertDaemonFromOffer(offer)
}, },
[upsertDaemonFromOffer] [upsertDaemonFromOffer]
); )
const value: DaemonRegistryContextValue = { const value: DaemonRegistryContextValue = {
daemons, daemons,
@@ -281,69 +389,65 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
removeConnection, removeConnection,
upsertDaemonFromOffer, upsertDaemonFromOffer,
upsertDaemonFromOfferUrl, upsertDaemonFromOfferUrl,
}; }
return ( return <DaemonRegistryContext.Provider value={value}>{children}</DaemonRegistryContext.Provider>
<DaemonRegistryContext.Provider value={value}>
{children}
</DaemonRegistryContext.Provider>
);
} }
type LegacyHostProfileV1 = { type LegacyHostProfileV1 = {
id: string; id: string
label: string; label: string
endpoints?: unknown; endpoints?: unknown
daemonPublicKeyB64?: unknown; daemonPublicKeyB64?: unknown
relay?: unknown; relay?: unknown
createdAt: string; createdAt: string
updatedAt: string; updatedAt: string
}; }
function isHostProfileV2(value: unknown): value is HostProfile { function isHostProfileV2(value: unknown): value is HostProfile {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== 'object') return false
const obj = value as Record<string, unknown>; const obj = value as Record<string, unknown>
return ( return (
typeof obj.serverId === "string" && typeof obj.serverId === 'string' &&
typeof obj.label === "string" && typeof obj.label === 'string' &&
Array.isArray(obj.connections) && Array.isArray(obj.connections) &&
typeof obj.createdAt === "string" && typeof obj.createdAt === 'string' &&
typeof obj.updatedAt === "string" typeof obj.updatedAt === 'string'
); )
} }
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> { async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
try { try {
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY); const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY)
if (stored) { if (stored) {
const parsed = JSON.parse(stored) as unknown; const parsed = JSON.parse(stored) as unknown
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
const v2 = parsed.filter((entry) => isHostProfileV2(entry)) as HostProfile[]; const v2 = parsed.filter((entry) => isHostProfileV2(entry)) as HostProfile[]
if (v2.length === parsed.length) { if (v2.length === parsed.length) {
return v2; return v2
} }
// Hard migration from the previous in-repo schema (v1 HostProfile with `id/endpoints/relay`). // Hard migration from the previous in-repo schema (v1 HostProfile with `id/endpoints/relay`).
const migrated: HostProfile[] = parsed const migrated: HostProfile[] = parsed
.map((entry): HostProfile | null => { .map((entry): HostProfile | null => {
if (!entry || typeof entry !== "object") return null; if (!entry || typeof entry !== 'object') return null
const obj = entry as LegacyHostProfileV1; const obj = entry as LegacyHostProfileV1
if (typeof obj.id !== "string" || typeof obj.label !== "string") return null; if (typeof obj.id !== 'string' || typeof obj.label !== 'string') return null
// Only keep stable daemon ids; discard transient entries to avoid confusing host selection. // Only keep stable daemon ids; discard transient entries to avoid confusing host selection.
if (!obj.id.startsWith("srv_")) return null; if (!obj.id.startsWith('srv_')) return null
const now = new Date().toISOString(); const now = new Date().toISOString()
const createdAt = typeof obj.createdAt === "string" ? obj.createdAt : now; const createdAt = typeof obj.createdAt === 'string' ? obj.createdAt : now
const updatedAt = typeof obj.updatedAt === "string" ? obj.updatedAt : now; const updatedAt = typeof obj.updatedAt === 'string' ? obj.updatedAt : now
const connections: HostConnection[] = []; const connections: HostConnection[] = []
if (Array.isArray(obj.endpoints)) { if (Array.isArray(obj.endpoints)) {
for (const endpointRaw of obj.endpoints) { for (const endpointRaw of obj.endpoints) {
try { try {
const endpoint = normalizeHostPort(String(endpointRaw)); const endpoint = normalizeHostPort(String(endpointRaw))
connections.push({ id: `direct:${endpoint}`, type: "direct", endpoint }); connections.push({ id: `direct:${endpoint}`, type: 'direct', endpoint })
} catch { } catch {
// ignore invalid endpoint // ignore invalid endpoint
} }
@@ -351,29 +455,29 @@ async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
} }
const relayEndpointRaw = const relayEndpointRaw =
obj.relay && typeof (obj.relay as any)?.endpoint === "string" obj.relay && typeof (obj.relay as any)?.endpoint === 'string'
? String((obj.relay as any).endpoint) ? String((obj.relay as any).endpoint)
: null; : null
const daemonPublicKeyB64 = const daemonPublicKeyB64 =
typeof obj.daemonPublicKeyB64 === "string" ? obj.daemonPublicKeyB64.trim() : ""; typeof obj.daemonPublicKeyB64 === 'string' ? obj.daemonPublicKeyB64.trim() : ''
if (relayEndpointRaw && daemonPublicKeyB64) { if (relayEndpointRaw && daemonPublicKeyB64) {
try { try {
const relayEndpoint = normalizeHostPort(relayEndpointRaw); const relayEndpoint = normalizeHostPort(relayEndpointRaw)
connections.push({ connections.push({
id: `relay:${relayEndpoint}`, id: `relay:${relayEndpoint}`,
type: "relay", type: 'relay',
relayEndpoint, relayEndpoint,
daemonPublicKeyB64, daemonPublicKeyB64,
}); })
} catch { } catch {
// ignore invalid relay endpoint // ignore invalid relay endpoint
} }
} }
if (connections.length === 0) return null; if (connections.length === 0) return null
const preferredConnectionId: string | null = connections[0]?.id ?? null; const preferredConnectionId: string | null = connections[0]?.id ?? null
return { return {
serverId: obj.id, serverId: obj.id,
@@ -382,18 +486,18 @@ async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
preferredConnectionId, preferredConnectionId,
createdAt, createdAt,
updatedAt, updatedAt,
}; }
}) })
.filter((entry): entry is HostProfile => entry !== null); .filter((entry): entry is HostProfile => entry !== null)
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated)); await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated))
return migrated; return migrated
} }
} }
return []; return []
} catch (error) { } catch (error) {
console.error("[DaemonRegistry] Failed to load daemon registry", error); console.error('[DaemonRegistry] Failed to load daemon registry', error)
throw error; throw error
} }
} }