Update files
This commit is contained in:
@@ -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
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user