feat(app): bootstrap default localhost connection once
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user