418 lines
13 KiB
TypeScript
418 lines
13 KiB
TypeScript
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_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 HostRelayConfig = {
|
|
endpoint: string;
|
|
sessionId: string;
|
|
};
|
|
|
|
export type HostProfile = {
|
|
id: string;
|
|
label: string;
|
|
endpoints: string[];
|
|
daemonPublicKeyB64?: string;
|
|
relay?: HostRelayConfig | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
metadata?: Record<string, unknown> | null;
|
|
};
|
|
|
|
// Backward compatibility with older imports.
|
|
export type DaemonProfile = HostProfile;
|
|
|
|
type CreateHostInput = {
|
|
label: string;
|
|
endpoints: string[];
|
|
};
|
|
|
|
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: HostProfile[];
|
|
isLoading: boolean;
|
|
error: unknown | null;
|
|
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);
|
|
|
|
export function useDaemonRegistry(): DaemonRegistryContextValue {
|
|
const ctx = useContext(DaemonRegistryContext);
|
|
if (!ctx) {
|
|
throw new Error("useDaemonRegistry must be used within DaemonRegistryProvider");
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|
const queryClient = useQueryClient();
|
|
const { data: daemons = [], isPending, error } = useQuery({
|
|
queryKey: DAEMON_REGISTRY_QUERY_KEY,
|
|
queryFn: loadDaemonRegistryFromStorage,
|
|
staleTime: Infinity,
|
|
gcTime: Infinity,
|
|
});
|
|
|
|
const persist = useCallback(
|
|
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<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons;
|
|
}, [queryClient, daemons]);
|
|
|
|
const addDaemon = useCallback(async (input: CreateHostInput) => {
|
|
const existing = readDaemons();
|
|
const timestamp = new Date().toISOString();
|
|
const profile: HostProfile = {
|
|
id: generateDaemonId(),
|
|
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,
|
|
};
|
|
|
|
const next = [...existing, profile];
|
|
await persist(next);
|
|
return profile;
|
|
}, [persist, readDaemons]);
|
|
|
|
const updateDaemon = useCallback(async (id: string, updates: UpdateHostInput) => {
|
|
const next = readDaemons().map((daemon) =>
|
|
daemon.id === id
|
|
? {
|
|
...daemon,
|
|
...updates,
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
: daemon
|
|
);
|
|
await persist(next);
|
|
}, [persist, readDaemons]);
|
|
|
|
const removeDaemon = useCallback(async (id: string) => {
|
|
const remaining = readDaemons().filter((daemon) => daemon.id !== id);
|
|
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,
|
|
error: error ?? null,
|
|
addDaemon,
|
|
updateDaemon,
|
|
removeDaemon,
|
|
upsertDaemonFromOffer,
|
|
upsertDaemonFromOfferUrl,
|
|
};
|
|
|
|
return (
|
|
<DaemonRegistryContext.Provider value={value}>
|
|
{children}
|
|
</DaemonRegistryContext.Provider>
|
|
);
|
|
}
|
|
|
|
function generateDaemonId(): string {
|
|
const random = Math.random().toString(36).slice(2, 8);
|
|
return `daemon_${Date.now().toString(36)}_${random}`;
|
|
}
|
|
|
|
function createProfile(label: string, endpoint: string): HostProfile {
|
|
const timestamp = new Date().toISOString();
|
|
return {
|
|
id: generateDaemonId(),
|
|
label,
|
|
endpoints: [normalizeHostPort(endpoint)],
|
|
daemonPublicKeyB64: undefined,
|
|
relay: null,
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
metadata: null,
|
|
};
|
|
}
|
|
|
|
type EnvDaemonConfig = {
|
|
label?: string;
|
|
endpoint?: string;
|
|
wsUrl?: string;
|
|
};
|
|
|
|
function parseEnvDaemonDefaults(): HostProfile[] {
|
|
const envDaemons = ((): EnvDaemonConfig[] => {
|
|
// Primary: allow a JSON array string like
|
|
// EXPO_PUBLIC_DAEMONS='[{"label":"Host","endpoint":"10.0.0.1:6767"}]'
|
|
const jsonList = process.env.EXPO_PUBLIC_DAEMONS;
|
|
if (jsonList) {
|
|
try {
|
|
const parsed = JSON.parse(jsonList) as unknown;
|
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
return parsed as EnvDaemonConfig[];
|
|
}
|
|
} catch (error) {
|
|
console.warn("[DaemonRegistry] Failed to parse EXPO_PUBLIC_DAEMONS:", error);
|
|
}
|
|
}
|
|
|
|
// Fallback: single host via EXPO_PUBLIC_DAEMON_WS_URL (+ optional label/rest URL)
|
|
const singleWsUrl = process.env.EXPO_PUBLIC_DAEMON_WS_URL;
|
|
if (singleWsUrl) {
|
|
return [
|
|
{
|
|
label: process.env.EXPO_PUBLIC_DAEMON_LABEL,
|
|
wsUrl: singleWsUrl,
|
|
},
|
|
];
|
|
}
|
|
|
|
return [];
|
|
})();
|
|
|
|
const timestamp = new Date().toISOString();
|
|
return envDaemons
|
|
.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);
|
|
}
|
|
|
|
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) {
|
|
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);
|
|
if (legacy) {
|
|
const legacyParsed = JSON.parse(legacy) as Record<string, unknown>;
|
|
const legacyUrl = typeof legacyParsed.serverUrl === "string" ? legacyParsed.serverUrl : null;
|
|
if (legacyUrl) {
|
|
const endpoint = extractHostPortFromWebSocketUrl(legacyUrl);
|
|
const migrated = [createProfile("Primary Host", endpoint)];
|
|
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated));
|
|
return migrated;
|
|
}
|
|
}
|
|
|
|
const envDefaults = parseEnvDaemonDefaults();
|
|
if (envDefaults.length > 0) {
|
|
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(envDefaults));
|
|
return envDefaults;
|
|
}
|
|
|
|
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) {
|
|
console.error("[DaemonRegistry] Failed to load daemon registry", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function buildDirectDaemonWsUrl(profile: HostProfile): string {
|
|
const endpoint = profile.endpoints[0] ?? FALLBACK_DAEMON_ENDPOINT;
|
|
return buildDaemonWebSocketUrl(endpoint);
|
|
}
|