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 { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { NameHostModal } from "@/components/name-host-modal"; 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"; import { ConnectionOfferSchema } from "@server/shared/connection-offer";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
@@ -217,6 +218,16 @@ export default function PairScanScreen() {
return; 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 isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
const profile = await upsertDaemonFromOfferUrl(offerUrl); const profile = await upsertDaemonFromOfferUrl(offerUrl);
+447 -252
View File
@@ -4,7 +4,6 @@ import {
View, View,
Text, Text,
ScrollView, ScrollView,
TextInput,
Pressable, Pressable,
Alert, Alert,
Platform, Platform,
@@ -13,11 +12,13 @@ import { router, useLocalSearchParams } from "expo-router";
import Constants from "expo-constants"; import Constants from "expo-constants";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; 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 { 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 { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
import { theme as defaultTheme } from "@/styles/theme"; import { theme as defaultTheme } from "@/styles/theme";
import { MenuHeader } from "@/components/headers/menu-header"; import { MenuHeader } from "@/components/headers/menu-header";
import { useSessionStore } from "@/stores/session-store"; 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 { AddHostModal } from "@/components/add-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal"; import { PairLinkModal } from "@/components/pair-link-modal";
import { NameHostModal } from "@/components/name-host-modal"; import { NameHostModal } from "@/components/name-host-modal";
import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
const delay = (ms: number) => const delay = (ms: number) =>
@@ -181,15 +180,24 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
flexShrink: 1, flexShrink: 1,
}, },
menuButton: { hostCardPressed: {
width: 36, opacity: 0.85,
height: 32,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
}, },
menuButtonPressed: { advancedTrigger: {
backgroundColor: theme.colors.surface3, 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: { disabled: {
opacity: theme.opacity[50], opacity: theme.opacity[50],
@@ -537,9 +545,19 @@ export default function SettingsScreen() {
); );
const handleRemoveDaemon = useCallback((profile: HostProfile) => { const handleRemoveDaemon = useCallback((profile: HostProfile) => {
setEditingDaemon(null);
setPendingRemoveHost(profile); setPendingRemoveHost(profile);
}, []); }, []);
const handleAddConnectionFromModal = useCallback(() => {
if (!editingDaemon) return;
const serverId = editingDaemon.serverId;
setEditingDaemon(null);
setAddConnectionTargetServerId(serverId);
setPendingEditReopenServerId(serverId);
setIsAddHostMethodVisible(true);
}, [editingDaemon]);
const handleThemeChange = useCallback( const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => { (newTheme: AppSettings["theme"]) => {
void updateSettings({ theme: newTheme }); void updateSettings({ theme: newTheme });
@@ -619,11 +637,7 @@ export default function SettingsScreen() {
connectionStatus={connectionStatus} connectionStatus={connectionStatus}
activeConnection={activeConnection} activeConnection={activeConnection}
lastError={lastConnectionError} lastError={lastConnectionError}
onEdit={handleEditDaemon} onPress={handleEditDaemon}
onRemove={handleRemoveDaemon}
restartConfirmationMessage={restartConfirmationMessage}
waitForCondition={waitForCondition}
isScreenMountedRef={isMountedRef}
/> />
); );
}) })
@@ -748,13 +762,21 @@ export default function SettingsScreen() {
</AdaptiveModalSheet> </AdaptiveModalSheet>
) : null} ) : null}
<EditHostModal <HostDetailModal
visible={Boolean(editingDaemon)} visible={Boolean(editingDaemon)}
host={editingDaemonLive ?? 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} isSaving={isSavingEdit}
onClose={handleCloseEditDaemon} onClose={handleCloseEditDaemon}
onSave={(label) => void handleSaveEditDaemon(label)} onSave={(label) => void handleSaveEditDaemon(label)}
onRemoveConnection={handleRemoveConnection} onRemoveConnection={handleRemoveConnection}
onRemoveHost={handleRemoveDaemon}
onAddConnection={handleAddConnectionFromModal}
restartConfirmationMessage={restartConfirmationMessage}
waitForCondition={waitForCondition}
isScreenMountedRef={isMountedRef}
/> />
{/* Appearance */} {/* Appearance */}
@@ -816,25 +838,185 @@ export default function SettingsScreen() {
); );
} }
interface EditHostModalProps { interface HostDetailModalProps {
visible: boolean; visible: boolean;
host: HostProfile | null; host: HostProfile | null;
connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
isSaving: boolean; isSaving: boolean;
onClose: () => void; onClose: () => void;
onSave: (label: string) => void; onSave: (label: string) => void;
onRemoveConnection: (serverId: string, connectionId: string) => Promise<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, visible,
host, host,
connectionStatus,
activeConnection,
lastError,
isSaving, isSaving,
onClose, onClose,
onSave, onSave,
onRemoveConnection, onRemoveConnection,
}: EditHostModalProps) { onRemoveHost,
onAddConnection,
restartConfirmationMessage,
waitForCondition,
isScreenMountedRef,
}: HostDetailModalProps) {
const { theme } = useUnistyles();
const [draftLabel, setDraftLabel] = useState(""); const [draftLabel, setDraftLabel] = useState("");
const activeServerIdRef = useRef<string | null>(null); 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(() => { useEffect(() => {
if (!visible || !host) return; if (!visible || !host) return;
@@ -851,88 +1033,248 @@ function EditHostModal({
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
activeServerIdRef.current = null; activeServerIdRef.current = null;
setIsRestarting(false);
} }
}, [visible]); }, [visible]);
return ( return (
<AdaptiveModalSheet <>
title="Edit host" <AdaptiveModalSheet
visible={visible} title={host?.label ?? "Host"}
onClose={onClose} visible={visible}
testID="edit-host-modal" onClose={onClose}
> testID="host-detail-modal"
<View style={styles.formField}> >
<Text style={styles.label}>Label</Text> {/* Status row */}
<AdaptiveTextInput <View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}>
style={styles.input} <View style={[styles.statusPill, { backgroundColor: statusPillBg }]}>
value={draftLabel} <View style={[styles.statusDot, { backgroundColor: statusColor }]} />
onChangeText={setDraftLabel} <Text style={[styles.statusText, { color: statusColor }]}>{statusLabel}</Text>
placeholder="My Host" </View>
placeholderTextColor={defaultTheme.colors.mutedForeground} {connectionBadge ? (
/> <View style={styles.connectionPill}>
</View> {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}> <View style={styles.formField}>
<Text style={styles.label}>Connections</Text> <Text style={styles.label}>Label</Text>
<View style={{ gap: 8 }}> <AdaptiveTextInput
{host.connections.map((conn) => { style={styles.input}
const title = value={draftLabel}
conn.type === "relay" onChangeText={setDraftLabel}
? `Relay (${conn.relayEndpoint})` placeholder="My Host"
: `Direct (${conn.endpoint})`; placeholderTextColor={defaultTheme.colors.mutedForeground}
return ( />
<View </View>
key={conn.id}
style={{ {/* Connections */}
flexDirection: "row", {host ? (
alignItems: "center", <View style={styles.formField}>
justifyContent: "space-between", <Text style={styles.label}>Connections</Text>
gap: 12, <View style={{ gap: 8 }}>
paddingVertical: 10, {host.connections.map((conn) => {
paddingHorizontal: 12, const latency = latencyByConnectionId.get(conn.id);
borderRadius: 12, return (
borderWidth: 1, <ConnectionRow
borderColor: defaultTheme.colors.border, key={conn.id}
backgroundColor: defaultTheme.colors.surface2, 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 }}> Restart daemon
{title} </DropdownMenuItem>
</Text> <DropdownMenuItem
<Pressable onSelect={() => { if (host) onRemoveHost(host); }}
onPress={() => void onRemoveConnection(host.serverId, conn.id)} leading={<Trash2 size={16} color={theme.colors.destructive} />}
> >
<Text style={{ color: defaultTheme.colors.destructive, fontSize: 12, fontWeight: "500" }}> Remove host
Remove </DropdownMenuItem>
</Text> </DropdownMenuContent>
</Pressable> </DropdownMenu>
</View>
); <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>
</View> </View>
) : null} </AdaptiveModalSheet>
<View style={styles.formActionsRow}> {/* Remove connection confirmation */}
<Button {pendingRemoveConnection ? (
variant="secondary" <AdaptiveModalSheet
size="sm" title="Remove connection"
onPress={onClose} visible
disabled={isSaving} onClose={() => {
if (isRemovingConnection) return;
setPendingRemoveConnection(null);
}}
testID="remove-connection-confirm-modal"
> >
Cancel <Text style={{ color: theme.colors.foregroundMuted, fontSize: 14 }}>
</Button> Remove {pendingRemoveConnection.title}? This cannot be undone.
<Button </Text>
variant="default" <View style={[styles.formActionsRow, { marginTop: theme.spacing[4] }]}>
size="sm" <Button
onPress={() => onSave(draftLabel)} variant="secondary"
disabled={isSaving} size="sm"
> style={{ flex: 1 }}
{isSaving ? "Saving..." : "Save"} onPress={() => setPendingRemoveConnection(null)}
</Button> disabled={isRemovingConnection}
</View> >
</AdaptiveModalSheet> 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; connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null; activeConnection: ActiveConnection | null;
lastError: string | null; lastError: string | null;
onEdit: (daemon: HostProfile) => void; onPress: (daemon: HostProfile) => void;
onRemove: (daemon: HostProfile) => void;
restartConfirmationMessage: string;
waitForCondition: (predicate: () => boolean, timeoutMs: number, intervalMs?: number) => Promise<boolean>;
isScreenMountedRef: MutableRefObject<boolean>;
} }
function DaemonCard({ function DaemonCard({
@@ -953,11 +1291,7 @@ function DaemonCard({
connectionStatus, connectionStatus,
activeConnection, activeConnection,
lastError, lastError,
onEdit, onPress,
onRemove,
restartConfirmationMessage,
waitForCondition,
isScreenMountedRef,
}: DaemonCardProps) { }: DaemonCardProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const statusLabel = formatConnectionStatus(connectionStatus); const statusLabel = formatConnectionStatus(connectionStatus);
@@ -972,111 +1306,6 @@ function DaemonCard({
: theme.colors.foregroundMuted; : theme.colors.foregroundMuted;
const badgeText = statusLabel; const badgeText = statusLabel;
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; 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 = const statusPillBg =
statusTone === "success" statusTone === "success"
? "rgba(74, 222, 128, 0.1)" ? "rgba(74, 222, 128, 0.1)"
@@ -1084,7 +1313,7 @@ function DaemonCard({
? "rgba(245, 158, 11, 0.1)" ? "rgba(245, 158, 11, 0.1)"
: statusTone === "error" : statusTone === "error"
? "rgba(248, 113, 113, 0.1)" ? "rgba(248, 113, 113, 0.1)"
: "rgba(161, 161, 170, 0.1)"; : "rgba(161, 161, 170, 0.1)";
const connectionBadge = (() => { const connectionBadge = (() => {
if (!activeConnection) return null; if (!activeConnection) return null;
if (activeConnection.type === "relay") { if (activeConnection.type === "relay") {
@@ -1097,7 +1326,13 @@ function DaemonCard({
})(); })();
return ( 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.hostCardContent}>
<View style={styles.hostHeaderRow}> <View style={styles.hostHeaderRow}>
<Text style={styles.hostLabel} numberOfLines={1}>{daemon.label}</Text> <Text style={styles.hostLabel} numberOfLines={1}>{daemon.label}</Text>
@@ -1119,50 +1354,10 @@ function DaemonCard({
) : null} ) : null}
</View> </View>
) : null} ) : 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>
</View> </View>
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null} {connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
</View> </View>
</View> </Pressable>
); );
} }
@@ -4,7 +4,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import { Link2 } from "lucide-react-native"; import { Link2 } from "lucide-react-native";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context"; import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
import { normalizeHostPort } from "@/utils/daemon-endpoints"; 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 { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -179,7 +179,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
setIsSaving(true); setIsSaving(true);
setErrorMessage(""); setErrorMessage("");
const { serverId, hostname } = await probeDaemonEndpoint(endpoint); const { serverId, hostname } = await probeConnection({ id: "probe", type: "direct", endpoint });
if (targetServerId && serverId !== targetServerId) { if (targetServerId && serverId !== targetServerId) {
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`; const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
setErrorMessage(message); setErrorMessage(message);
@@ -8,7 +8,7 @@ import {
buildDaemonWebSocketUrl, buildDaemonWebSocketUrl,
buildRelayWebSocketUrl, buildRelayWebSocketUrl,
} from "@/utils/daemon-endpoints"; } 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 { useEffect, useMemo, useRef, useState } from "react";
import type { ActiveConnection } from "@/contexts/daemon-connections-context"; import type { ActiveConnection } from "@/contexts/daemon-connections-context";
@@ -157,9 +157,10 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
upgradeProbeInFlightRef.current = true; upgradeProbeInFlightRef.current = true;
try { try {
const { serverId } = await probeDaemonEndpoint(best.activeConnection.endpoint, { const { serverId } = await probeConnection(
timeoutMs: 2000, { id: "probe", type: "direct", endpoint: best.activeConnection.endpoint },
}); { timeoutMs: 2000 },
);
if (cancelled) return; if (cancelled) return;
if (serverId !== daemon.serverId) 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 { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Link } from "lucide-react-native"; import { Link } from "lucide-react-native";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context"; 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 { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -120,9 +121,20 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
try { try {
setIsSaving(true); setIsSaving(true);
setErrorMessage(""); 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 isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
const profile = await upsertDaemonFromOfferUrl(raw); const profile = await upsertDaemonFromOfferUrl(raw);
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname: null, isNewHost }); onSaved?.({ profile, serverId: parsedOffer.serverId, hostname: probeResult.hostname, isNewHost });
handleClose(); handleClose();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unable to pair host"; 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, borderRadius: theme.borderRadius.xl,
}, },
default: { default: {
backgroundColor: theme.colors.palette.blue[500], backgroundColor: theme.colors.accent,
borderColor: theme.colors.palette.blue[500], borderColor: theme.colors.accent,
}, },
secondary: { secondary: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
+112 -66
View File
@@ -1,6 +1,7 @@
import { DaemonClient } from "@server/client/daemon-client"; import { DaemonClient } from "@server/client/daemon-client";
import type { ConnectionState } from "@server/client/daemon-client"; import type { DaemonClientConfig } from "@server/client/daemon-client";
import { buildDaemonWebSocketUrl } from "./daemon-endpoints"; import type { HostConnection } from "@/contexts/daemon-registry-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport"; import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
function normalizeNonEmptyString(value: unknown): string | null { function normalizeNonEmptyString(value: unknown): string | null {
@@ -39,82 +40,127 @@ export class DaemonConnectionTestError extends Error {
} }
} }
export async function probeDaemonEndpoint( function buildClientConfig(connection: HostConnection, serverId?: string): DaemonClientConfig {
endpoint: string,
options?: { timeoutMs?: number }
): Promise<{ serverId: string; hostname: string | null }> {
const timeoutMs = options?.timeoutMs ?? 6000;
const url = buildDaemonWebSocketUrl(endpoint);
const tauriTransportFactory = createTauriWebSocketTransportFactory(); const tauriTransportFactory = createTauriWebSocketTransportFactory();
const client = new DaemonClient({ const base = {
url,
suppressSendErrors: true, suppressSendErrors: true,
...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}), ...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}),
}); };
try { if (connection.type === "direct") {
return await new Promise<{ serverId: string; hostname: string | null }>((resolve, reject) => { return { ...base, url: buildDaemonWebSocketUrl(connection.endpoint) };
let cleanedUp = false; }
let unsubscribe: (() => void) | null = null;
let unsubscribeStatus: (() => void) | null = null;
let serverId: string | null = null;
let hostname: string | null = null;
const cleanup = () => { if (!serverId) {
if (cleanedUp) return; throw new Error("serverId is required to probe a relay connection");
cleanedUp = true; }
clearTimeout(timeout);
unsubscribe?.();
unsubscribeStatus?.();
};
const maybeFinishOk = () => { return {
if (!serverId) return; ...base,
cleanup(); url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, serverId }),
resolve({ serverId, hostname }); e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
}; };
}
const finishErr = (error: Error) => { function connectAndProbe(
if (cleanedUp) return; config: DaemonClientConfig,
cleanup(); timeoutMs: number,
reject(error); ): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
}; const client = new DaemonClient(config);
const timeout = setTimeout(() => { return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>((resolve, reject) => {
finishErr( let cleanedUp = false;
new DaemonConnectionTestError("Connection timed out", { let unsubscribe: (() => void) | null = null;
reason: "Connection timed out", let unsubscribeStatus: (() => void) | null = null;
lastError: client.lastError ?? null, let serverId: string | null = null;
}) let hostname: string | null = null;
);
}, timeoutMs);
unsubscribe = client.subscribeConnectionStatus((state) => { const cleanup = () => {
if (state.status === "disconnected") { if (cleanedUp) return;
const reason = normalizeNonEmptyString(state.reason); cleanedUp = true;
const lastError = normalizeNonEmptyString(client.lastError); clearTimeout(timer);
const message = pickBestReason(reason, lastError); unsubscribe?.();
finishErr(new DaemonConnectionTestError(message, { reason, lastError })); unsubscribeStatus?.();
} };
});
unsubscribeStatus = client.on("status", (message) => { const maybeFinishOk = () => {
if (message.type !== "status") return; if (!serverId) return;
const payload = message.payload as { status?: unknown; serverId?: unknown; hostname?: unknown }; cleanup();
if (payload?.status !== "server_info") return; resolve({ client, serverId, hostname });
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); 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 { } finally {
await client.close().catch(() => undefined); 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);
});