Relay pairing offer v2 (E2EE) + host connections
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import "@/styles/unistyles";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { Stack, usePathname, useRouter } from "expo-router";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
@@ -37,6 +38,8 @@ import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
|
||||
|
||||
polyfillCrypto();
|
||||
|
||||
function PushNotificationRouter() {
|
||||
const router = useRouter();
|
||||
const lastHandledIdRef = useRef<string | null>(null);
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
||||
import { resolveCanonicalDaemonId, useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
|
||||
export default function AgentReadyRoute() {
|
||||
const router = useRouter();
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const { serverId, agentId } = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof serverId !== "string" || typeof agentId !== "string") return;
|
||||
const canonical = resolveCanonicalDaemonId(daemons, serverId);
|
||||
if (!canonical || canonical === serverId) return;
|
||||
router.replace(`/agent/${canonical}/${agentId}` as any);
|
||||
}, [agentId, daemons, router, serverId]);
|
||||
|
||||
return (
|
||||
<AgentReadyScreen
|
||||
serverId={typeof serverId === "string" ? serverId : ""}
|
||||
|
||||
@@ -157,7 +157,7 @@ export default function PairScanScreen() {
|
||||
try {
|
||||
setIsPairing(true);
|
||||
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
||||
router.replace({ pathname: "/", params: { serverId: profile.id } });
|
||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
||||
} catch (error) {
|
||||
lastScannedRef.current = null;
|
||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||
|
||||
@@ -17,8 +17,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
||||
import { Sun, Moon, Monitor } from "lucide-react-native";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
@@ -27,6 +27,8 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
||||
import { AddHostModal } from "@/components/add-host-modal";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { probeDaemonEndpoint } from "@/utils/test-daemon-connection";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -385,15 +387,19 @@ export default function SettingsScreen() {
|
||||
const {
|
||||
daemons,
|
||||
isLoading: daemonLoading,
|
||||
updateDaemon,
|
||||
removeDaemon,
|
||||
updateHost,
|
||||
removeHost,
|
||||
removeConnection,
|
||||
upsertDirectConnection,
|
||||
} = useDaemonRegistry();
|
||||
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
||||
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
||||
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
||||
const [editingDaemon, setEditingDaemon] = useState<DaemonProfile | null>(null);
|
||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||
const [editLabel, setEditLabel] = useState("");
|
||||
const [newEndpointRaw, setNewEndpointRaw] = useState("");
|
||||
const [isAddingConnection, setIsAddingConnection] = useState(false);
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
const isLoading = settingsLoading || daemonLoading;
|
||||
const isMountedRef = useRef(true);
|
||||
@@ -421,15 +427,17 @@ export default function SettingsScreen() {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleEditDaemon = useCallback((profile: DaemonProfile) => {
|
||||
const handleEditDaemon = useCallback((profile: HostProfile) => {
|
||||
setEditingDaemon(profile);
|
||||
setEditLabel(profile.label ?? "");
|
||||
setNewEndpointRaw("");
|
||||
}, []);
|
||||
|
||||
const handleCloseEditDaemon = useCallback(() => {
|
||||
if (isSavingEdit) return;
|
||||
setEditingDaemon(null);
|
||||
setEditLabel("");
|
||||
setNewEndpointRaw("");
|
||||
}, [isSavingEdit]);
|
||||
|
||||
const handleSaveEditDaemon = useCallback(async () => {
|
||||
@@ -444,7 +452,7 @@ export default function SettingsScreen() {
|
||||
|
||||
try {
|
||||
setIsSavingEdit(true);
|
||||
await updateDaemon(editingDaemon.id, { label: nextLabel });
|
||||
await updateHost(editingDaemon.serverId, { label: nextLabel });
|
||||
handleCloseEditDaemon();
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to rename host", error);
|
||||
@@ -452,10 +460,63 @@ export default function SettingsScreen() {
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
}, [editLabel, editingDaemon, handleCloseEditDaemon, isSavingEdit, updateDaemon]);
|
||||
}, [editLabel, editingDaemon, handleCloseEditDaemon, isSavingEdit, updateHost]);
|
||||
|
||||
const handleRemoveConnection = useCallback(
|
||||
async (serverId: string, connectionId: string) => {
|
||||
await removeConnection(serverId, connectionId);
|
||||
},
|
||||
[removeConnection]
|
||||
);
|
||||
|
||||
const handleAddDirectConnectionToHost = useCallback(async () => {
|
||||
if (!editingDaemon) return;
|
||||
if (isAddingConnection) return;
|
||||
|
||||
const raw = newEndpointRaw.trim();
|
||||
if (!raw) {
|
||||
Alert.alert("Host required", "Enter host:port.");
|
||||
return;
|
||||
}
|
||||
if (raw.includes("://") || raw.includes("/")) {
|
||||
Alert.alert("Invalid host", "Enter host:port only (no ws://, no /ws).");
|
||||
return;
|
||||
}
|
||||
|
||||
let endpoint: string;
|
||||
try {
|
||||
endpoint = normalizeHostPort(raw);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid host:port";
|
||||
Alert.alert("Invalid host", message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAddingConnection(true);
|
||||
const { serverId } = await probeDaemonEndpoint(endpoint, { timeoutMs: 6000 });
|
||||
if (serverId !== editingDaemon.serverId) {
|
||||
Alert.alert(
|
||||
"Wrong daemon",
|
||||
`That endpoint belongs to ${serverId}, but this host is ${editingDaemon.serverId}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
await upsertDirectConnection({
|
||||
serverId,
|
||||
endpoint,
|
||||
});
|
||||
setNewEndpointRaw("");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unable to add connection";
|
||||
Alert.alert("Connection failed", message);
|
||||
} finally {
|
||||
setIsAddingConnection(false);
|
||||
}
|
||||
}, [editingDaemon, isAddingConnection, newEndpointRaw, upsertDirectConnection]);
|
||||
|
||||
const handleRemoveDaemon = useCallback(
|
||||
(profile: DaemonProfile) => {
|
||||
(profile: HostProfile) => {
|
||||
if (Platform.OS === "web") {
|
||||
const hasBrowserConfirm =
|
||||
typeof globalThis !== "undefined" &&
|
||||
@@ -464,7 +525,7 @@ export default function SettingsScreen() {
|
||||
const confirmed = hasBrowserConfirm ? (globalThis as any).confirm(`Remove ${profile.label}?`) : true;
|
||||
if (!confirmed) return;
|
||||
|
||||
void removeDaemon(profile.id).catch((error) => {
|
||||
void removeHost(profile.serverId).catch((error) => {
|
||||
console.error("[Settings] Failed to remove daemon", error);
|
||||
Alert.alert("Error", "Unable to remove host");
|
||||
});
|
||||
@@ -481,7 +542,7 @@ export default function SettingsScreen() {
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await removeDaemon(profile.id);
|
||||
await removeHost(profile.serverId);
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to remove daemon", error);
|
||||
Alert.alert("Error", "Unable to remove host");
|
||||
@@ -491,7 +552,7 @@ export default function SettingsScreen() {
|
||||
]
|
||||
);
|
||||
},
|
||||
[removeDaemon]
|
||||
[removeHost]
|
||||
);
|
||||
|
||||
const handleToggleUseSpeaker = useCallback(
|
||||
@@ -576,14 +637,16 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
) : (
|
||||
daemons.map((daemon) => {
|
||||
const connection = connectionStates.get(daemon.id);
|
||||
const connection = connectionStates.get(daemon.serverId);
|
||||
const connectionStatus = connection?.status ?? "idle";
|
||||
const activeConnection = connection?.activeConnection ?? null;
|
||||
const lastConnectionError = connection?.lastError ?? null;
|
||||
return (
|
||||
<DaemonCard
|
||||
key={daemon.id}
|
||||
key={daemon.serverId}
|
||||
daemon={daemon}
|
||||
connectionStatus={connectionStatus}
|
||||
activeConnection={activeConnection}
|
||||
lastError={lastConnectionError}
|
||||
onEdit={handleEditDaemon}
|
||||
onRemove={handleRemoveDaemon}
|
||||
@@ -615,7 +678,7 @@ export default function SettingsScreen() {
|
||||
visible={isDirectHostVisible}
|
||||
onClose={() => setIsDirectHostVisible(false)}
|
||||
onSaved={(profile) => {
|
||||
router.replace({ pathname: "/", params: { serverId: profile.id } });
|
||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -623,7 +686,7 @@ export default function SettingsScreen() {
|
||||
visible={isPasteLinkVisible}
|
||||
onClose={() => setIsPasteLinkVisible(false)}
|
||||
onSaved={(profile) => {
|
||||
router.replace({ pathname: "/", params: { serverId: profile.id } });
|
||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -644,6 +707,77 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
{editingDaemon ? (
|
||||
<View style={styles.formField}>
|
||||
<Text style={styles.label}>Connections</Text>
|
||||
<View style={{ gap: 8 }}>
|
||||
{editingDaemon.connections.map((conn) => {
|
||||
const title =
|
||||
conn.type === "relay"
|
||||
? `Relay (${conn.relayEndpoint})`
|
||||
: `Direct (${conn.endpoint})`;
|
||||
return (
|
||||
<View
|
||||
key={conn.id}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.colors.foreground, fontSize: 12, fontFamily: Fonts.mono, flex: 1 }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => void handleRemoveConnection(editingDaemon.serverId, conn.id)}
|
||||
>
|
||||
<Text style={{ color: theme.colors.destructive, fontSize: 12, fontWeight: "600" }}>
|
||||
Remove
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{editingDaemon ? (
|
||||
<View style={styles.formField}>
|
||||
<Text style={styles.label}>Add direct connection</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.inputUrl]}
|
||||
value={newEndpointRaw}
|
||||
onChangeText={setNewEndpointRaw}
|
||||
placeholder="192.168.1.10:6767"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.formButton,
|
||||
styles.formButtonPrimary,
|
||||
(isAddingConnection || !newEndpointRaw.trim()) && styles.hostActionDisabled,
|
||||
{ alignSelf: "flex-end" },
|
||||
]}
|
||||
onPress={() => void handleAddDirectConnectionToHost()}
|
||||
disabled={isAddingConnection || !newEndpointRaw.trim()}
|
||||
>
|
||||
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>
|
||||
{isAddingConnection ? "Adding..." : "Add"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.formActionsRow}>
|
||||
<Pressable
|
||||
style={[styles.formButton, isSavingEdit && styles.hostActionDisabled]}
|
||||
@@ -779,11 +913,12 @@ export default function SettingsScreen() {
|
||||
}
|
||||
|
||||
interface DaemonCardProps {
|
||||
daemon: DaemonProfile;
|
||||
daemon: HostProfile;
|
||||
connectionStatus: ConnectionStatus;
|
||||
activeConnection: ActiveConnection | null;
|
||||
lastError: string | null;
|
||||
onEdit: (daemon: DaemonProfile) => void;
|
||||
onRemove: (daemon: DaemonProfile) => void;
|
||||
onEdit: (daemon: HostProfile) => void;
|
||||
onRemove: (daemon: HostProfile) => void;
|
||||
restartConfirmationMessage: string;
|
||||
waitForCondition: (predicate: () => boolean, timeoutMs: number, intervalMs?: number) => Promise<boolean>;
|
||||
isScreenMountedRef: MutableRefObject<boolean>;
|
||||
@@ -792,6 +927,7 @@ interface DaemonCardProps {
|
||||
function DaemonCard({
|
||||
daemon,
|
||||
connectionStatus,
|
||||
activeConnection,
|
||||
lastError,
|
||||
onEdit,
|
||||
onRemove,
|
||||
@@ -813,9 +949,9 @@ function DaemonCard({
|
||||
const badgeText = statusLabel;
|
||||
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||
const daemonConnection = useSessionStore(
|
||||
(state) => state.sessions[daemon.id]?.connection ?? null
|
||||
(state) => state.sessions[daemon.serverId]?.connection ?? null
|
||||
);
|
||||
const daemonClient = useSessionStore((state) => state.sessions[daemon.id]?.client ?? null);
|
||||
const daemonClient = useSessionStore((state) => state.sessions[daemon.serverId]?.client ?? null);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const isConnected = daemonConnection?.isConnected ?? false;
|
||||
const isConnectedRef = useRef(isConnected);
|
||||
@@ -866,7 +1002,7 @@ function DaemonCard({
|
||||
|
||||
setIsRestarting(true);
|
||||
void daemonClient
|
||||
.restartServer(`settings_daemon_restart_${daemon.id}`)
|
||||
.restartServer(`settings_daemon_restart_${daemon.serverId}`)
|
||||
.catch((error) => {
|
||||
console.error(`[Settings] Failed to restart daemon ${daemon.label}`, error);
|
||||
if (!isScreenMountedRef.current) {
|
||||
@@ -880,7 +1016,7 @@ function DaemonCard({
|
||||
});
|
||||
|
||||
void waitForDaemonRestart();
|
||||
}, [daemon.id, daemon.label, daemonClient, isScreenMountedRef, waitForDaemonRestart]);
|
||||
}, [daemon.label, daemon.serverId, daemonClient, isScreenMountedRef, waitForDaemonRestart]);
|
||||
|
||||
const handleRestartPress = useCallback(() => {
|
||||
if (!daemonClient) {
|
||||
@@ -927,7 +1063,7 @@ function DaemonCard({
|
||||
: "rgba(161, 161, 170, 0.1)";
|
||||
|
||||
return (
|
||||
<View style={styles.hostCard} testID={`daemon-card-${daemon.id}`}>
|
||||
<View style={styles.hostCard} testID={`daemon-card-${daemon.serverId}`}>
|
||||
<View style={styles.hostCardContent}>
|
||||
<View style={styles.hostHeaderRow}>
|
||||
<Text style={styles.hostLabel}>{daemon.label}</Text>
|
||||
@@ -936,7 +1072,31 @@ function DaemonCard({
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.hostUrl}>{daemon.endpoints?.[0] ?? ""}</Text>
|
||||
<Text style={styles.hostUrl}>
|
||||
{(() => {
|
||||
if (connectionStatus === "online") {
|
||||
if (activeConnection?.type === "relay") return "Connected via relay";
|
||||
if (activeConnection?.type === "direct") return `Connected via ${activeConnection.display}`;
|
||||
return "Connected";
|
||||
}
|
||||
|
||||
if (connectionStatus === "connecting" && activeConnection) {
|
||||
if (activeConnection.type === "relay") return "Trying relay";
|
||||
return `Trying ${activeConnection.display}`;
|
||||
}
|
||||
|
||||
if ((connectionStatus === "offline" || connectionStatus === "error") && activeConnection) {
|
||||
if (activeConnection.type === "relay") return "Last tried: relay";
|
||||
return `Last tried: ${activeConnection.display}`;
|
||||
}
|
||||
|
||||
const relay = daemon.connections.find((c) => c.type === "relay");
|
||||
const direct = daemon.connections.find((c) => c.type === "direct");
|
||||
if (direct) return direct.endpoint;
|
||||
if (relay) return "Relay";
|
||||
return "";
|
||||
})()}
|
||||
</Text>
|
||||
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
|
||||
</View>
|
||||
<View style={styles.hostActionsRow}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { Link2 } from "lucide-react-native";
|
||||
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { DaemonConnectionTestError, testDaemonEndpointConnection } from "@/utils/test-daemon-connection";
|
||||
import { DaemonConnectionTestError, probeDaemonEndpoint } from "@/utils/test-daemon-connection";
|
||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -151,7 +151,7 @@ export interface AddHostModalProps {
|
||||
|
||||
export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { addDaemon } = useDaemonRegistry();
|
||||
const { upsertDirectConnection } = useDaemonRegistry();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
@@ -198,11 +198,11 @@ export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
|
||||
await testDaemonEndpointConnection(endpoint);
|
||||
|
||||
const profile = await addDaemon({
|
||||
const { serverId } = await probeDaemonEndpoint(endpoint);
|
||||
const profile = await upsertDirectConnection({
|
||||
serverId,
|
||||
endpoint,
|
||||
label: label.trim(),
|
||||
endpoints: [endpoint],
|
||||
});
|
||||
|
||||
onSaved?.(profile);
|
||||
@@ -223,7 +223,7 @@ export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [addDaemon, endpointRaw, handleClose, isMobile, isSaving, label, onSaved]);
|
||||
}, [endpointRaw, handleClose, isMobile, isSaving, label, onSaved, upsertDirectConnection]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
|
||||
|
||||
@@ -28,7 +28,17 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
<Pressable onPress={toggleAgentList} style={styles.menuButton}>
|
||||
<Pressable
|
||||
onPress={toggleAgentList}
|
||||
style={styles.menuButton}
|
||||
testID="menu-button"
|
||||
nativeID="menu-button"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
||||
accessibilityState={{ expanded: isOpen }}
|
||||
>
|
||||
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
|
||||
</Pressable>
|
||||
{title && (
|
||||
|
||||
@@ -4,38 +4,63 @@ import {
|
||||
type HostProfile,
|
||||
} from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, extractHostPortFromWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
buildRelayWebSocketUrl,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { probeDaemonEndpoint } from "@/utils/test-daemon-connection";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ActiveConnection } from "@/contexts/daemon-connections-context";
|
||||
|
||||
function buildCandidateUrls(daemon: HostProfile): string[] {
|
||||
const endpoints = daemon.endpoints ?? [];
|
||||
const sessionId = daemon.relay?.sessionId ?? null;
|
||||
const relayEndpoint = daemon.relay?.endpoint ?? null;
|
||||
const lastKnownGood =
|
||||
typeof daemon.metadata?.lastKnownGoodEndpoint === "string"
|
||||
? (daemon.metadata.lastKnownGoodEndpoint as string)
|
||||
: null;
|
||||
type Candidate = {
|
||||
connectionId: string;
|
||||
url: string;
|
||||
activeConnection: ActiveConnection;
|
||||
daemonPublicKeyB64?: string;
|
||||
};
|
||||
|
||||
const out: string[] = [];
|
||||
const push = (url: string) => {
|
||||
if (!out.includes(url)) out.push(url);
|
||||
};
|
||||
function sortConnectionsByPreference<T extends { id: string }>(
|
||||
connections: T[],
|
||||
preferredId: string | null
|
||||
): T[] {
|
||||
if (!preferredId) return connections;
|
||||
const idx = connections.findIndex((c) => c.id === preferredId);
|
||||
if (idx === -1) return connections;
|
||||
return [connections[idx]!, ...connections.slice(0, idx), ...connections.slice(idx + 1)];
|
||||
}
|
||||
|
||||
const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint;
|
||||
const directEndpoints = endpoints;
|
||||
function buildCandidates(host: HostProfile): Candidate[] {
|
||||
const preferred = host.preferredConnectionId ?? null;
|
||||
|
||||
if (sessionId && relayEndpoint && isLastKnownRelay) {
|
||||
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
|
||||
} else if (lastKnownGood) {
|
||||
push(buildDaemonWebSocketUrl(lastKnownGood));
|
||||
const direct = sortConnectionsByPreference(
|
||||
host.connections.filter((c) => c.type === "direct"),
|
||||
preferred
|
||||
);
|
||||
const relay = sortConnectionsByPreference(
|
||||
host.connections.filter((c) => c.type === "relay"),
|
||||
preferred
|
||||
);
|
||||
|
||||
const out: Candidate[] = [];
|
||||
|
||||
for (const conn of direct) {
|
||||
out.push({
|
||||
connectionId: conn.id,
|
||||
url: buildDaemonWebSocketUrl(conn.endpoint),
|
||||
activeConnection: { type: "direct", endpoint: conn.endpoint, display: conn.endpoint },
|
||||
});
|
||||
}
|
||||
|
||||
for (const endpoint of directEndpoints) {
|
||||
push(buildDaemonWebSocketUrl(endpoint));
|
||||
}
|
||||
|
||||
if (sessionId && relayEndpoint) {
|
||||
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
|
||||
for (const conn of relay) {
|
||||
out.push({
|
||||
connectionId: conn.id,
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: conn.relayEndpoint,
|
||||
serverId: host.serverId,
|
||||
}),
|
||||
activeConnection: { type: "relay", endpoint: conn.relayEndpoint, display: "relay" },
|
||||
daemonPublicKeyB64: conn.daemonPublicKeyB64,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
@@ -43,55 +68,50 @@ function buildCandidateUrls(daemon: HostProfile): string[] {
|
||||
|
||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const { updateDaemon } = useDaemonRegistry();
|
||||
const { updateHost } = useDaemonRegistry();
|
||||
|
||||
const candidates = useMemo(() => buildCandidateUrls(daemon), [daemon]);
|
||||
const candidates = useMemo(() => buildCandidates(daemon), [daemon]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const activeUrl = candidates[activeIndex] ?? candidates[0] ?? null;
|
||||
const active = candidates[activeIndex] ?? candidates[0] ?? null;
|
||||
const activeUrl = active?.url ?? null;
|
||||
|
||||
const lastAttemptedUrlRef = useRef<string | null>(null);
|
||||
const pendingMetadataWriteRef = useRef(false);
|
||||
const pendingPreferenceWriteRef = useRef(false);
|
||||
const upgradeProbeInFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeUrl) {
|
||||
return;
|
||||
}
|
||||
// If the active URL fell out of the candidate set (e.g. endpoints updated), snap back.
|
||||
const idx = candidates.indexOf(activeUrl);
|
||||
const idx = candidates.findIndex((c) => c.url === activeUrl);
|
||||
if (idx === -1) {
|
||||
setActiveIndex(0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [candidates.join("|")]);
|
||||
}, [candidates.map((c) => c.url).join("|")]);
|
||||
|
||||
if (!activeUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connection = connectionStates.get(daemon.id);
|
||||
const connection = connectionStates.get(daemon.serverId);
|
||||
const status = connection?.status ?? "idle";
|
||||
const lastError = connection?.lastError ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
|
||||
if (status === "online") {
|
||||
if (pendingMetadataWriteRef.current) return;
|
||||
const endpoint = (() => {
|
||||
try {
|
||||
return extractHostPortFromWebSocketUrl(activeUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!endpoint) return;
|
||||
if (daemon.metadata?.lastKnownGoodEndpoint === endpoint) return;
|
||||
if (status === "online") {
|
||||
if (!active) return;
|
||||
if (pendingPreferenceWriteRef.current) return;
|
||||
if (daemon.preferredConnectionId === active.connectionId) return;
|
||||
|
||||
pendingMetadataWriteRef.current = true;
|
||||
void updateDaemon(daemon.id, {
|
||||
metadata: { ...(daemon.metadata ?? {}), lastKnownGoodEndpoint: endpoint },
|
||||
pendingPreferenceWriteRef.current = true;
|
||||
void updateHost(daemon.serverId, {
|
||||
preferredConnectionId: active.connectionId,
|
||||
}).finally(() => {
|
||||
pendingMetadataWriteRef.current = false;
|
||||
pendingPreferenceWriteRef.current = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -106,23 +126,67 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
}
|
||||
}
|
||||
}, [
|
||||
active,
|
||||
activeIndex,
|
||||
activeUrl,
|
||||
candidates.length,
|
||||
daemon.id,
|
||||
daemon.metadata,
|
||||
daemon.preferredConnectionId,
|
||||
daemon.serverId,
|
||||
lastError,
|
||||
status,
|
||||
updateDaemon,
|
||||
updateHost,
|
||||
connection,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
if (status !== "online") return;
|
||||
if (activeIndex === 0) return;
|
||||
|
||||
const best = candidates[0];
|
||||
if (!best) return;
|
||||
if (best.activeConnection.type !== "direct") return;
|
||||
|
||||
const intervalMs = 15_000;
|
||||
let cancelled = false;
|
||||
|
||||
const attemptUpgrade = async () => {
|
||||
if (cancelled) return;
|
||||
if (upgradeProbeInFlightRef.current) return;
|
||||
if (activeIndex === 0) return;
|
||||
|
||||
upgradeProbeInFlightRef.current = true;
|
||||
try {
|
||||
const { serverId } = await probeDaemonEndpoint(best.activeConnection.endpoint, {
|
||||
timeoutMs: 2000,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (serverId !== daemon.serverId) return;
|
||||
|
||||
lastAttemptedUrlRef.current = null;
|
||||
setActiveIndex(0);
|
||||
} catch {
|
||||
// ignore - we'll retry periodically
|
||||
} finally {
|
||||
upgradeProbeInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void attemptUpgrade();
|
||||
const interval = setInterval(() => void attemptUpgrade(), intervalMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [activeIndex, candidates, connection, daemon.serverId, status]);
|
||||
|
||||
return (
|
||||
<SessionProvider
|
||||
key={`${daemon.id}:${activeUrl}`}
|
||||
key={`${daemon.serverId}:${activeUrl}`}
|
||||
serverUrl={activeUrl}
|
||||
serverId={daemon.id}
|
||||
daemonPublicKeyB64={daemon.daemonPublicKeyB64}
|
||||
serverId={daemon.serverId}
|
||||
activeConnection={active?.activeConnection ?? null}
|
||||
daemonPublicKeyB64={active?.daemonPublicKeyB64}
|
||||
>
|
||||
{null}
|
||||
</SessionProvider>
|
||||
@@ -138,7 +202,7 @@ export function MultiDaemonSessionHost() {
|
||||
return (
|
||||
<>
|
||||
{daemons.map((daemon) => (
|
||||
<ManagedDaemonSession key={daemon.id} daemon={daemon} />
|
||||
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -123,6 +123,7 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Pairing link</Text>
|
||||
<InputComponent
|
||||
testID="pair-link-input"
|
||||
value={offerUrl}
|
||||
onChangeText={setOfferUrl}
|
||||
placeholder="https://app.paseo.sh/#offer=..."
|
||||
@@ -136,13 +137,14 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving}>
|
||||
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving} testID="pair-link-cancel">
|
||||
<Text style={styles.buttonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.button, styles.primaryButton, isSaving ? { opacity: 0.7 } : null]}
|
||||
onPress={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
testID="pair-link-submit"
|
||||
>
|
||||
<View style={styles.connectRow}>
|
||||
<Link size={16} color={theme.colors.palette.white} />
|
||||
|
||||
@@ -177,7 +177,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}
|
||||
|
||||
if (voiceEligibleHosts.length === 1) {
|
||||
void startVoice(voiceEligibleHosts[0].daemon.id).catch((error) => {
|
||||
void startVoice(voiceEligibleHosts[0].daemon.serverId).catch((error) => {
|
||||
console.error("[SlidingSidebar] Failed to start voice", error);
|
||||
Alert.alert("Voice failed", "Unable to start Voice mode for this host.");
|
||||
});
|
||||
@@ -188,9 +188,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}, [hasAnyConfiguredHosts, isVoiceMode, startVoice, stopVoice, voiceEligibleHosts]);
|
||||
|
||||
const handleSelectVoiceHost = useCallback(
|
||||
(daemonId: string) => {
|
||||
(serverId: string) => {
|
||||
setShowVoiceHostPicker(false);
|
||||
void startVoice(daemonId).catch((error) => {
|
||||
void startVoice(serverId).catch((error) => {
|
||||
console.error("[SlidingSidebar] Failed to start voice", error);
|
||||
Alert.alert("Voice failed", "Unable to start Voice mode for this host.");
|
||||
});
|
||||
@@ -320,14 +320,19 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-voice"
|
||||
onPress={handleToggleVoice}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<AudioLines
|
||||
size={20}
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-voice"
|
||||
nativeID="sidebar-voice"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Voice"
|
||||
accessibilityRole="button"
|
||||
onPress={handleToggleVoice}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<AudioLines
|
||||
size={20}
|
||||
color={
|
||||
isVoiceMode
|
||||
? theme.colors.foreground
|
||||
@@ -340,6 +345,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettingsMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
@@ -362,9 +373,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
<View style={styles.hostPickerList}>
|
||||
{voiceEligibleHosts.map((entry) => (
|
||||
<Pressable
|
||||
key={entry.daemon.id}
|
||||
key={entry.daemon.serverId}
|
||||
style={styles.hostPickerOption}
|
||||
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
|
||||
onPress={() => handleSelectVoiceHost(entry.daemon.serverId)}
|
||||
>
|
||||
<Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
|
||||
</Pressable>
|
||||
@@ -436,6 +447,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-voice"
|
||||
nativeID="sidebar-voice"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Voice"
|
||||
accessibilityRole="button"
|
||||
onPress={handleToggleVoice}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
@@ -453,6 +469,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettingsDesktop}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
@@ -472,9 +494,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
<View style={styles.hostPickerList}>
|
||||
{voiceEligibleHosts.map((entry) => (
|
||||
<Pressable
|
||||
key={entry.daemon.id}
|
||||
key={entry.daemon.serverId}
|
||||
style={styles.hostPickerOption}
|
||||
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
|
||||
onPress={() => handleSelectVoiceHost(entry.daemon.serverId)}
|
||||
>
|
||||
<Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { Image, Pressable, Text, View, Platform } from "react-native";
|
||||
import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2 } from "lucide-react-native";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { AddHostModal } from "./add-host-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[8],
|
||||
padding: theme.spacing[6],
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
@@ -70,9 +71,15 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="welcome-screen">
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
|
||||
contentContainerStyle={styles.container}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="welcome-screen"
|
||||
>
|
||||
<Image
|
||||
source={require("../../assets/images/icon.png")}
|
||||
style={styles.logo}
|
||||
@@ -85,15 +92,26 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.actionButtonPrimary]}
|
||||
onPress={() => setIsDirectOpen(true)}
|
||||
testID="welcome-direct-connection"
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.palette.white} />
|
||||
<Text style={[styles.actionText, styles.actionTextPrimary]}>Direct connection</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={styles.actionButton}
|
||||
onPress={() => setIsPasteLinkOpen(true)}
|
||||
testID="welcome-paste-pairing-link"
|
||||
>
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<Text style={styles.actionText}>Paste pairing link</Text>
|
||||
</Pressable>
|
||||
|
||||
{Platform.OS !== "web" ? (
|
||||
<Pressable
|
||||
style={styles.actionButton}
|
||||
onPress={() => router.push("/pair-scan")}
|
||||
testID="welcome-scan-qr"
|
||||
>
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<Text style={styles.actionText}>Scan QR code</Text>
|
||||
@@ -106,9 +124,18 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
onClose={() => setIsDirectOpen(false)}
|
||||
onSaved={(profile) => {
|
||||
onHostAdded?.(profile);
|
||||
router.replace({ pathname: "/", params: { serverId: profile.id } });
|
||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<PairLinkModal
|
||||
visible={isPasteLinkOpen}
|
||||
onClose={() => setIsPasteLinkOpen(false)}
|
||||
onSaved={(profile) => {
|
||||
onHostAdded?.(profile);
|
||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context";
|
||||
import { useDaemonRegistry, type HostProfile } from "./daemon-registry-context";
|
||||
|
||||
export type ActiveConnection =
|
||||
| { type: "direct"; endpoint: string; display: string }
|
||||
| { type: "relay"; endpoint: string; display: "relay" };
|
||||
|
||||
export type ConnectionState =
|
||||
| { status: "idle"; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: false }
|
||||
| { status: "connecting"; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
|
||||
| { status: "online"; lastError: null; lastOnlineAt: string; agentListReady: boolean; hasEverReceivedAgentList: boolean }
|
||||
| { status: "offline"; lastError: string | null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
|
||||
| { status: "error"; lastError: string; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean };
|
||||
| { status: "idle"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: false }
|
||||
| { status: "connecting"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
|
||||
| { status: "online"; activeConnection: ActiveConnection | null; lastError: null; lastOnlineAt: string; agentListReady: boolean; hasEverReceivedAgentList: boolean }
|
||||
| { status: "offline"; activeConnection: ActiveConnection | null; lastError: string | null; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean }
|
||||
| { status: "error"; activeConnection: ActiveConnection | null; lastError: string; lastOnlineAt: string | null; agentListReady: false; hasEverReceivedAgentList: boolean };
|
||||
|
||||
export type ConnectionStatus = ConnectionState["status"];
|
||||
|
||||
type ConnectionStateUpdate =
|
||||
| { status: "idle" }
|
||||
| { status: "connecting"; lastOnlineAt?: string | null }
|
||||
| { status: "online"; lastOnlineAt: string; agentListReady?: boolean }
|
||||
| { status: "offline"; lastError?: string | null; lastOnlineAt?: string | null }
|
||||
| { status: "error"; lastError: string; lastOnlineAt?: string | null };
|
||||
| { status: "connecting"; activeConnection?: ActiveConnection | null; lastOnlineAt?: string | null }
|
||||
| { status: "online"; activeConnection?: ActiveConnection | null; lastOnlineAt: string; agentListReady?: boolean }
|
||||
| { status: "offline"; activeConnection?: ActiveConnection | null; lastError?: string | null; lastOnlineAt?: string | null }
|
||||
| { status: "error"; activeConnection?: ActiveConnection | null; lastError: string; lastOnlineAt?: string | null };
|
||||
|
||||
export type DaemonConnectionRecord = {
|
||||
daemon: DaemonProfile;
|
||||
daemon: HostProfile;
|
||||
} & ConnectionState;
|
||||
|
||||
interface DaemonConnectionsContextValue {
|
||||
connectionStates: Map<string, DaemonConnectionRecord>;
|
||||
isLoading: boolean;
|
||||
updateConnectionStatus: (daemonId: string, update: ConnectionStateUpdate) => void;
|
||||
updateConnectionStatus: (serverId: string, update: ConnectionStateUpdate) => void;
|
||||
}
|
||||
|
||||
const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | null>(null);
|
||||
@@ -33,6 +37,7 @@ const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | n
|
||||
function createDefaultConnectionState(): ConnectionState {
|
||||
return {
|
||||
status: "idle",
|
||||
activeConnection: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
agentListReady: false,
|
||||
@@ -48,6 +53,7 @@ function resolveNextConnectionState(
|
||||
case "idle":
|
||||
return {
|
||||
status: "idle",
|
||||
activeConnection: existing.activeConnection ?? null,
|
||||
lastError: null,
|
||||
lastOnlineAt: existing.lastOnlineAt,
|
||||
agentListReady: false,
|
||||
@@ -56,6 +62,7 @@ function resolveNextConnectionState(
|
||||
case "connecting":
|
||||
return {
|
||||
status: "connecting",
|
||||
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
|
||||
lastError: null,
|
||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
agentListReady: false,
|
||||
@@ -66,6 +73,7 @@ function resolveNextConnectionState(
|
||||
|
||||
return {
|
||||
status: "online",
|
||||
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
|
||||
lastError: null,
|
||||
lastOnlineAt: update.lastOnlineAt,
|
||||
agentListReady: currentAgentListReady,
|
||||
@@ -75,6 +83,7 @@ function resolveNextConnectionState(
|
||||
case "offline":
|
||||
return {
|
||||
status: "offline",
|
||||
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
|
||||
lastError: update.lastError ?? null,
|
||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
agentListReady: false,
|
||||
@@ -83,6 +92,7 @@ function resolveNextConnectionState(
|
||||
case "error":
|
||||
return {
|
||||
status: "error",
|
||||
activeConnection: update.activeConnection ?? existing.activeConnection ?? null,
|
||||
lastError: update.lastError,
|
||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
agentListReady: false,
|
||||
@@ -91,13 +101,13 @@ function resolveNextConnectionState(
|
||||
}
|
||||
}
|
||||
|
||||
function logConnectionLifecycle(daemon: DaemonProfile, previous: ConnectionState, next: ConnectionState) {
|
||||
function logConnectionLifecycle(daemon: HostProfile, previous: ConnectionState, next: ConnectionState) {
|
||||
const severity: "info" | "warn" = next.status === "error" ? "warn" : "info";
|
||||
const logger = severity === "warn" ? console.warn : console.info;
|
||||
|
||||
const logPayload = {
|
||||
event: "daemon_connection_state",
|
||||
daemonId: daemon.id,
|
||||
serverId: daemon.serverId,
|
||||
label: daemon.label,
|
||||
from: previous.status,
|
||||
to: next.status,
|
||||
@@ -127,8 +137,8 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
|
||||
setConnectionStates((prev) => {
|
||||
const next = new Map<string, DaemonConnectionRecord>();
|
||||
for (const daemon of daemons) {
|
||||
const existing = prev.get(daemon.id);
|
||||
next.set(daemon.id, {
|
||||
const existing = prev.get(daemon.serverId);
|
||||
next.set(daemon.serverId, {
|
||||
daemon,
|
||||
...(existing ?? createDefaultConnectionState()),
|
||||
});
|
||||
@@ -139,9 +149,9 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
|
||||
|
||||
|
||||
const updateConnectionStatus = useCallback(
|
||||
(daemonId: string, update: ConnectionStateUpdate) => {
|
||||
(serverId: string, update: ConnectionStateUpdate) => {
|
||||
setConnectionStates((prev) => {
|
||||
const existing = prev.get(daemonId);
|
||||
const existing = prev.get(serverId);
|
||||
if (!existing) {
|
||||
return prev;
|
||||
}
|
||||
@@ -156,7 +166,7 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
|
||||
}
|
||||
|
||||
const next = new Map(prev);
|
||||
next.set(daemonId, { daemon: existing.daemon, ...nextState });
|
||||
next.set(serverId, { daemon: existing.daemon, ...nextState });
|
||||
return next;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -3,57 +3,64 @@ import type { ReactNode } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
decodeOfferFragmentPayload,
|
||||
deriveLabelFromEndpoint,
|
||||
extractHostPortFromWebSocketUrl,
|
||||
normalizeHostPort,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import {
|
||||
ConnectionOfferV1Schema,
|
||||
type ConnectionOfferV1,
|
||||
ConnectionOfferSchema,
|
||||
type ConnectionOffer,
|
||||
} from "@server/shared/connection-offer";
|
||||
|
||||
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
const DAEMON_REGISTRY_QUERY_KEY = ["daemon-registry"];
|
||||
|
||||
export type HostRelayConfig = {
|
||||
endpoint: string;
|
||||
sessionId: string;
|
||||
export type DirectHostConnection = {
|
||||
id: string;
|
||||
type: "direct";
|
||||
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 = {
|
||||
id: string;
|
||||
serverId: string;
|
||||
label: string;
|
||||
endpoints: string[];
|
||||
daemonPublicKeyB64?: string;
|
||||
relay?: HostRelayConfig | null;
|
||||
connections: HostConnection[];
|
||||
preferredConnectionId: string | 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">>;
|
||||
export type UpdateHostInput = Partial<Omit<HostProfile, "serverId" | "createdAt">>;
|
||||
|
||||
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>;
|
||||
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>;
|
||||
adoptDaemonServerId: (currentId: string, serverId: string) => Promise<string>;
|
||||
}
|
||||
|
||||
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null);
|
||||
@@ -87,158 +94,170 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
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 adoptDaemonServerId = useCallback(
|
||||
async (currentId: string, serverId: string) => {
|
||||
const trimmed = serverId.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("serverId is required");
|
||||
}
|
||||
if (currentId === trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const existing = readDaemons();
|
||||
const now = new Date().toISOString();
|
||||
const idx = existing.findIndex((daemon) => daemon.id === currentId);
|
||||
if (idx === -1) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const current = existing[idx];
|
||||
const otherIdx = existing.findIndex((daemon) => daemon.id === trimmed);
|
||||
|
||||
const addLegacyId = (metadata: Record<string, unknown> | null | undefined, legacyId: string) => {
|
||||
const prev = (metadata ?? {}) as Record<string, unknown>;
|
||||
const legacyIdsRaw = prev.legacyIds;
|
||||
const legacyIds = Array.isArray(legacyIdsRaw)
|
||||
? legacyIdsRaw.filter((value) => typeof value === "string")
|
||||
: [];
|
||||
if (!legacyIds.includes(legacyId)) {
|
||||
legacyIds.push(legacyId);
|
||||
}
|
||||
return { ...prev, legacyIds };
|
||||
};
|
||||
|
||||
// Merge into existing canonical entry if it exists.
|
||||
if (otherIdx !== -1) {
|
||||
const canonical = existing[otherIdx];
|
||||
const merged: HostProfile = {
|
||||
...canonical,
|
||||
label: canonical.label?.trim() ? canonical.label : current.label,
|
||||
endpoints: Array.from(new Set([...(canonical.endpoints ?? []), ...(current.endpoints ?? [])])),
|
||||
daemonPublicKeyB64: canonical.daemonPublicKeyB64 ?? current.daemonPublicKeyB64,
|
||||
relay: canonical.relay ?? current.relay ?? null,
|
||||
createdAt: canonical.createdAt ?? current.createdAt,
|
||||
updatedAt: now,
|
||||
metadata: addLegacyId(canonical.metadata, currentId),
|
||||
};
|
||||
const next = existing.filter((daemon) => daemon.id !== currentId);
|
||||
next[otherIdx > idx ? otherIdx - 1 : otherIdx] = merged;
|
||||
await persist(next);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const updated: HostProfile = {
|
||||
...current,
|
||||
id: trimmed,
|
||||
updatedAt: now,
|
||||
metadata: addLegacyId(current.metadata, currentId),
|
||||
};
|
||||
|
||||
const next = [...existing];
|
||||
next[idx] = updated;
|
||||
const updateHost = useCallback(
|
||||
async (serverId: string, updates: UpdateHostInput) => {
|
||||
const next = readDaemons().map((daemon) =>
|
||||
daemon.serverId === serverId
|
||||
? {
|
||||
...daemon,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: daemon
|
||||
);
|
||||
await persist(next);
|
||||
return trimmed;
|
||||
},
|
||||
[persist, readDaemons]
|
||||
);
|
||||
|
||||
const upsertDaemonFromOffer = useCallback(
|
||||
async (offer: ConnectionOfferV1) => {
|
||||
const existing = readDaemons();
|
||||
const now = new Date().toISOString();
|
||||
const normalizedEndpoints = offer.endpoints.map((endpoint) => normalizeHostPort(endpoint));
|
||||
let relayEndpoint: string | null = null;
|
||||
if (offer.relay?.endpoint) {
|
||||
relayEndpoint = normalizeHostPort(offer.relay.endpoint);
|
||||
} else if (offer.relay === undefined && normalizedEndpoints.length > 0) {
|
||||
// Back-compat: older offers encoded relay endpoint as the last entry.
|
||||
relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1];
|
||||
}
|
||||
const relay = relayEndpoint
|
||||
? { endpoint: relayEndpoint, sessionId: offer.sessionId }
|
||||
: null;
|
||||
|
||||
const matchIndex = existing.findIndex((daemon) => daemon.daemonPublicKeyB64 === offer.daemonPublicKeyB64);
|
||||
if (matchIndex !== -1) {
|
||||
const updated: HostProfile = {
|
||||
...existing[matchIndex],
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
endpoints: normalizedEndpoints,
|
||||
relay,
|
||||
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,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
const next = [...existing, profile];
|
||||
await persist(next);
|
||||
return profile;
|
||||
const removeHost = useCallback(
|
||||
async (serverId: string) => {
|
||||
const remaining = readDaemons().filter((daemon) => daemon.serverId !== serverId);
|
||||
await persist(remaining);
|
||||
},
|
||||
[persist, readDaemons]
|
||||
);
|
||||
|
||||
const removeConnection = useCallback(
|
||||
async (serverId: string, connectionId: string) => {
|
||||
const now = new Date().toISOString();
|
||||
const next = readDaemons()
|
||||
.map((daemon) => {
|
||||
if (daemon.serverId !== serverId) return daemon;
|
||||
const remaining = daemon.connections.filter((conn) => conn.id !== connectionId);
|
||||
if (remaining.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const preferred =
|
||||
daemon.preferredConnectionId === connectionId
|
||||
? remaining[0]?.id ?? null
|
||||
: daemon.preferredConnectionId;
|
||||
return {
|
||||
...daemon,
|
||||
connections: remaining,
|
||||
preferredConnectionId: preferred,
|
||||
updatedAt: now,
|
||||
} satisfies HostProfile;
|
||||
})
|
||||
.filter((entry): entry is HostProfile => entry !== null);
|
||||
await persist(next);
|
||||
},
|
||||
[persist, readDaemons]
|
||||
);
|
||||
|
||||
const upsertHostConnection = useCallback(
|
||||
async (
|
||||
input: {
|
||||
serverId: string;
|
||||
label?: string;
|
||||
} & (
|
||||
| { connection: DirectHostConnection }
|
||||
| { connection: RelayHostConnection }
|
||||
)
|
||||
) => {
|
||||
const existing = readDaemons();
|
||||
const now = new Date().toISOString();
|
||||
const serverId = input.serverId.trim();
|
||||
if (!serverId) {
|
||||
throw new Error("serverId is required");
|
||||
}
|
||||
|
||||
const labelTrimmed = input.label?.trim() ?? "";
|
||||
const derivedLabel =
|
||||
labelTrimmed ||
|
||||
(input.connection.type === "direct"
|
||||
? deriveLabelFromEndpoint(input.connection.endpoint)
|
||||
: serverId);
|
||||
|
||||
const idx = existing.findIndex((d) => d.serverId === serverId);
|
||||
if (idx === -1) {
|
||||
const profile: HostProfile = {
|
||||
serverId,
|
||||
label: derivedLabel,
|
||||
connections: [input.connection],
|
||||
preferredConnectionId: input.connection.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const next = [...existing, profile];
|
||||
await persist(next);
|
||||
return profile;
|
||||
}
|
||||
|
||||
const prev = existing[idx]!;
|
||||
const connectionIdx = prev.connections.findIndex((c) => c.id === input.connection.id);
|
||||
const nextConnections =
|
||||
connectionIdx === -1
|
||||
? [...prev.connections, input.connection]
|
||||
: prev.connections.map((c, i) => (i === connectionIdx ? input.connection : c));
|
||||
|
||||
const nextProfile: HostProfile = {
|
||||
...prev,
|
||||
label: labelTrimmed ? labelTrimmed : prev.label,
|
||||
connections: nextConnections,
|
||||
preferredConnectionId: prev.preferredConnectionId ?? input.connection.id,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const next = [...existing];
|
||||
next[idx] = nextProfile;
|
||||
await persist(next);
|
||||
return nextProfile;
|
||||
},
|
||||
[persist, readDaemons]
|
||||
);
|
||||
|
||||
const upsertDirectConnection = useCallback(
|
||||
async (input: { serverId: string; endpoint: string; label?: string }) => {
|
||||
const endpoint = normalizeHostPort(input.endpoint);
|
||||
const connection: DirectHostConnection = {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "direct",
|
||||
endpoint,
|
||||
};
|
||||
return upsertHostConnection({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
connection,
|
||||
});
|
||||
},
|
||||
[upsertHostConnection]
|
||||
);
|
||||
|
||||
const upsertRelayConnection = useCallback(
|
||||
async (input: { serverId: string; relayEndpoint: string; daemonPublicKeyB64: string; label?: string }) => {
|
||||
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
|
||||
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim();
|
||||
if (!daemonPublicKeyB64) {
|
||||
throw new Error("daemonPublicKeyB64 is required");
|
||||
}
|
||||
const connection: RelayHostConnection = {
|
||||
id: `relay:${relayEndpoint}`,
|
||||
type: "relay",
|
||||
relayEndpoint,
|
||||
daemonPublicKeyB64,
|
||||
};
|
||||
return upsertHostConnection({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
connection,
|
||||
});
|
||||
},
|
||||
[upsertHostConnection]
|
||||
);
|
||||
|
||||
const upsertDaemonFromOffer = useCallback(
|
||||
async (offer: ConnectionOffer) => {
|
||||
return upsertRelayConnection({
|
||||
serverId: offer.serverId,
|
||||
relayEndpoint: offer.relay.endpoint,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
label: offer.serverId,
|
||||
});
|
||||
},
|
||||
[upsertRelayConnection]
|
||||
);
|
||||
|
||||
const upsertDaemonFromOfferUrl = useCallback(
|
||||
async (offerUrlOrFragment: string) => {
|
||||
const marker = "#offer=";
|
||||
@@ -251,7 +270,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
throw new Error("Offer payload is empty");
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
const offer = ConnectionOfferV1Schema.parse(payload);
|
||||
const offer = ConnectionOfferSchema.parse(payload);
|
||||
return upsertDaemonFromOffer(offer);
|
||||
},
|
||||
[upsertDaemonFromOffer]
|
||||
@@ -261,10 +280,11 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
daemons,
|
||||
isLoading: isPending,
|
||||
error: error ?? null,
|
||||
addDaemon,
|
||||
updateDaemon,
|
||||
removeDaemon,
|
||||
adoptDaemonServerId,
|
||||
upsertDirectConnection,
|
||||
upsertRelayConnection,
|
||||
updateHost,
|
||||
removeHost,
|
||||
removeConnection,
|
||||
upsertDaemonFromOffer,
|
||||
upsertDaemonFromOfferUrl,
|
||||
};
|
||||
@@ -276,200 +296,110 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const LOCAL_DAEMON_LABEL = "Local";
|
||||
|
||||
function getLocalDaemonEndpoint(): string | null {
|
||||
const endpoint = process.env.EXPO_PUBLIC_LOCAL_DAEMON;
|
||||
if (!endpoint || endpoint.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return endpoint.trim();
|
||||
}
|
||||
|
||||
type LegacyDaemonProfile = {
|
||||
type LegacyHostProfileV1 = {
|
||||
id: string;
|
||||
label: string;
|
||||
wsUrl: string;
|
||||
restUrl?: string | null;
|
||||
endpoints?: unknown;
|
||||
daemonPublicKeyB64?: unknown;
|
||||
relay?: unknown;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function isLegacyDaemonProfile(value: unknown): value is LegacyDaemonProfile {
|
||||
function isHostProfileV2(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.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,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertLocalDaemon(hosts: HostProfile[], localEndpoint: string): HostProfile[] {
|
||||
const result = [...hosts];
|
||||
const now = new Date().toISOString();
|
||||
const existingIndex = result.findIndex((host) => host.label === LOCAL_DAEMON_LABEL);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
result[existingIndex] = {
|
||||
...result[existingIndex],
|
||||
endpoints: [normalizeHostPort(localEndpoint)],
|
||||
updatedAt: now,
|
||||
};
|
||||
} else {
|
||||
result.unshift({
|
||||
id: generateDaemonId(),
|
||||
label: LOCAL_DAEMON_LABEL,
|
||||
endpoints: [normalizeHostPort(localEndpoint)],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: null,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return (
|
||||
typeof obj.serverId === "string" &&
|
||||
typeof obj.label === "string" &&
|
||||
Array.isArray(obj.connections) &&
|
||||
typeof obj.createdAt === "string" &&
|
||||
typeof obj.updatedAt === "string"
|
||||
);
|
||||
}
|
||||
|
||||
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
||||
try {
|
||||
const localEndpoint = getLocalDaemonEndpoint();
|
||||
|
||||
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 && !localEndpoint) {
|
||||
return [];
|
||||
const v2 = parsed.filter((entry) => isHostProfileV2(entry)) as HostProfile[];
|
||||
if (v2.length === parsed.length) {
|
||||
return v2;
|
||||
}
|
||||
|
||||
const hasLegacy = parsed.some((entry) => isLegacyDaemonProfile(entry));
|
||||
const hasNew = parsed.some((entry) => isHostProfile(entry));
|
||||
// Hard migration from the previous in-repo schema (v1 HostProfile with `id/endpoints/relay`).
|
||||
const migrated: HostProfile[] = parsed
|
||||
.map((entry): HostProfile | null => {
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const obj = entry as LegacyHostProfileV1;
|
||||
if (typeof obj.id !== "string" || typeof obj.label !== "string") return null;
|
||||
|
||||
if (hasNew && !hasLegacy) {
|
||||
const hosts = parsed as HostProfile[];
|
||||
if (localEndpoint) {
|
||||
const merged = upsertLocalDaemon(hosts, localEndpoint);
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(merged));
|
||||
return merged;
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
// Only keep stable daemon ids; discard transient entries to avoid confusing host selection.
|
||||
if (!obj.id.startsWith("srv_")) return null;
|
||||
|
||||
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;
|
||||
const now = new Date().toISOString();
|
||||
const createdAt = typeof obj.createdAt === "string" ? obj.createdAt : now;
|
||||
const updatedAt = typeof obj.updatedAt === "string" ? obj.updatedAt : now;
|
||||
|
||||
const connections: HostConnection[] = [];
|
||||
|
||||
if (Array.isArray(obj.endpoints)) {
|
||||
for (const endpointRaw of obj.endpoints) {
|
||||
try {
|
||||
const endpoint = normalizeHostPort(String(endpointRaw));
|
||||
connections.push({ id: `direct:${endpoint}`, type: "direct", endpoint });
|
||||
} catch {
|
||||
// ignore invalid endpoint
|
||||
}
|
||||
}
|
||||
return { ...entry, endpoints } as HostProfile;
|
||||
}
|
||||
if (isLegacyDaemonProfile(entry)) {
|
||||
|
||||
const relayEndpointRaw =
|
||||
obj.relay && typeof (obj.relay as any)?.endpoint === "string"
|
||||
? String((obj.relay as any).endpoint)
|
||||
: null;
|
||||
const daemonPublicKeyB64 =
|
||||
typeof obj.daemonPublicKeyB64 === "string" ? obj.daemonPublicKeyB64.trim() : "";
|
||||
|
||||
if (relayEndpointRaw && daemonPublicKeyB64) {
|
||||
try {
|
||||
return migrateLegacyToHostProfile(entry);
|
||||
const relayEndpoint = normalizeHostPort(relayEndpointRaw);
|
||||
connections.push({
|
||||
id: `relay:${relayEndpoint}`,
|
||||
type: "relay",
|
||||
relayEndpoint,
|
||||
daemonPublicKeyB64,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
// ignore invalid relay endpoint
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
if (connections.length === 0) return null;
|
||||
|
||||
const preferredConnectionId: string | null = connections[0]?.id ?? null;
|
||||
|
||||
return {
|
||||
serverId: obj.id,
|
||||
label: obj.label,
|
||||
connections,
|
||||
preferredConnectionId,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is HostProfile => entry !== null);
|
||||
|
||||
if (migrated.length > 0) {
|
||||
const merged = localEndpoint ? upsertLocalDaemon(migrated, localEndpoint) : migrated;
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(merged));
|
||||
return merged;
|
||||
}
|
||||
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)];
|
||||
const merged = localEndpoint ? upsertLocalDaemon(migrated, localEndpoint) : migrated;
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(merged));
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
if (localEndpoint) {
|
||||
const hosts = upsertLocalDaemon([], localEndpoint);
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(hosts));
|
||||
return hosts;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("[DaemonRegistry] Failed to load daemon registry", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDirectDaemonWsUrl(profile: HostProfile): string {
|
||||
const endpoint = profile.endpoints[0];
|
||||
if (!endpoint) {
|
||||
throw new Error("Host profile has no endpoints");
|
||||
}
|
||||
return buildDaemonWebSocketUrl(endpoint);
|
||||
}
|
||||
|
||||
export function resolveCanonicalDaemonId(daemons: HostProfile[], id: string): string | null {
|
||||
const direct = daemons.find((daemon) => daemon.id === id);
|
||||
if (direct) return direct.id;
|
||||
|
||||
for (const daemon of daemons) {
|
||||
const legacyIds = (daemon.metadata as any)?.legacyIds;
|
||||
if (Array.isArray(legacyIds) && legacyIds.some((value) => value === id)) {
|
||||
return daemon.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useDaemonClient } from "@/hooks/use-daemon-client";
|
||||
import { useAudioPlayer } from "@/hooks/use-audio-player";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
@@ -23,7 +22,7 @@ import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-type
|
||||
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
|
||||
import { File } from "expo-file-system";
|
||||
import { useDaemonConnections } from "./daemon-connections-context";
|
||||
import { useDaemonRegistry } from "./daemon-registry-context";
|
||||
import type { ActiveConnection } from "./daemon-connections-context";
|
||||
import {
|
||||
useSessionStore,
|
||||
type SessionState,
|
||||
@@ -258,27 +257,24 @@ interface SessionProviderProps {
|
||||
children: ReactNode;
|
||||
serverUrl: string;
|
||||
serverId: string;
|
||||
activeConnection: ActiveConnection | null;
|
||||
daemonPublicKeyB64?: string;
|
||||
}
|
||||
|
||||
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
||||
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
||||
|
||||
// SessionProvider: Daemon client message handler that updates Zustand store
|
||||
export function SessionProvider({
|
||||
children,
|
||||
serverUrl,
|
||||
serverId,
|
||||
activeConnection,
|
||||
daemonPublicKeyB64,
|
||||
}: SessionProviderProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 });
|
||||
const [connectionSnapshot, setConnectionSnapshot] =
|
||||
useState<DaemonConnectionSnapshot>(() =>
|
||||
mapConnectionState(client.getConnectionState(), client.lastError)
|
||||
);
|
||||
const { updateConnectionStatus } = useDaemonConnections();
|
||||
const { adoptDaemonServerId } = useDaemonRegistry();
|
||||
|
||||
// Zustand store actions
|
||||
const initializeSession = useSessionStore((state) => state.initializeSession);
|
||||
@@ -357,7 +353,6 @@ export function SessionProvider({
|
||||
);
|
||||
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
||||
const appStateRef = useRef(AppState.currentState);
|
||||
const hasAdoptedServerIdRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||
@@ -452,47 +447,6 @@ export function SessionProvider({
|
||||
return unsubscribe;
|
||||
}, [client]);
|
||||
|
||||
// Daemon identity handshake: adopt stable serverId from the daemon.
|
||||
useEffect(() => {
|
||||
return client.on("status", (message) => {
|
||||
if (message.type !== "status") return;
|
||||
const payload = message.payload as { status?: unknown; serverId?: unknown };
|
||||
if (payload?.status !== "server_info") return;
|
||||
|
||||
const remoteServerId = typeof payload.serverId === "string" ? payload.serverId.trim() : "";
|
||||
if (!remoteServerId) return;
|
||||
|
||||
// Avoid loops if multiple server_info messages arrive.
|
||||
if (hasAdoptedServerIdRef.current) return;
|
||||
hasAdoptedServerIdRef.current = true;
|
||||
|
||||
if (remoteServerId === serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const canonicalId = await adoptDaemonServerId(serverId, remoteServerId);
|
||||
|
||||
// Migrate persisted "create agent" preferences if they reference the old id.
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(FORM_PREFERENCES_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as { serverId?: unknown } & Record<string, unknown>;
|
||||
if (typeof parsed.serverId === "string" && parsed.serverId === serverId) {
|
||||
const next = { ...parsed, serverId: canonicalId };
|
||||
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||
queryClient.setQueryData(FORM_PREFERENCES_QUERY_KEY, next);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[Session] Failed to migrate form preferences serverId", { error });
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.warn("[Session] Failed to adopt daemon serverId", { error });
|
||||
});
|
||||
});
|
||||
}, [adoptDaemonServerId, client, queryClient, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
updateSessionConnection(serverId, connectionSnapshot);
|
||||
}, [serverId, connectionSnapshot, updateSessionConnection]);
|
||||
@@ -511,31 +465,34 @@ export function SessionProvider({
|
||||
if (connectionSnapshot.isConnected) {
|
||||
updateConnectionStatus(serverId, {
|
||||
status: "online",
|
||||
activeConnection,
|
||||
lastOnlineAt: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionSnapshot.isConnecting) {
|
||||
updateConnectionStatus(serverId, { status: "connecting" });
|
||||
updateConnectionStatus(serverId, { status: "connecting", activeConnection });
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionSnapshot.lastError) {
|
||||
updateConnectionStatus(serverId, {
|
||||
status: "error",
|
||||
activeConnection,
|
||||
lastError: connectionSnapshot.lastError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateConnectionStatus(serverId, { status: "offline" });
|
||||
updateConnectionStatus(serverId, { status: "offline", activeConnection });
|
||||
}, [
|
||||
serverId,
|
||||
updateConnectionStatus,
|
||||
connectionSnapshot.isConnected,
|
||||
connectionSnapshot.isConnecting,
|
||||
connectionSnapshot.lastError,
|
||||
activeConnection,
|
||||
]);
|
||||
|
||||
// If the client drops mid-initialization, clear pending flags
|
||||
@@ -547,9 +504,9 @@ export function SessionProvider({
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateConnectionStatus(serverId, { status: "offline", lastError: null });
|
||||
updateConnectionStatus(serverId, { status: "offline", activeConnection, lastError: null });
|
||||
};
|
||||
}, [serverId, updateConnectionStatus]);
|
||||
}, [serverId, updateConnectionStatus, activeConnection]);
|
||||
|
||||
const updateExplorerState = useCallback(
|
||||
(agentId: string, updater: (state: any) => any) => {
|
||||
|
||||
@@ -245,7 +245,7 @@ export function useAgentFormState(
|
||||
|
||||
// Build a set of valid server IDs for preference validation
|
||||
const validServerIds = useMemo(
|
||||
() => new Set(daemons.map((d) => d.id)),
|
||||
() => new Set(daemons.map((d) => d.serverId)),
|
||||
[daemons]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as ExpoCrypto from "expo-crypto";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
declare global {
|
||||
interface Crypto {
|
||||
@@ -7,32 +8,43 @@ declare global {
|
||||
}
|
||||
|
||||
export function polyfillCrypto(): void {
|
||||
let webcrypto: Crypto | null = null;
|
||||
try {
|
||||
// Prefer the React Native entrypoint to avoid the browser shim.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const rnModule = require("@sphereon/isomorphic-webcrypto/src/react-native");
|
||||
webcrypto = (rnModule?.default ?? rnModule) as Crypto;
|
||||
} catch {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fallbackModule = require("@sphereon/isomorphic-webcrypto");
|
||||
webcrypto = (fallbackModule?.default ?? fallbackModule) as Crypto;
|
||||
// Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport).
|
||||
// Hermes may not provide them in all configurations.
|
||||
if (typeof (globalThis as any).TextEncoder !== "function") {
|
||||
class BufferTextEncoder {
|
||||
encode(input = ""): Uint8Array {
|
||||
return Uint8Array.from(Buffer.from(String(input), "utf8"));
|
||||
}
|
||||
}
|
||||
(globalThis as any).TextEncoder = BufferTextEncoder as any;
|
||||
}
|
||||
|
||||
if (typeof (globalThis as any).TextDecoder !== "function") {
|
||||
class BufferTextDecoder {
|
||||
constructor(_label?: string, _options?: unknown) {
|
||||
// no-op
|
||||
}
|
||||
decode(input?: ArrayBuffer | ArrayBufferView): string {
|
||||
if (input == null) return "";
|
||||
if (input instanceof ArrayBuffer) {
|
||||
return Buffer.from(input).toString("utf8");
|
||||
}
|
||||
if (ArrayBuffer.isView(input)) {
|
||||
return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString("utf8");
|
||||
}
|
||||
return Buffer.from(String(input), "utf8").toString("utf8");
|
||||
}
|
||||
}
|
||||
(globalThis as any).TextDecoder = BufferTextDecoder as any;
|
||||
}
|
||||
|
||||
const existing = (globalThis as any).crypto as Crypto | null | undefined;
|
||||
let target = existing;
|
||||
if (!target || typeof (target as Crypto).subtle === "undefined") {
|
||||
target = (webcrypto && typeof webcrypto === "object" ? (webcrypto as Crypto) : undefined) ?? ({} as Crypto);
|
||||
if (!target) {
|
||||
target = {} as Crypto;
|
||||
(globalThis as any).crypto = target;
|
||||
}
|
||||
|
||||
const ensureSecure = (globalThis as any).crypto?.ensureSecure as
|
||||
| (() => Promise<void>)
|
||||
| undefined;
|
||||
if (typeof ensureSecure === "function") {
|
||||
void ensureSecure();
|
||||
}
|
||||
|
||||
if (typeof (globalThis as any).crypto?.randomUUID !== "function") {
|
||||
if (!globalThis.crypto) {
|
||||
(globalThis as any).crypto = {} as Crypto;
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
checkoutStatusQueryKey,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { resolveCanonicalDaemonId, useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
@@ -151,29 +151,12 @@ export function DraftAgentScreen({
|
||||
if (daemons.length === 0) return [];
|
||||
const out: string[] = [];
|
||||
for (const daemon of daemons) {
|
||||
const status = connectionStates.get(daemon.id)?.status ?? "idle";
|
||||
if (status === "online") out.push(daemon.id);
|
||||
const status = connectionStates.get(daemon.serverId)?.status ?? "idle";
|
||||
if (status === "online") out.push(daemon.serverId);
|
||||
}
|
||||
return out;
|
||||
}, [connectionStates, daemons]);
|
||||
|
||||
// If the URL contains a legacy host id, redirect to the canonical daemon-provided id.
|
||||
useEffect(() => {
|
||||
if (!resolvedServerId) return;
|
||||
const canonical = resolveCanonicalDaemonId(daemons, resolvedServerId);
|
||||
if (!canonical || canonical === resolvedServerId) return;
|
||||
|
||||
const nextParams: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (key === "serverId") continue;
|
||||
if (typeof value === "string") nextParams[key] = value;
|
||||
if (Array.isArray(value) && typeof value[0] === "string") nextParams[key] = value[0] as string;
|
||||
}
|
||||
nextParams.serverId = canonical;
|
||||
|
||||
router.replace({ pathname: "/", params: nextParams as any });
|
||||
}, [daemons, params, resolvedServerId, router]);
|
||||
|
||||
const initialValues = useMemo((): CreateAgentInitialValues => {
|
||||
const values: CreateAgentInitialValues = {};
|
||||
if (resolvedWorkingDir) {
|
||||
@@ -830,7 +813,7 @@ export function DraftAgentScreen({
|
||||
return (
|
||||
<WelcomeScreen
|
||||
onHostAdded={(profile) => {
|
||||
setSelectedServerIdFromUser(profile.id);
|
||||
setSelectedServerIdFromUser(profile.serverId);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -935,17 +918,17 @@ export function DraftAgentScreen({
|
||||
) : (
|
||||
<View style={styles.dropdownSheetList}>
|
||||
{Array.from(connectionStates.values()).map(({ daemon, status }) => {
|
||||
const isSelected = daemon.id === selectedServerId;
|
||||
const label = daemon.label ?? daemon.endpoints?.[0] ?? daemon.id;
|
||||
const isSelected = daemon.serverId === selectedServerId;
|
||||
const label = daemon.label?.trim() ? daemon.label : daemon.serverId;
|
||||
return (
|
||||
<Pressable
|
||||
key={daemon.id}
|
||||
key={daemon.serverId}
|
||||
style={[
|
||||
styles.dropdownSheetOption,
|
||||
isSelected && styles.dropdownSheetOptionSelected,
|
||||
]}
|
||||
onPress={() => {
|
||||
setSelectedServerIdFromUser(daemon.id);
|
||||
setSelectedServerIdFromUser(daemon.serverId);
|
||||
closeDropdown();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Platform } from "react-native";
|
||||
import { File as FSFile, Paths } from "expo-file-system";
|
||||
import * as LegacyFileSystem from "expo-file-system/legacy";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import type { DaemonProfile } from "@/contexts/daemon-registry-context";
|
||||
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||
|
||||
interface DownloadProgress {
|
||||
@@ -34,7 +34,7 @@ interface DownloadState {
|
||||
agentId: string;
|
||||
fileName: string;
|
||||
path: string;
|
||||
daemonProfile: DaemonProfile | undefined;
|
||||
daemonProfile: HostProfile | undefined;
|
||||
requestFileDownloadToken: (
|
||||
agentId: string,
|
||||
path: string
|
||||
@@ -246,8 +246,9 @@ type DownloadTarget = {
|
||||
authCredentials: { username: string; password: string } | null;
|
||||
};
|
||||
|
||||
function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget {
|
||||
const endpoint = daemon?.endpoints?.[0] ?? null;
|
||||
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
|
||||
const endpoint =
|
||||
daemon?.connections.find((conn) => conn.type === "direct")?.endpoint ?? null;
|
||||
if (!endpoint) {
|
||||
return { baseUrl: null, authHeader: null, authCredentials: null };
|
||||
}
|
||||
|
||||
@@ -30,6 +30,6 @@ export function decodeOfferFragmentPayload(encoded: string): unknown {
|
||||
return JSON.parse(json) as unknown;
|
||||
}
|
||||
|
||||
export function buildRelayWebSocketUrl(params: { endpoint: string; sessionId: string }): string {
|
||||
export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string {
|
||||
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
|
||||
}
|
||||
|
||||
@@ -38,10 +38,10 @@ export class DaemonConnectionTestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export async function testDaemonEndpointConnection(
|
||||
export async function probeDaemonEndpoint(
|
||||
endpoint: string,
|
||||
options?: { timeoutMs?: number }
|
||||
): Promise<void> {
|
||||
): Promise<{ serverId: string }> {
|
||||
const timeoutMs = options?.timeoutMs ?? 6000;
|
||||
const url = buildDaemonWebSocketUrl(endpoint);
|
||||
|
||||
@@ -51,20 +51,26 @@ export async function testDaemonEndpointConnection(
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
return await new Promise<{ serverId: string }>((resolve, reject) => {
|
||||
let cleanedUp = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let unsubscribeStatus: (() => void) | null = null;
|
||||
let isConnected = false;
|
||||
let serverId: string | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
clearTimeout(timeout);
|
||||
unsubscribe?.();
|
||||
unsubscribeStatus?.();
|
||||
};
|
||||
|
||||
const finishOk = () => {
|
||||
const maybeFinishOk = () => {
|
||||
if (!isConnected) return;
|
||||
if (!serverId) return;
|
||||
cleanup();
|
||||
resolve();
|
||||
resolve({ serverId });
|
||||
};
|
||||
|
||||
const finishErr = (error: Error) => {
|
||||
@@ -83,7 +89,8 @@ export async function testDaemonEndpointConnection(
|
||||
|
||||
unsubscribe = client.subscribeConnectionStatus((state) => {
|
||||
if (state.status === "connected") {
|
||||
finishOk();
|
||||
isConnected = true;
|
||||
maybeFinishOk();
|
||||
return;
|
||||
}
|
||||
if (state.status === "disconnected") {
|
||||
@@ -94,6 +101,16 @@ export async function testDaemonEndpointConnection(
|
||||
}
|
||||
});
|
||||
|
||||
unsubscribeStatus = client.on("status", (message) => {
|
||||
if (message.type !== "status") return;
|
||||
const payload = message.payload as { status?: unknown; serverId?: unknown };
|
||||
if (payload?.status !== "server_info") return;
|
||||
const raw = typeof payload.serverId === "string" ? payload.serverId.trim() : "";
|
||||
if (!raw) return;
|
||||
serverId = raw;
|
||||
maybeFinishOk();
|
||||
});
|
||||
|
||||
void client.connect().catch(() => undefined);
|
||||
});
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user