feat: migrate from wsUrl to endpoints + add relay transport and pairing
This commit is contained in:
@@ -2,40 +2,67 @@ import { createContext, useCallback, useContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
decodeOfferFragmentPayload,
|
||||
deriveLabelFromEndpoint,
|
||||
extractHostPortFromWebSocketUrl,
|
||||
normalizeHostPort,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
|
||||
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
const FALLBACK_DAEMON_URL = "ws://localhost:6767/ws";
|
||||
const DEFAULT_DAEMONS: Array<{ label: string; wsUrl: string; restUrl?: string | null }> = [
|
||||
{ label: "localhost", wsUrl: "ws://localhost:6767/ws" },
|
||||
const FALLBACK_DAEMON_ENDPOINT = "localhost:6767";
|
||||
const DEFAULT_HOSTS: Array<{ label: string; endpoint: string }> = [
|
||||
{ label: "localhost", endpoint: "localhost:6767" },
|
||||
];
|
||||
const DAEMON_REGISTRY_QUERY_KEY = ["daemon-registry"];
|
||||
|
||||
export type DaemonProfile = {
|
||||
export type HostRelayConfig = {
|
||||
endpoint: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type HostProfile = {
|
||||
id: string;
|
||||
label: string;
|
||||
wsUrl: string;
|
||||
restUrl?: string | null;
|
||||
endpoints: string[];
|
||||
daemonPublicKeyB64?: string;
|
||||
relay?: HostRelayConfig | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type CreateDaemonInput = {
|
||||
// Backward compatibility with older imports.
|
||||
export type DaemonProfile = HostProfile;
|
||||
|
||||
type CreateHostInput = {
|
||||
label: string;
|
||||
wsUrl: string;
|
||||
restUrl?: string | null;
|
||||
endpoints: string[];
|
||||
};
|
||||
|
||||
type UpdateDaemonInput = Partial<Omit<DaemonProfile, "id" | "createdAt">>;
|
||||
type UpdateHostInput = Partial<Omit<HostProfile, "id" | "createdAt">>;
|
||||
|
||||
const ConnectionOfferV1Schema = z.object({
|
||||
v: z.literal(1),
|
||||
sessionId: z.string().min(1),
|
||||
endpoints: z.array(z.string().min(1)).min(1),
|
||||
daemonPublicKeyB64: z.string().min(1),
|
||||
});
|
||||
|
||||
export type ConnectionOfferV1 = z.infer<typeof ConnectionOfferV1Schema>;
|
||||
|
||||
interface DaemonRegistryContextValue {
|
||||
daemons: DaemonProfile[];
|
||||
daemons: HostProfile[];
|
||||
isLoading: boolean;
|
||||
error: unknown | null;
|
||||
addDaemon: (input: CreateDaemonInput) => Promise<DaemonProfile>;
|
||||
updateDaemon: (id: string, updates: UpdateDaemonInput) => Promise<void>;
|
||||
addDaemon: (input: CreateHostInput) => Promise<HostProfile>;
|
||||
updateDaemon: (id: string, updates: UpdateHostInput) => Promise<void>;
|
||||
removeDaemon: (id: string) => Promise<void>;
|
||||
upsertDaemonFromOffer: (offer: ConnectionOfferV1) => Promise<HostProfile>;
|
||||
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
|
||||
}
|
||||
|
||||
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null);
|
||||
@@ -58,25 +85,26 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
|
||||
const persist = useCallback(
|
||||
async (profiles: DaemonProfile[]) => {
|
||||
queryClient.setQueryData<DaemonProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles);
|
||||
async (profiles: HostProfile[]) => {
|
||||
queryClient.setQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles);
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles));
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const readDaemons = useCallback(() => {
|
||||
return queryClient.getQueryData<DaemonProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons;
|
||||
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons;
|
||||
}, [queryClient, daemons]);
|
||||
|
||||
const addDaemon = useCallback(async (input: CreateDaemonInput) => {
|
||||
const addDaemon = useCallback(async (input: CreateHostInput) => {
|
||||
const existing = readDaemons();
|
||||
const timestamp = new Date().toISOString();
|
||||
const profile: DaemonProfile = {
|
||||
const profile: HostProfile = {
|
||||
id: generateDaemonId(),
|
||||
label: input.label.trim() || deriveLabelFromUrl(input.wsUrl),
|
||||
wsUrl: input.wsUrl,
|
||||
restUrl: input.restUrl ?? null,
|
||||
label: input.label.trim() || deriveLabelFromEndpoint(input.endpoints[0] ?? ""),
|
||||
endpoints: input.endpoints.map((endpoint) => normalizeHostPort(endpoint)),
|
||||
daemonPublicKeyB64: undefined,
|
||||
relay: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
metadata: null,
|
||||
@@ -87,7 +115,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
return profile;
|
||||
}, [persist, readDaemons]);
|
||||
|
||||
const updateDaemon = useCallback(async (id: string, updates: UpdateDaemonInput) => {
|
||||
const updateDaemon = useCallback(async (id: string, updates: UpdateHostInput) => {
|
||||
const next = readDaemons().map((daemon) =>
|
||||
daemon.id === id
|
||||
? {
|
||||
@@ -102,10 +130,67 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const removeDaemon = useCallback(async (id: string) => {
|
||||
const remaining = readDaemons().filter((daemon) => daemon.id !== id);
|
||||
const next = remaining.length > 0 ? remaining : [createProfile("Local Host", FALLBACK_DAEMON_URL)];
|
||||
await persist(next);
|
||||
await persist(remaining);
|
||||
}, [persist, readDaemons]);
|
||||
|
||||
const upsertDaemonFromOffer = useCallback(
|
||||
async (offer: ConnectionOfferV1) => {
|
||||
const existing = readDaemons();
|
||||
const now = new Date().toISOString();
|
||||
const normalizedEndpoints = offer.endpoints.map((endpoint) => normalizeHostPort(endpoint));
|
||||
const relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1];
|
||||
|
||||
const matchIndex = existing.findIndex((daemon) => daemon.daemonPublicKeyB64 === offer.daemonPublicKeyB64);
|
||||
if (matchIndex !== -1) {
|
||||
const updated: HostProfile = {
|
||||
...existing[matchIndex],
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
endpoints: normalizedEndpoints,
|
||||
relay: { endpoint: relayEndpoint, sessionId: offer.sessionId },
|
||||
updatedAt: now,
|
||||
};
|
||||
const next = [...existing];
|
||||
next[matchIndex] = updated;
|
||||
await persist(next);
|
||||
return updated;
|
||||
}
|
||||
|
||||
const profile: HostProfile = {
|
||||
id: generateDaemonId(),
|
||||
label: deriveLabelFromEndpoint(normalizedEndpoints[0] ?? "Unnamed Host"),
|
||||
endpoints: normalizedEndpoints,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
relay: { endpoint: relayEndpoint, sessionId: offer.sessionId },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
const next = [...existing, profile];
|
||||
await persist(next);
|
||||
return profile;
|
||||
},
|
||||
[persist, readDaemons]
|
||||
);
|
||||
|
||||
const upsertDaemonFromOfferUrl = useCallback(
|
||||
async (offerUrlOrFragment: string) => {
|
||||
const marker = "#offer=";
|
||||
const idx = offerUrlOrFragment.indexOf(marker);
|
||||
if (idx === -1) {
|
||||
throw new Error("Missing #offer= fragment");
|
||||
}
|
||||
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim();
|
||||
if (!encoded) {
|
||||
throw new Error("Offer payload is empty");
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
const offer = ConnectionOfferV1Schema.parse(payload);
|
||||
return upsertDaemonFromOffer(offer);
|
||||
},
|
||||
[upsertDaemonFromOffer]
|
||||
);
|
||||
|
||||
const value: DaemonRegistryContextValue = {
|
||||
daemons,
|
||||
isLoading: isPending,
|
||||
@@ -113,6 +198,8 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
addDaemon,
|
||||
updateDaemon,
|
||||
removeDaemon,
|
||||
upsertDaemonFromOffer,
|
||||
upsertDaemonFromOfferUrl,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -127,22 +214,14 @@ function generateDaemonId(): string {
|
||||
return `daemon_${Date.now().toString(36)}_${random}`;
|
||||
}
|
||||
|
||||
function deriveLabelFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname || "Unnamed Host";
|
||||
} catch {
|
||||
return "Unnamed Host";
|
||||
}
|
||||
}
|
||||
|
||||
function createProfile(label: string, wsUrl: string): DaemonProfile {
|
||||
function createProfile(label: string, endpoint: string): HostProfile {
|
||||
const timestamp = new Date().toISOString();
|
||||
return {
|
||||
id: generateDaemonId(),
|
||||
label,
|
||||
wsUrl,
|
||||
restUrl: null,
|
||||
endpoints: [normalizeHostPort(endpoint)],
|
||||
daemonPublicKeyB64: undefined,
|
||||
relay: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
metadata: null,
|
||||
@@ -151,14 +230,14 @@ function createProfile(label: string, wsUrl: string): DaemonProfile {
|
||||
|
||||
type EnvDaemonConfig = {
|
||||
label?: string;
|
||||
wsUrl: string;
|
||||
restUrl?: string | null;
|
||||
endpoint?: string;
|
||||
wsUrl?: string;
|
||||
};
|
||||
|
||||
function parseEnvDaemonDefaults(): DaemonProfile[] {
|
||||
function parseEnvDaemonDefaults(): HostProfile[] {
|
||||
const envDaemons = (() => {
|
||||
// Primary: allow a JSON array string like
|
||||
// EXPO_PUBLIC_DAEMONS='[{"label":"Host","wsUrl":"ws://10.0.0.1:6767/ws"}]'
|
||||
// EXPO_PUBLIC_DAEMONS='[{"label":"Host","endpoint":"10.0.0.1:6767"}]'
|
||||
const jsonList = process.env.EXPO_PUBLIC_DAEMONS;
|
||||
if (jsonList) {
|
||||
try {
|
||||
@@ -178,7 +257,6 @@ function parseEnvDaemonDefaults(): DaemonProfile[] {
|
||||
{
|
||||
label: process.env.EXPO_PUBLIC_DAEMON_LABEL,
|
||||
wsUrl: singleWsUrl,
|
||||
restUrl: process.env.EXPO_PUBLIC_DAEMON_REST_URL,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -188,23 +266,120 @@ function parseEnvDaemonDefaults(): DaemonProfile[] {
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
return envDaemons
|
||||
.filter((entry) => typeof entry?.wsUrl === "string" && entry.wsUrl.trim().length > 0)
|
||||
.map((entry) => ({
|
||||
id: generateDaemonId(),
|
||||
label: entry.label?.trim() || deriveLabelFromUrl(entry.wsUrl),
|
||||
wsUrl: entry.wsUrl.trim(),
|
||||
restUrl: entry.restUrl ?? null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
metadata: null,
|
||||
}));
|
||||
.map((entry): HostProfile | null => {
|
||||
const endpoint = (() => {
|
||||
if (typeof entry.endpoint === "string" && entry.endpoint.trim().length > 0) {
|
||||
return entry.endpoint.trim();
|
||||
}
|
||||
if (typeof entry.wsUrl === "string" && entry.wsUrl.trim().length > 0) {
|
||||
try {
|
||||
return extractHostPortFromWebSocketUrl(entry.wsUrl.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (!endpoint) return null;
|
||||
|
||||
return {
|
||||
id: generateDaemonId(),
|
||||
label: entry.label?.trim() || deriveLabelFromEndpoint(endpoint),
|
||||
endpoints: [normalizeHostPort(endpoint)],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
metadata: null,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is HostProfile => entry !== null);
|
||||
}
|
||||
|
||||
async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
|
||||
type LegacyDaemonProfile = {
|
||||
id: string;
|
||||
label: string;
|
||||
wsUrl: string;
|
||||
restUrl?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function isLegacyDaemonProfile(value: unknown): value is LegacyDaemonProfile {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return typeof obj.id === "string" && typeof obj.wsUrl === "string" && typeof obj.label === "string";
|
||||
}
|
||||
|
||||
function isHostProfile(value: unknown): value is HostProfile {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return typeof obj.id === "string" && typeof obj.label === "string" && Array.isArray(obj.endpoints);
|
||||
}
|
||||
|
||||
function migrateLegacyToHostProfile(legacy: LegacyDaemonProfile): HostProfile {
|
||||
const endpoint = extractHostPortFromWebSocketUrl(legacy.wsUrl);
|
||||
return {
|
||||
id: legacy.id,
|
||||
label: legacy.label,
|
||||
endpoints: [normalizeHostPort(endpoint)],
|
||||
daemonPublicKeyB64: undefined,
|
||||
relay: null,
|
||||
createdAt: legacy.createdAt,
|
||||
updatedAt: legacy.updatedAt,
|
||||
metadata: legacy.metadata ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as DaemonProfile[];
|
||||
const parsed = JSON.parse(stored) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const hasLegacy = parsed.some((entry) => isLegacyDaemonProfile(entry));
|
||||
const hasNew = parsed.some((entry) => isHostProfile(entry));
|
||||
|
||||
if (hasNew && !hasLegacy) {
|
||||
return parsed as HostProfile[];
|
||||
}
|
||||
|
||||
const migrated = parsed
|
||||
.map((entry) => {
|
||||
if (isHostProfile(entry)) {
|
||||
const endpoints = entry.endpoints
|
||||
.map((endpoint) => {
|
||||
try {
|
||||
return normalizeHostPort(String(endpoint));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((endpoint): endpoint is string => endpoint !== null);
|
||||
if (endpoints.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { ...entry, endpoints } as HostProfile;
|
||||
}
|
||||
if (isLegacyDaemonProfile(entry)) {
|
||||
try {
|
||||
return migrateLegacyToHostProfile(entry);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((entry): entry is HostProfile => entry !== null);
|
||||
|
||||
if (migrated.length > 0) {
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated));
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
|
||||
@@ -212,7 +387,8 @@ async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
|
||||
const legacyParsed = JSON.parse(legacy) as Record<string, unknown>;
|
||||
const legacyUrl = typeof legacyParsed.serverUrl === "string" ? legacyParsed.serverUrl : null;
|
||||
if (legacyUrl) {
|
||||
const migrated = [createProfile("Primary Host", legacyUrl)];
|
||||
const endpoint = extractHostPortFromWebSocketUrl(legacyUrl);
|
||||
const migrated = [createProfile("Primary Host", endpoint)];
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated));
|
||||
return migrated;
|
||||
}
|
||||
@@ -224,9 +400,9 @@ async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
|
||||
return envDefaults;
|
||||
}
|
||||
|
||||
const fallback = DEFAULT_DAEMONS.length > 0
|
||||
? DEFAULT_DAEMONS.map((entry) => createProfile(entry.label, entry.wsUrl))
|
||||
: [createProfile("Local Host", FALLBACK_DAEMON_URL)];
|
||||
const fallback = DEFAULT_HOSTS.length > 0
|
||||
? DEFAULT_HOSTS.map((entry) => createProfile(entry.label, entry.endpoint))
|
||||
: [createProfile("Local Host", FALLBACK_DAEMON_ENDPOINT)];
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(fallback));
|
||||
return fallback;
|
||||
} catch (error) {
|
||||
@@ -234,3 +410,8 @@ async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDirectDaemonWsUrl(profile: HostProfile): string {
|
||||
const endpoint = profile.endpoints[0] ?? FALLBACK_DAEMON_ENDPOINT;
|
||||
return buildDaemonWebSocketUrl(endpoint);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user