diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index e374764c6..78845bea3 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -8,7 +8,8 @@ import type { BarcodeScanningResult } from "expo-camera"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { useSessionStore } from "@/stores/session-store"; import { NameHostModal } from "@/components/name-host-modal"; -import { decodeOfferFragmentPayload } from "@/utils/daemon-endpoints"; +import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; +import { probeConnection } from "@/utils/test-daemon-connection"; import { ConnectionOfferSchema } from "@server/shared/connection-offer"; const styles = StyleSheet.create((theme) => ({ @@ -217,6 +218,16 @@ export default function PairScanScreen() { return; } + await probeConnection( + { + id: "probe", + type: "relay", + relayEndpoint: normalizeHostPort(offer.relay.endpoint), + daemonPublicKeyB64: offer.daemonPublicKeyB64, + }, + { serverId: offer.serverId }, + ); + const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId); const profile = await upsertDaemonFromOfferUrl(offerUrl); diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index 64d146e66..a9f748952 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -4,7 +4,6 @@ import { View, Text, ScrollView, - TextInput, Pressable, Alert, Platform, @@ -13,11 +12,13 @@ import { router, useLocalSearchParams } from "expo-router"; import Constants from "expo-constants"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { Sun, Moon, Monitor, MoreVertical, Globe, Trash2, Pencil, RotateCw } from "lucide-react-native"; +import { useQueries } from "@tanstack/react-query"; +import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; -import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context"; +import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context"; import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; +import { measureConnectionLatency } from "@/utils/test-daemon-connection"; import { theme as defaultTheme } from "@/styles/theme"; import { MenuHeader } from "@/components/headers/menu-header"; import { useSessionStore } from "@/stores/session-store"; @@ -25,15 +26,13 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; import { PairLinkModal } from "@/components/pair-link-modal"; import { NameHostModal } from "@/components/name-host-modal"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Button } from "@/components/ui/button"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; const delay = (ms: number) => @@ -181,15 +180,24 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, flexShrink: 1, }, - menuButton: { - width: 36, - height: 32, - borderRadius: theme.borderRadius.md, - alignItems: "center", - justifyContent: "center", + hostCardPressed: { + opacity: 0.85, }, - menuButtonPressed: { - backgroundColor: theme.colors.surface3, + advancedTrigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: "transparent", + }, + advancedTriggerText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, }, disabled: { opacity: theme.opacity[50], @@ -537,9 +545,19 @@ export default function SettingsScreen() { ); const handleRemoveDaemon = useCallback((profile: HostProfile) => { + setEditingDaemon(null); setPendingRemoveHost(profile); }, []); + const handleAddConnectionFromModal = useCallback(() => { + if (!editingDaemon) return; + const serverId = editingDaemon.serverId; + setEditingDaemon(null); + setAddConnectionTargetServerId(serverId); + setPendingEditReopenServerId(serverId); + setIsAddHostMethodVisible(true); + }, [editingDaemon]); + const handleThemeChange = useCallback( (newTheme: AppSettings["theme"]) => { void updateSettings({ theme: newTheme }); @@ -619,11 +637,7 @@ export default function SettingsScreen() { connectionStatus={connectionStatus} activeConnection={activeConnection} lastError={lastConnectionError} - onEdit={handleEditDaemon} - onRemove={handleRemoveDaemon} - restartConfirmationMessage={restartConfirmationMessage} - waitForCondition={waitForCondition} - isScreenMountedRef={isMountedRef} + onPress={handleEditDaemon} /> ); }) @@ -748,13 +762,21 @@ export default function SettingsScreen() { ) : null} - void handleSaveEditDaemon(label)} onRemoveConnection={handleRemoveConnection} + onRemoveHost={handleRemoveDaemon} + onAddConnection={handleAddConnectionFromModal} + restartConfirmationMessage={restartConfirmationMessage} + waitForCondition={waitForCondition} + isScreenMountedRef={isMountedRef} /> {/* Appearance */} @@ -816,25 +838,185 @@ export default function SettingsScreen() { ); } -interface EditHostModalProps { +interface HostDetailModalProps { visible: boolean; host: HostProfile | null; + connectionStatus: ConnectionStatus; + activeConnection: ActiveConnection | null; + lastError: string | null; isSaving: boolean; onClose: () => void; onSave: (label: string) => void; onRemoveConnection: (serverId: string, connectionId: string) => Promise; + onRemoveHost: (host: HostProfile) => void; + onAddConnection: () => void; + restartConfirmationMessage: string; + waitForCondition: (predicate: () => boolean, timeoutMs: number, intervalMs?: number) => Promise; + isScreenMountedRef: MutableRefObject; } -function EditHostModal({ +function HostDetailModal({ visible, host, + connectionStatus, + activeConnection, + lastError, isSaving, onClose, onSave, onRemoveConnection, -}: EditHostModalProps) { + onRemoveHost, + onAddConnection, + restartConfirmationMessage, + waitForCondition, + isScreenMountedRef, +}: HostDetailModalProps) { + const { theme } = useUnistyles(); const [draftLabel, setDraftLabel] = useState(""); const activeServerIdRef = useRef(null); + const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null); + const [isRemovingConnection, setIsRemovingConnection] = useState(false); + + // Latency probes for each connection + const connections = host?.connections ?? []; + const latencyQueries = useQueries({ + queries: connections.map((conn) => ({ + queryKey: ["connection-latency", conn.id], + queryFn: () => measureConnectionLatency(conn, { serverId: host?.serverId }), + enabled: visible, + refetchInterval: 5_000, + staleTime: 4_000, + gcTime: 60_000, + retry: 1, + })), + }); + const latencyByConnectionId = new Map( + connections.map((conn, i) => [conn.id, latencyQueries[i]] as const) + ); + + // Restart logic (moved from DaemonCard) + const daemonClient = useSessionStore((state) => host ? (state.sessions[host.serverId]?.client ?? null) : null); + const daemonConnection = useSessionStore((state) => host ? (state.sessions[host.serverId]?.connection ?? null) : null); + const isConnected = daemonConnection?.isConnected ?? false; + const isConnectedRef = useRef(isConnected); + const [isRestarting, setIsRestarting] = useState(false); + + useEffect(() => { + isConnectedRef.current = isConnected; + }, [isConnected]); + + const waitForDaemonRestart = useCallback(async () => { + const disconnectTimeoutMs = 7000; + const reconnectTimeoutMs = 30000; + + if (isConnectedRef.current) { + await waitForCondition(() => !isConnectedRef.current, disconnectTimeoutMs); + } + + const reconnected = await waitForCondition(() => isConnectedRef.current, reconnectTimeoutMs); + + if (isScreenMountedRef.current) { + setIsRestarting(false); + if (!reconnected && host) { + Alert.alert( + "Unable to reconnect", + `${host.label} did not come back online. Please verify it restarted.` + ); + } + } + }, [host, isScreenMountedRef, waitForCondition]); + + const beginServerRestart = useCallback(() => { + if (!daemonClient || !host) return; + + if (!isConnectedRef.current) { + Alert.alert( + "Host offline", + "This host is offline. Paseo reconnects automatically—wait until it's back online before restarting." + ); + return; + } + + setIsRestarting(true); + void daemonClient + .restartServer(`settings_daemon_restart_${host.serverId}`) + .catch((error) => { + console.error(`[Settings] Failed to restart daemon ${host.label}`, error); + if (!isScreenMountedRef.current) return; + setIsRestarting(false); + Alert.alert( + "Error", + "Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online." + ); + }); + + void waitForDaemonRestart(); + }, [daemonClient, host, isScreenMountedRef, waitForDaemonRestart]); + + const handleRestartPress = useCallback(() => { + if (!daemonClient || !host) { + Alert.alert( + "Host unavailable", + "This host is not connected. Wait for it to come online before restarting." + ); + return; + } + + if (Platform.OS === "web") { + const hasBrowserConfirm = + typeof globalThis !== "undefined" && + typeof (globalThis as any).confirm === "function"; + + const confirmed = hasBrowserConfirm + ? (globalThis as any).confirm(`Restart ${host.label}? ${restartConfirmationMessage}`) + : true; + + if (confirmed) { + beginServerRestart(); + } + return; + } + + Alert.alert(`Restart ${host.label}`, restartConfirmationMessage, [ + { text: "Cancel", style: "cancel" }, + { + text: "Restart", + style: "destructive", + onPress: beginServerRestart, + }, + ]); + }, [beginServerRestart, daemonClient, host, restartConfirmationMessage]); + + // Status display + const statusLabel = formatConnectionStatus(connectionStatus); + const statusTone = getConnectionStatusTone(connectionStatus); + const statusColor = + statusTone === "success" + ? theme.colors.palette.green[400] + : statusTone === "warning" + ? theme.colors.palette.amber[500] + : statusTone === "error" + ? theme.colors.destructive + : theme.colors.foregroundMuted; + const statusPillBg = + statusTone === "success" + ? "rgba(74, 222, 128, 0.1)" + : statusTone === "warning" + ? "rgba(245, 158, 11, 0.1)" + : statusTone === "error" + ? "rgba(248, 113, 113, 0.1)" + : "rgba(161, 161, 170, 0.1)"; + const connectionBadge = (() => { + if (!activeConnection) return null; + if (activeConnection.type === "relay") { + return { icon: , text: "Relay" }; + } + return { + icon: , + text: activeConnection.display, + }; + })(); + const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; useEffect(() => { if (!visible || !host) return; @@ -851,88 +1033,248 @@ function EditHostModal({ useEffect(() => { if (!visible) { activeServerIdRef.current = null; + setIsRestarting(false); } }, [visible]); return ( - - - Label - - + <> + + {/* Status row */} + + + + {statusLabel} + + {connectionBadge ? ( + + {connectionBadge.icon} + + {connectionBadge.text} + + + ) : null} + + {connectionError ? ( + + {connectionError} + + ) : null} - {host ? ( + {/* Label */} - Connections - - {host.connections.map((conn) => { - const title = - conn.type === "relay" - ? `Relay (${conn.relayEndpoint})` - : `Direct (${conn.endpoint})`; - return ( - Label + + + + {/* Connections */} + {host ? ( + + Connections + + {host.connections.map((conn) => { + const latency = latencyByConnectionId.get(conn.id); + return ( + { + const title = + conn.type === "relay" + ? `Relay (${conn.relayEndpoint})` + : `Direct (${conn.endpoint})`; + setPendingRemoveConnection({ serverId: host.serverId, connectionId: conn.id, title }); + }} + /> + ); + })} + + + Add connection + + + + ) : null} + + {/* Save/Cancel + Advanced */} + + + + [ + styles.advancedTrigger, + pressed && { opacity: 0.85 }, + ]} + > + + Advanced + + + } + status={isRestarting ? "pending" : "idle"} + pendingLabel="Restarting..." + disabled={!daemonClient || !isConnectedRef.current} > - - {title} - - void onRemoveConnection(host.serverId, conn.id)} - > - - Remove - - - - ); - })} + Restart daemon + + { if (host) onRemoveHost(host); }} + leading={} + > + Remove host + + + + + + + + - ) : null} + - - - - - + + Remove {pendingRemoveConnection.title}? This cannot be undone. + + + + + + + ) : null} + + ); +} + +function ConnectionRow({ + connection, + latencyMs, + latencyLoading, + latencyError, + onRemove, +}: { + connection: HostConnection; + latencyMs: number | null | undefined; + latencyLoading: boolean; + latencyError: boolean; + onRemove: () => void; +}) { + const { theme } = useUnistyles(); + const title = + connection.type === "relay" + ? `Relay (${connection.relayEndpoint})` + : `Direct (${connection.endpoint})`; + + const latencyText = (() => { + if (latencyLoading) return "..."; + if (latencyError) return "Timeout"; + if (latencyMs != null) return `${latencyMs}ms`; + return "\u2014"; + })(); + + const latencyColor = + latencyError + ? theme.colors.palette.red[300] + : theme.colors.foregroundMuted; + + return ( + + + {title} + + + {latencyText} + + + + Remove + + + ); } @@ -941,11 +1283,7 @@ interface DaemonCardProps { connectionStatus: ConnectionStatus; activeConnection: ActiveConnection | null; lastError: string | null; - onEdit: (daemon: HostProfile) => void; - onRemove: (daemon: HostProfile) => void; - restartConfirmationMessage: string; - waitForCondition: (predicate: () => boolean, timeoutMs: number, intervalMs?: number) => Promise; - isScreenMountedRef: MutableRefObject; + onPress: (daemon: HostProfile) => void; } function DaemonCard({ @@ -953,11 +1291,7 @@ function DaemonCard({ connectionStatus, activeConnection, lastError, - onEdit, - onRemove, - restartConfirmationMessage, - waitForCondition, - isScreenMountedRef, + onPress, }: DaemonCardProps) { const { theme } = useUnistyles(); const statusLabel = formatConnectionStatus(connectionStatus); @@ -972,111 +1306,6 @@ function DaemonCard({ : theme.colors.foregroundMuted; const badgeText = statusLabel; const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; - const daemonConnection = useSessionStore( - (state) => state.sessions[daemon.serverId]?.connection ?? 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); - - useEffect(() => { - isConnectedRef.current = isConnected; - }, [isConnected]); - - const waitForDaemonRestart = useCallback(async () => { - const disconnectTimeoutMs = 7000; - const reconnectTimeoutMs = 30000; - - // Wait for disconnect first - if (isConnectedRef.current) { - await waitForCondition(() => !isConnectedRef.current, disconnectTimeoutMs); - } - - // Wait for auto-reconnect - const reconnected = await waitForCondition(() => isConnectedRef.current, reconnectTimeoutMs); - - if (isScreenMountedRef.current) { - setIsRestarting(false); - if (!reconnected) { - Alert.alert( - "Unable to reconnect", - `${daemon.label} did not come back online. Please verify it restarted.` - ); - } - } - }, [daemon.label, isScreenMountedRef, waitForCondition]); - - const beginServerRestart = useCallback(() => { - if (!daemonClient) { - Alert.alert( - "Host unavailable", - `${daemon.label} is not connected. Wait for it to come online before restarting.` - ); - return; - } - - if (!isConnectedRef.current) { - Alert.alert( - "Host offline", - "This host is offline. Paseo reconnects automatically—wait until it's back online before restarting." - ); - return; - } - - setIsRestarting(true); - void daemonClient - .restartServer(`settings_daemon_restart_${daemon.serverId}`) - .catch((error) => { - console.error(`[Settings] Failed to restart daemon ${daemon.label}`, error); - if (!isScreenMountedRef.current) { - return; - } - setIsRestarting(false); - Alert.alert( - "Error", - "Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online." - ); - }); - - void waitForDaemonRestart(); - }, [daemon.label, daemon.serverId, daemonClient, isScreenMountedRef, waitForDaemonRestart]); - - const handleRestartPress = useCallback(() => { - if (!daemonClient) { - Alert.alert( - "Host unavailable", - `${daemon.label} is not connected. Wait for it to come online before restarting.` - ); - return; - } - - if (Platform.OS === "web") { - const hasBrowserConfirm = - typeof globalThis !== "undefined" && - typeof (globalThis as any).confirm === "function"; - - const confirmed = hasBrowserConfirm - ? (globalThis as any).confirm(`Restart ${daemon.label}? ${restartConfirmationMessage}`) - : true; - - if (confirmed) { - beginServerRestart(); - } - return; - } - - Alert.alert(`Restart ${daemon.label}`, restartConfirmationMessage, [ - { text: "Cancel", style: "cancel" }, - { - text: "Restart", - style: "destructive", - onPress: beginServerRestart, - }, - ]); - }, [beginServerRestart, daemon.label, daemonClient, restartConfirmationMessage]); - - // Status pill background with 10% opacity const statusPillBg = statusTone === "success" ? "rgba(74, 222, 128, 0.1)" @@ -1084,7 +1313,7 @@ function DaemonCard({ ? "rgba(245, 158, 11, 0.1)" : statusTone === "error" ? "rgba(248, 113, 113, 0.1)" - : "rgba(161, 161, 170, 0.1)"; + : "rgba(161, 161, 170, 0.1)"; const connectionBadge = (() => { if (!activeConnection) return null; if (activeConnection.type === "relay") { @@ -1097,7 +1326,13 @@ function DaemonCard({ })(); return ( - + [styles.hostCard, pressed && styles.hostCardPressed]} + onPress={() => onPress(daemon)} + testID={`daemon-card-${daemon.serverId}`} + accessibilityRole="button" + accessibilityLabel={`${daemon.label}, ${statusLabel}`} + > {daemon.label} @@ -1119,50 +1354,10 @@ function DaemonCard({ ) : null} ) : null} - - - [ - styles.menuButton, - pressed ? styles.menuButtonPressed : null, - ]} - accessibilityRole="button" - accessibilityLabel={`Host actions for ${daemon.label}`} - > - - - - onEdit(daemon)} - leading={} - testID={`daemon-menu-edit-${daemon.serverId}`} - > - Edit - - } - status={isRestarting ? "pending" : "idle"} - pendingLabel="Restarting..." - disabled={!daemonClient || !isConnectedRef.current} - testID={`daemon-menu-restart-${daemon.serverId}`} - > - Restart daemon - - onRemove(daemon)} - leading={} - testID={`daemon-menu-remove-${daemon.serverId}`} - > - Remove - - - {connectionError ? {connectionError} : null} - + ); } diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index 06a13518b..fbb879554 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -4,7 +4,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl import { Link2 } from "lucide-react-native"; import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context"; import { normalizeHostPort } from "@/utils/daemon-endpoints"; -import { DaemonConnectionTestError, probeDaemonEndpoint } from "@/utils/test-daemon-connection"; +import { DaemonConnectionTestError, probeConnection } from "@/utils/test-daemon-connection"; import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; @@ -179,7 +179,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer setIsSaving(true); setErrorMessage(""); - const { serverId, hostname } = await probeDaemonEndpoint(endpoint); + const { serverId, hostname } = await probeConnection({ id: "probe", type: "direct", endpoint }); if (targetServerId && serverId !== targetServerId) { const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`; setErrorMessage(message); diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx index 13c44d2ac..b67786dd1 100644 --- a/packages/app/src/components/multi-daemon-session-host.tsx +++ b/packages/app/src/components/multi-daemon-session-host.tsx @@ -8,7 +8,7 @@ import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, } from "@/utils/daemon-endpoints"; -import { probeDaemonEndpoint } from "@/utils/test-daemon-connection"; +import { probeConnection } from "@/utils/test-daemon-connection"; import { useEffect, useMemo, useRef, useState } from "react"; import type { ActiveConnection } from "@/contexts/daemon-connections-context"; @@ -157,9 +157,10 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { upgradeProbeInFlightRef.current = true; try { - const { serverId } = await probeDaemonEndpoint(best.activeConnection.endpoint, { - timeoutMs: 2000, - }); + const { serverId } = await probeConnection( + { id: "probe", type: "direct", endpoint: best.activeConnection.endpoint }, + { timeoutMs: 2000 }, + ); if (cancelled) return; if (serverId !== daemon.serverId) return; diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index a6eb1c7c8..a0b1c22c8 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -3,7 +3,8 @@ import { Alert, Text, View } from "react-native"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { Link } from "lucide-react-native"; import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context"; -import { decodeOfferFragmentPayload } from "@/utils/daemon-endpoints"; +import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; +import { probeConnection } from "@/utils/test-daemon-connection"; import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; @@ -120,9 +121,20 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe try { setIsSaving(true); setErrorMessage(""); + + const probeResult = await probeConnection( + { + id: "probe", + type: "relay", + relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint), + daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64, + }, + { serverId: parsedOffer.serverId }, + ); + const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId); const profile = await upsertDaemonFromOfferUrl(raw); - onSaved?.({ profile, serverId: parsedOffer.serverId, hostname: null, isNewHost }); + onSaved?.({ profile, serverId: parsedOffer.serverId, hostname: probeResult.hostname, isNewHost }); handleClose(); } catch (error) { const message = error instanceof Error ? error.message : "Unable to pair host"; diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index 03ca9c8cb..e2cb3a00e 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -31,8 +31,8 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.xl, }, default: { - backgroundColor: theme.colors.palette.blue[500], - borderColor: theme.colors.palette.blue[500], + backgroundColor: theme.colors.accent, + borderColor: theme.colors.accent, }, secondary: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index ea4a284df..d0a1adde6 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -1,6 +1,7 @@ import { DaemonClient } from "@server/client/daemon-client"; -import type { ConnectionState } from "@server/client/daemon-client"; -import { buildDaemonWebSocketUrl } from "./daemon-endpoints"; +import type { DaemonClientConfig } from "@server/client/daemon-client"; +import type { HostConnection } from "@/contexts/daemon-registry-context"; +import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints"; import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport"; function normalizeNonEmptyString(value: unknown): string | null { @@ -39,82 +40,127 @@ export class DaemonConnectionTestError extends Error { } } -export async function probeDaemonEndpoint( - endpoint: string, - options?: { timeoutMs?: number } -): Promise<{ serverId: string; hostname: string | null }> { - const timeoutMs = options?.timeoutMs ?? 6000; - const url = buildDaemonWebSocketUrl(endpoint); - +function buildClientConfig(connection: HostConnection, serverId?: string): DaemonClientConfig { const tauriTransportFactory = createTauriWebSocketTransportFactory(); - const client = new DaemonClient({ - url, + const base = { suppressSendErrors: true, ...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}), - }); + }; - try { - return await new Promise<{ serverId: string; hostname: string | null }>((resolve, reject) => { - let cleanedUp = false; - let unsubscribe: (() => void) | null = null; - let unsubscribeStatus: (() => void) | null = null; - let serverId: string | null = null; - let hostname: string | null = null; + if (connection.type === "direct") { + return { ...base, url: buildDaemonWebSocketUrl(connection.endpoint) }; + } - const cleanup = () => { - if (cleanedUp) return; - cleanedUp = true; - clearTimeout(timeout); - unsubscribe?.(); - unsubscribeStatus?.(); - }; + if (!serverId) { + throw new Error("serverId is required to probe a relay connection"); + } - const maybeFinishOk = () => { - if (!serverId) return; - cleanup(); - resolve({ serverId, hostname }); - }; + return { + ...base, + url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, serverId }), + e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 }, + }; +} - const finishErr = (error: Error) => { - if (cleanedUp) return; - cleanup(); - reject(error); - }; +function connectAndProbe( + config: DaemonClientConfig, + timeoutMs: number, +): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> { + const client = new DaemonClient(config); - const timeout = setTimeout(() => { - finishErr( - new DaemonConnectionTestError("Connection timed out", { - reason: "Connection timed out", - lastError: client.lastError ?? null, - }) - ); - }, timeoutMs); + return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>((resolve, reject) => { + let cleanedUp = false; + let unsubscribe: (() => void) | null = null; + let unsubscribeStatus: (() => void) | null = null; + let serverId: string | null = null; + let hostname: string | null = null; - unsubscribe = client.subscribeConnectionStatus((state) => { - if (state.status === "disconnected") { - const reason = normalizeNonEmptyString(state.reason); - const lastError = normalizeNonEmptyString(client.lastError); - const message = pickBestReason(reason, lastError); - finishErr(new DaemonConnectionTestError(message, { reason, lastError })); - } - }); + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + clearTimeout(timer); + unsubscribe?.(); + unsubscribeStatus?.(); + }; - unsubscribeStatus = client.on("status", (message) => { - if (message.type !== "status") return; - const payload = message.payload as { status?: unknown; serverId?: unknown; hostname?: unknown }; - if (payload?.status !== "server_info") return; - const raw = typeof payload.serverId === "string" ? payload.serverId.trim() : ""; - if (!raw) return; - serverId = raw; - hostname = typeof payload.hostname === "string" ? payload.hostname.trim() : null; - if (hostname && hostname.length === 0) { - hostname = null; - } - maybeFinishOk(); - }); + const maybeFinishOk = () => { + if (!serverId) return; + cleanup(); + resolve({ client, serverId, hostname }); + }; - void client.connect().catch(() => undefined); + const finishErr = (error: Error) => { + if (cleanedUp) return; + cleanup(); + client.close().catch(() => undefined); + reject(error); + }; + + const timer = setTimeout(() => { + finishErr( + new DaemonConnectionTestError("Connection timed out", { + reason: "Connection timed out", + lastError: client.lastError ?? null, + }) + ); + }, timeoutMs); + + unsubscribe = client.subscribeConnectionStatus((state) => { + if (state.status === "disconnected") { + const reason = normalizeNonEmptyString(state.reason); + const lastError = normalizeNonEmptyString(client.lastError); + const message = pickBestReason(reason, lastError); + finishErr(new DaemonConnectionTestError(message, { reason, lastError })); + } }); + + unsubscribeStatus = client.on("status", (message) => { + if (message.type !== "status") return; + const payload = message.payload as { status?: unknown; serverId?: unknown; hostname?: unknown }; + if (payload?.status !== "server_info") return; + const raw = typeof payload.serverId === "string" ? payload.serverId.trim() : ""; + if (!raw) return; + serverId = raw; + hostname = typeof payload.hostname === "string" ? payload.hostname.trim() : null; + if (hostname && hostname.length === 0) { + hostname = null; + } + maybeFinishOk(); + }); + + void client.connect().catch(() => undefined); + }); +} + +interface ProbeOptions { + serverId?: string; + timeoutMs?: number; +} + +function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number { + if (options?.timeoutMs) return options.timeoutMs; + return connection.type === "relay" ? 10_000 : 6_000; +} + +export async function probeConnection( + connection: HostConnection, + options?: ProbeOptions, +): Promise<{ serverId: string; hostname: string | null }> { + const config = buildClientConfig(connection, options?.serverId); + const { client, serverId, hostname } = await connectAndProbe(config, resolveTimeout(connection, options)); + await client.close().catch(() => undefined); + return { serverId, hostname }; +} + +export async function measureConnectionLatency( + connection: HostConnection, + options?: ProbeOptions, +): Promise { + const config = buildClientConfig(connection, options?.serverId); + const { client } = await connectAndProbe(config, resolveTimeout(connection, options)); + try { + const { rttMs } = await client.ping({ timeoutMs: 5000 }); + return rttMs; } finally { await client.close().catch(() => undefined); } diff --git a/scripts/measure-relay-latency.ts b/scripts/measure-relay-latency.ts new file mode 100644 index 000000000..0c4d0c154 --- /dev/null +++ b/scripts/measure-relay-latency.ts @@ -0,0 +1,142 @@ +import { DaemonClient } from "../packages/server/src/client/daemon-client.js"; +import { buildRelayWebSocketUrl } from "../packages/server/src/shared/daemon-endpoints.js"; +import { buildDaemonWebSocketUrl } from "../packages/server/src/shared/daemon-endpoints.js"; + +const OFFER = { + serverId: "srv_ETXtcjYRGrCI", + daemonPublicKeyB64: "12yCG8sqNumkwHMOQyRM/vMXfPc6nb430pj27sfARBc=", + relay: { endpoint: "relay.paseo.sh:443" }, +}; + +const DIRECT_ENDPOINT = "localhost:6767"; +const PING_COUNT = 20; +const WARMUP_COUNT = 3; + +async function connectClient( + label: string, + config: ConstructorParameters[0], +): Promise { + const client = new DaemonClient(config); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`${label}: connect timeout`)), 15_000); + const unsub = client.on("status", (msg) => { + if (msg.type === "status") { + clearTimeout(timeout); + unsub(); + resolve(); + } + }); + client.connect().catch(reject); + }); + + return client; +} + +async function measurePings( + label: string, + client: DaemonClient, + count: number, + warmup: number, +): Promise { + // Warmup + for (let i = 0; i < warmup; i++) { + await client.ping({ timeoutMs: 10_000 }); + } + + const results: number[] = []; + const serverTimings: { serverReceivedAt: number; serverSentAt: number; clientSentAt: number }[] = []; + + for (let i = 0; i < count; i++) { + const result = await client.ping({ timeoutMs: 10_000 }); + results.push(result.rttMs); + serverTimings.push({ + serverReceivedAt: result.serverReceivedAt, + serverSentAt: result.serverSentAt, + clientSentAt: result.clientSentAt, + }); + // Small delay between pings to avoid batching effects + await new Promise((r) => setTimeout(r, 100)); + } + + const sorted = [...results].sort((a, b) => a - b); + const avg = results.reduce((a, b) => a + b, 0) / results.length; + const min = sorted[0]!; + const max = sorted[sorted.length - 1]!; + const p50 = sorted[Math.floor(sorted.length * 0.5)]!; + const p95 = sorted[Math.floor(sorted.length * 0.95)]!; + + console.log(`\n${label}:`); + console.log(` samples: ${count} (after ${warmup} warmup)`); + console.log(` min: ${min}ms`); + console.log(` max: ${max}ms`); + console.log(` avg: ${avg.toFixed(1)}ms`); + console.log(` p50: ${p50}ms`); + console.log(` p95: ${p95}ms`); + console.log(` all: [${results.join(", ")}]`); + + // Server-side processing time (serverSentAt - serverReceivedAt) + const serverProcessing = serverTimings.map((t) => t.serverSentAt - t.serverReceivedAt); + const avgServerProcessing = serverProcessing.reduce((a, b) => a + b, 0) / serverProcessing.length; + console.log(` server processing avg: ${avgServerProcessing.toFixed(1)}ms`); + console.log(` server processing: [${serverProcessing.join(", ")}]`); +} + +async function main() { + console.log("=== Relay Latency Measurement ===\n"); + + // Measure direct connection + console.log("Connecting direct..."); + const directClient = await connectClient("Direct", { + url: buildDaemonWebSocketUrl(DIRECT_ENDPOINT), + }); + + await measurePings("Direct (localhost:6767)", directClient, PING_COUNT, WARMUP_COUNT); + + // Measure relay connection + console.log("\nConnecting via relay..."); + const relayUrl = buildRelayWebSocketUrl({ + endpoint: OFFER.relay.endpoint, + serverId: OFFER.serverId, + role: "client", + }); + + const relayClient = await connectClient("Relay", { + url: relayUrl, + e2ee: { + enabled: true, + daemonPublicKeyB64: OFFER.daemonPublicKeyB64, + }, + }); + + await measurePings("Relay (relay.paseo.sh:443)", relayClient, PING_COUNT, WARMUP_COUNT); + + // Measure raw WebSocket to relay (no E2EE, no daemon, just WS open+close timing) + console.log("\nMeasuring raw WebSocket connect time to relay..."); + const wsConnectTimes: number[] = []; + for (let i = 0; i < 5; i++) { + const start = Date.now(); + const { WebSocket } = await import("ws"); + const ws = new WebSocket(`wss://relay.paseo.sh/ws?serverId=latency_probe_${Date.now()}&role=client&clientId=probe_${i}`); + await new Promise((resolve, reject) => { + ws.on("open", () => { + wsConnectTimes.push(Date.now() - start); + ws.close(); + resolve(); + }); + ws.on("error", reject); + setTimeout(() => reject(new Error("ws connect timeout")), 5000); + }); + } + console.log(` Raw WS connect times: [${wsConnectTimes.join(", ")}]ms`); + + await directClient.close(); + await relayClient.close(); + + console.log("\nDone."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});