Update files

This commit is contained in:
Mohamed Boudra
2026-02-08 15:59:38 +07:00
parent b4a93955e3
commit c5a27a4ddc
8 changed files with 736 additions and 329 deletions
+12 -1
View File
@@ -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);
+447 -252
View File
@@ -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() {
</AdaptiveModalSheet>
) : null}
<EditHostModal
<HostDetailModal
visible={Boolean(editingDaemon)}
host={editingDaemonLive ?? editingDaemon}
connectionStatus={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.status ?? "idle") : "idle"}
activeConnection={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.activeConnection ?? null) : null}
lastError={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.lastError ?? null) : null}
isSaving={isSavingEdit}
onClose={handleCloseEditDaemon}
onSave={(label) => 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<void>;
onRemoveHost: (host: HostProfile) => void;
onAddConnection: () => void;
restartConfirmationMessage: string;
waitForCondition: (predicate: () => boolean, timeoutMs: number, intervalMs?: number) => Promise<boolean>;
isScreenMountedRef: MutableRefObject<boolean>;
}
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<string | null>(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: <Globe size={12} color={theme.colors.foregroundMuted} />, text: "Relay" };
}
return {
icon: <Monitor size={12} color={theme.colors.foregroundMuted} />,
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 (
<AdaptiveModalSheet
title="Edit host"
visible={visible}
onClose={onClose}
testID="edit-host-modal"
>
<View style={styles.formField}>
<Text style={styles.label}>Label</Text>
<AdaptiveTextInput
style={styles.input}
value={draftLabel}
onChangeText={setDraftLabel}
placeholder="My Host"
placeholderTextColor={defaultTheme.colors.mutedForeground}
/>
</View>
<>
<AdaptiveModalSheet
title={host?.label ?? "Host"}
visible={visible}
onClose={onClose}
testID="host-detail-modal"
>
{/* Status row */}
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}>
<View style={[styles.statusPill, { backgroundColor: statusPillBg }]}>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.statusText, { color: statusColor }]}>{statusLabel}</Text>
</View>
{connectionBadge ? (
<View style={styles.connectionPill}>
{connectionBadge.icon}
<Text style={styles.connectionText} numberOfLines={1}>
{connectionBadge.text}
</Text>
</View>
) : null}
</View>
{connectionError ? (
<Text style={{ color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs }}>
{connectionError}
</Text>
) : null}
{host ? (
{/* Label */}
<View style={styles.formField}>
<Text style={styles.label}>Connections</Text>
<View style={{ gap: 8 }}>
{host.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: defaultTheme.colors.border,
backgroundColor: defaultTheme.colors.surface2,
}}
<Text style={styles.label}>Label</Text>
<AdaptiveTextInput
style={styles.input}
value={draftLabel}
onChangeText={setDraftLabel}
placeholder="My Host"
placeholderTextColor={defaultTheme.colors.mutedForeground}
/>
</View>
{/* Connections */}
{host ? (
<View style={styles.formField}>
<Text style={styles.label}>Connections</Text>
<View style={{ gap: 8 }}>
{host.connections.map((conn) => {
const latency = latencyByConnectionId.get(conn.id);
return (
<ConnectionRow
key={conn.id}
connection={conn}
latencyMs={latency?.data ?? undefined}
latencyLoading={latency?.isLoading ?? false}
latencyError={latency?.isError ?? false}
onRemove={() => {
const title =
conn.type === "relay"
? `Relay (${conn.relayEndpoint})`
: `Direct (${conn.endpoint})`;
setPendingRemoveConnection({ serverId: host.serverId, connectionId: conn.id, title });
}}
/>
);
})}
<Pressable
style={styles.addButton}
onPress={onAddConnection}
>
<Text style={styles.addButtonText}>+ Add connection</Text>
</Pressable>
</View>
</View>
) : null}
{/* Save/Cancel + Advanced */}
<View style={{ borderTopWidth: 1, borderTopColor: theme.colors.border, marginTop: theme.spacing[2], paddingTop: theme.spacing[4] }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.advancedTrigger,
pressed && { opacity: 0.85 },
]}
>
<Settings size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.advancedTriggerText}>Advanced</Text>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" width={220}>
<DropdownMenuItem
onSelect={handleRestartPress}
leading={<RotateCw size={16} color={theme.colors.foregroundMuted} />}
status={isRestarting ? "pending" : "idle"}
pendingLabel="Restarting..."
disabled={!daemonClient || !isConnectedRef.current}
>
<Text style={{ color: defaultTheme.colors.foreground, fontSize: 12, flex: 1 }}>
{title}
</Text>
<Pressable
onPress={() => void onRemoveConnection(host.serverId, conn.id)}
>
<Text style={{ color: defaultTheme.colors.destructive, fontSize: 12, fontWeight: "500" }}>
Remove
</Text>
</Pressable>
</View>
);
})}
Restart daemon
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => { if (host) onRemoveHost(host); }}
leading={<Trash2 size={16} color={theme.colors.destructive} />}
>
Remove host
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<View style={styles.formActionsRow}>
<Button
variant="secondary"
size="sm"
onPress={onClose}
disabled={isSaving}
>
Cancel
</Button>
<Button
variant="default"
size="sm"
onPress={() => onSave(draftLabel)}
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save"}
</Button>
</View>
</View>
</View>
) : null}
</AdaptiveModalSheet>
<View style={styles.formActionsRow}>
<Button
variant="secondary"
size="sm"
onPress={onClose}
disabled={isSaving}
{/* Remove connection confirmation */}
{pendingRemoveConnection ? (
<AdaptiveModalSheet
title="Remove connection"
visible
onClose={() => {
if (isRemovingConnection) return;
setPendingRemoveConnection(null);
}}
testID="remove-connection-confirm-modal"
>
Cancel
</Button>
<Button
variant="default"
size="sm"
onPress={() => onSave(draftLabel)}
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save"}
</Button>
</View>
</AdaptiveModalSheet>
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 14 }}>
Remove {pendingRemoveConnection.title}? This cannot be undone.
</Text>
<View style={[styles.formActionsRow, { marginTop: theme.spacing[4] }]}>
<Button
variant="secondary"
size="sm"
style={{ flex: 1 }}
onPress={() => setPendingRemoveConnection(null)}
disabled={isRemovingConnection}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
style={{ flex: 1 }}
onPress={() => {
const { serverId, connectionId } = pendingRemoveConnection;
setIsRemovingConnection(true);
void onRemoveConnection(serverId, connectionId)
.then(() => setPendingRemoveConnection(null))
.catch((error) => {
console.error("[Settings] Failed to remove connection", error);
Alert.alert("Error", "Unable to remove connection");
})
.finally(() => setIsRemovingConnection(false));
}}
disabled={isRemovingConnection}
testID="remove-connection-confirm"
>
Remove
</Button>
</View>
</AdaptiveModalSheet>
) : 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 (
<View
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, flex: 1 }}>
{title}
</Text>
<Text style={{ color: latencyColor, fontSize: 11 }}>
{latencyText}
</Text>
<Pressable onPress={onRemove}>
<Text style={{ color: theme.colors.destructive, fontSize: 12, fontWeight: "500" }}>
Remove
</Text>
</Pressable>
</View>
);
}
@@ -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<boolean>;
isScreenMountedRef: MutableRefObject<boolean>;
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 (
<View style={styles.hostCard} testID={`daemon-card-${daemon.serverId}`}>
<Pressable
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
onPress={() => onPress(daemon)}
testID={`daemon-card-${daemon.serverId}`}
accessibilityRole="button"
accessibilityLabel={`${daemon.label}, ${statusLabel}`}
>
<View style={styles.hostCardContent}>
<View style={styles.hostHeaderRow}>
<Text style={styles.hostLabel} numberOfLines={1}>{daemon.label}</Text>
@@ -1119,50 +1354,10 @@ function DaemonCard({
) : null}
</View>
) : null}
<DropdownMenu>
<DropdownMenuTrigger
testID={`daemon-menu-trigger-${daemon.serverId}`}
style={({ pressed }) => [
styles.menuButton,
pressed ? styles.menuButtonPressed : null,
]}
accessibilityRole="button"
accessibilityLabel={`Host actions for ${daemon.label}`}
>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID={`daemon-menu-content-${daemon.serverId}`}>
<DropdownMenuItem
onSelect={() => onEdit(daemon)}
leading={<Pencil size={16} color={theme.colors.foregroundMuted} />}
testID={`daemon-menu-edit-${daemon.serverId}`}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleRestartPress}
leading={<RotateCw size={16} color={theme.colors.foregroundMuted} />}
status={isRestarting ? "pending" : "idle"}
pendingLabel="Restarting..."
disabled={!daemonClient || !isConnectedRef.current}
testID={`daemon-menu-restart-${daemon.serverId}`}
>
Restart daemon
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onRemove(daemon)}
leading={<Trash2 size={16} color={theme.colors.foregroundMuted} />}
testID={`daemon-menu-remove-${daemon.serverId}`}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
</View>
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
</View>
</View>
</Pressable>
);
}
@@ -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);
@@ -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;
@@ -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";
+2 -2
View File
@@ -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,
+112 -66
View File
@@ -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<number> {
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);
}
+142
View File
@@ -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<typeof DaemonClient>[0],
): Promise<DaemonClient> {
const client = new DaemonClient(config);
await new Promise<void>((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<void> {
// 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<void>((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);
});