import { useState, useEffect, useRef, useCallback } from "react"; import type { MutableRefObject } from "react"; import { View, Text, ScrollView, Pressable, Alert, Platform, } from "react-native"; import { router, useLocalSearchParams } from "expo-router"; import Constants from "expo-constants"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { useQueries } from "@tanstack/react-query"; import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context"; import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { measureConnectionLatency } from "@/utils/test-daemon-connection"; import { confirmDialog } from "@/utils/confirm-dialog"; import { theme as defaultTheme } from "@/styles/theme"; import { MenuHeader } from "@/components/headers/menu-header"; import { useSessionStore } from "@/stores/session-store"; import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; import { PairLinkModal } from "@/components/pair-link-modal"; import { NameHostModal } from "@/components/name-host-modal"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { getDesktopPermissionSnapshot, requestDesktopPermission, shouldShowDesktopPermissionSection, type DesktopPermissionKind, type DesktopPermissionSnapshot, type DesktopPermissionStatus, } from "@/utils/desktop-permissions"; const delay = (ms: number) => new Promise((resolve) => { const timeout = setTimeout(() => { clearTimeout(timeout); resolve(); }, ms); }); const styles = StyleSheet.create((theme) => ({ loadingContainer: { flex: 1, backgroundColor: theme.colors.surface0, alignItems: "center", justifyContent: "center", }, loadingText: { color: theme.colors.foreground, fontSize: theme.fontSize.lg, }, container: { flex: 1, backgroundColor: theme.colors.surface0, }, scrollView: { flex: 1, }, content: { padding: theme.spacing[4], paddingTop: theme.spacing[6], width: "100%", maxWidth: 720, alignSelf: "center", }, section: { marginBottom: theme.spacing[6], }, sectionTitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, marginBottom: theme.spacing[3], marginLeft: theme.spacing[1], }, label: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, marginBottom: theme.spacing[2], }, input: { backgroundColor: theme.colors.surface0, color: theme.colors.foreground, padding: theme.spacing[3], borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, fontSize: theme.fontSize.base, }, // Host card styles hostCard: { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, marginBottom: theme.spacing[3], overflow: "hidden", }, hostCardContent: { padding: theme.spacing[4], gap: theme.spacing[2], }, hostHeaderRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], }, hostHeaderRight: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flexShrink: 0, }, hostLabel: { color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, flex: 1, flexShrink: 1, }, hostError: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs, }, // Status pill statusPill: { flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: theme.spacing[2], paddingVertical: 4, borderRadius: theme.borderRadius.full, }, statusPillMobile: { alignItems: "center", justifyContent: "center", width: 14, height: 14, borderRadius: 7, }, statusDot: { width: 6, height: 6, borderRadius: theme.borderRadius.full, }, statusText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, }, connectionPill: { flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: theme.spacing[2], paddingVertical: 4, borderRadius: theme.borderRadius.full, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface3, maxWidth: 170, }, connectionPillMobile: { flexDirection: "row", alignItems: "center", borderRadius: theme.borderRadius.full, }, connectionText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, flexShrink: 1, }, hostSettingsButton: { width: 28, height: 28, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", borderWidth: 1, borderColor: "transparent", backgroundColor: "transparent", marginLeft: theme.spacing[2], }, hostSettingsButtonActive: { backgroundColor: theme.colors.surface3, }, advancedTrigger: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: "transparent", }, advancedTriggerText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, disabled: { opacity: theme.opacity[50], }, testResultText: { fontSize: theme.fontSize.xs, }, // Add host button addButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[3], borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, borderStyle: "dashed", }, addButtonText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, // Add/Edit form formCard: { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, padding: theme.spacing[4], marginBottom: theme.spacing[3], gap: theme.spacing[4], }, formTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, }, formField: { gap: theme.spacing[2], }, formActionsRow: { flexDirection: "row", justifyContent: "flex-end", gap: theme.spacing[2], }, formButton: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[4], borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, }, formButtonPrimary: { backgroundColor: theme.colors.palette.blue[500], borderColor: theme.colors.palette.blue[500], }, formButtonText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, formButtonPrimaryText: { color: theme.colors.palette.white, }, // Audio settings card audioCard: { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, overflow: "hidden", }, audioRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], }, audioRowBorder: { borderTopWidth: 1, borderTopColor: theme.colors.border, }, audioRowContent: { flex: 1, marginRight: theme.spacing[3], }, audioRowTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base, }, permissionSectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], marginBottom: theme.spacing[3], }, permissionRefreshButton: { width: 34, height: 34, borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface2, alignItems: "center", justifyContent: "center", }, permissionRefreshButtonDisabled: { opacity: theme.opacity[50], }, permissionRowActions: { alignItems: "flex-end", gap: theme.spacing[1], }, permissionStatusPill: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], borderRadius: theme.borderRadius.full, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface3, paddingHorizontal: theme.spacing[2], paddingVertical: 4, minWidth: 88, justifyContent: "center", }, permissionStatusText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, permissionDetailText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, maxWidth: 220, textAlign: "right", }, aboutValue: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, // Empty state emptyCard: { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, padding: theme.spacing[4], marginBottom: theme.spacing[3], }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, textAlign: "center", }, // Theme toggle themeToggleContainer: { flexDirection: "row", backgroundColor: theme.colors.surface3, borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, padding: 2, gap: 2, }, themeToggleButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[1], paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.sm, }, themeToggleButtonActive: { backgroundColor: theme.colors.surface2, }, themeToggleText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, themeToggleTextActive: { color: theme.colors.foreground, }, // Dev section devCard: { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, overflow: "hidden", }, devButton: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], }, devButtonBorder: { borderTopWidth: 1, borderTopColor: theme.colors.border, }, devButtonContent: { flex: 1, }, devButtonTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base, }, devButtonDescription: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, marginTop: 2, }, })); export default function SettingsScreen() { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>(); const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : ""; const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings(); const { daemons, isLoading: daemonLoading, updateHost, removeHost, removeConnection, } = useDaemonRegistry(); const { connectionStates } = useDaemonConnections(); const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false); const [isDirectHostVisible, setIsDirectHostVisible] = useState(false); const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false); const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState(null); const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState(null); const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null); const [pendingRemoveHost, setPendingRemoveHost] = useState(null); const [isRemovingHost, setIsRemovingHost] = useState(false); const [editingDaemon, setEditingDaemon] = useState(null); const [isSavingEdit, setIsSavingEdit] = useState(false); const [desktopPermissionSnapshot, setDesktopPermissionSnapshot] = useState(null); const [isRefreshingDesktopPermissions, setIsRefreshingDesktopPermissions] = useState(false); const [requestingDesktopPermission, setRequestingDesktopPermission] = useState(null); const isLoading = settingsLoading || daemonLoading; const showDesktopPermissionSection = shouldShowDesktopPermissionSection(); const isMountedRef = useRef(true); const lastHandledEditHostRef = useRef(null); const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0"; const editingServerId = editingDaemon?.serverId ?? null; const editingDaemonLive = editingServerId ? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null : null; const pendingNameHostname = useSessionStore( useCallback( (state) => { if (!pendingNameHost) return null; return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null; }, [pendingNameHost] ) ); useEffect(() => { return () => { isMountedRef.current = false; }; }, []); // Keep the edit modal bound to live registry state. useEffect(() => { if (!editingServerId) return; if (editingDaemonLive) return; setEditingDaemon(null); }, [editingDaemonLive, editingServerId]); const waitForCondition = useCallback( async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (!isMountedRef.current) { return false; } if (predicate()) { return true; } await delay(intervalMs); } return predicate(); }, [] ); const handleEditDaemon = useCallback((profile: HostProfile) => { setEditingDaemon(profile); }, []); const handleCloseEditDaemon = useCallback(() => { if (isSavingEdit) return; setEditingDaemon(null); }, [isSavingEdit]); const closeAddConnectionFlow = useCallback(() => { setIsAddHostMethodVisible(false); setIsDirectHostVisible(false); setIsPasteLinkVisible(false); setAddConnectionTargetServerId(null); }, []); const goBackToAddConnectionMethods = useCallback(() => { setIsDirectHostVisible(false); setIsPasteLinkVisible(false); setIsAddHostMethodVisible(true); }, []); useEffect(() => { const editHost = typeof params.editHost === "string" ? params.editHost.trim() : ""; if (!editHost) return; if (lastHandledEditHostRef.current === editHost) return; const profile = daemons.find((daemon) => daemon.serverId === editHost) ?? null; if (!profile) return; lastHandledEditHostRef.current = editHost; handleEditDaemon(profile); }, [daemons, handleEditDaemon, params.editHost]); useEffect(() => { if (!pendingEditReopenServerId) return; if (isAddHostMethodVisible || isDirectHostVisible || isPasteLinkVisible) return; const profile = daemons.find((daemon) => daemon.serverId === pendingEditReopenServerId) ?? null; setPendingEditReopenServerId(null); setAddConnectionTargetServerId(null); if (profile) { handleEditDaemon(profile); } }, [ daemons, handleEditDaemon, isAddHostMethodVisible, isDirectHostVisible, isPasteLinkVisible, pendingEditReopenServerId, ]); const refreshDesktopPermissions = useCallback(async () => { if (!showDesktopPermissionSection) return; setIsRefreshingDesktopPermissions(true); try { const snapshot = await getDesktopPermissionSnapshot(); if (!isMountedRef.current) return; setDesktopPermissionSnapshot(snapshot); } catch (error) { console.error("[Settings] Failed to load desktop permission status", error); } finally { if (isMountedRef.current) { setIsRefreshingDesktopPermissions(false); } } }, [showDesktopPermissionSection]); const handleRequestDesktopPermission = useCallback( async (kind: DesktopPermissionKind) => { if (!showDesktopPermissionSection) return; setRequestingDesktopPermission(kind); try { const status = await requestDesktopPermission({ kind }); if (!isMountedRef.current) return; setDesktopPermissionSnapshot((previous) => { const base: DesktopPermissionSnapshot = previous ?? { checkedAt: Date.now(), notifications: { state: "unknown", detail: "Notification status has not been checked yet.", }, microphone: { state: "unknown", detail: "Microphone status has not been checked yet.", }, }; return kind === "notifications" ? { ...base, checkedAt: Date.now(), notifications: status, } : { ...base, checkedAt: Date.now(), microphone: status, }; }); } catch (error) { console.error(`[Settings] Failed to request ${kind} permission`, error); } finally { if (isMountedRef.current) { setRequestingDesktopPermission(null); } await refreshDesktopPermissions(); } }, [refreshDesktopPermissions, showDesktopPermissionSection] ); useEffect(() => { if (!showDesktopPermissionSection) return; void refreshDesktopPermissions(); }, [refreshDesktopPermissions, showDesktopPermissionSection]); const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => { if (!editingServerId) return; if (isSavingEdit) return; const nextLabel = nextLabelRaw.trim(); if (!nextLabel) { Alert.alert("Label required", "Enter a label for this host."); return; } try { setIsSavingEdit(true); await updateHost(editingServerId, { label: nextLabel }); handleCloseEditDaemon(); } catch (error) { console.error("[Settings] Failed to rename host", error); Alert.alert("Error", "Unable to save host"); } finally { setIsSavingEdit(false); } }, [editingServerId, handleCloseEditDaemon, isSavingEdit, updateHost]); const handleRemoveConnection = useCallback( async (serverId: string, connectionId: string) => { await removeConnection(serverId, connectionId); }, [removeConnection] ); const handleRemoveDaemon = useCallback((profile: HostProfile) => { setEditingDaemon(null); setPendingRemoveHost(profile); }, []); const handleAddConnectionFromModal = useCallback(() => { if (!editingServerId) return; const serverId = editingServerId; setEditingDaemon(null); setAddConnectionTargetServerId(serverId); setPendingEditReopenServerId(serverId); setIsAddHostMethodVisible(true); }, [editingServerId]); const handleThemeChange = useCallback( (newTheme: AppSettings["theme"]) => { void updateSettings({ theme: newTheme }); if (newTheme === "auto") { UnistylesRuntime.setAdaptiveThemes(true); } else { UnistylesRuntime.setAdaptiveThemes(false); UnistylesRuntime.setTheme(newTheme); } }, [updateSettings] ); const restartConfirmationMessage = "This will immediately stop the Paseo daemon process. The app will disconnect until it restarts."; if (isLoading) { return ( Loading settings... ); } return ( {/* Host Management */} Hosts {daemons.length === 0 ? ( No hosts configured ) : ( daemons.map((daemon) => { const connection = connectionStates.get(daemon.serverId); const connectionStatus = connection?.status ?? "idle"; const activeConnection = connection?.activeConnection ?? null; const lastConnectionError = connection?.lastError ?? null; return ( ); }) )} { setAddConnectionTargetServerId(null); setPendingEditReopenServerId(null); setIsAddHostMethodVisible(true); }} > + Add connection { setIsAddHostMethodVisible(false); setIsDirectHostVisible(true); }} onPasteLink={() => { setIsAddHostMethodVisible(false); setIsPasteLinkVisible(true); }} onScanQr={() => { const targetServerId = addConnectionTargetServerId; const source = targetServerId ? "editHost" : "settings"; const sourceServerId = routeServerId || targetServerId || undefined; closeAddConnectionFlow(); router.push({ pathname: "/pair-scan", params: targetServerId ? { source, targetServerId, sourceServerId } : { source, sourceServerId }, }); }} /> { if (isNewHost) { setPendingNameHost({ serverId, hostname }); } }} /> { if (isNewHost) { setPendingNameHost({ serverId, hostname }); } }} /> {pendingNameHost ? ( setPendingNameHost(null)} onSave={(label) => { void updateHost(pendingNameHost.serverId, { label }).finally(() => { setPendingNameHost(null); }); }} /> ) : null} {pendingRemoveHost ? ( { if (isRemovingHost) return; setPendingRemoveHost(null); }} testID="remove-host-confirm-modal" > Remove {pendingRemoveHost.label}? This will delete its saved connections. ) : null} void handleSaveEditDaemon(label)} onRemoveConnection={handleRemoveConnection} onRemoveHost={handleRemoveDaemon} onAddConnection={handleAddConnectionFromModal} restartConfirmationMessage={restartConfirmationMessage} waitForCondition={waitForCondition} isScreenMountedRef={isMountedRef} /> {/* Appearance */} Appearance Theme handleThemeChange("light")} > Light handleThemeChange("dark")} > Dark handleThemeChange("auto")} > System {showDesktopPermissionSection ? ( Desktop permissions [ styles.permissionRefreshButton, (isRefreshingDesktopPermissions || requestingDesktopPermission !== null) && styles.permissionRefreshButtonDisabled, pressed && { opacity: 0.85 }, ]} onPress={() => { void refreshDesktopPermissions(); }} disabled={ isRefreshingDesktopPermissions || requestingDesktopPermission !== null } accessibilityRole="button" accessibilityLabel="Refresh desktop permissions" > { void handleRequestDesktopPermission("notifications"); }} /> { void handleRequestDesktopPermission("microphone"); }} /> ) : null} {/* About */} About Version {appVersion} ); } interface HostDetailModalProps { visible: boolean; host: HostProfile | null; connectionStatus: ConnectionStatus; activeConnection: ActiveConnection | null; lastError: string | null; isSaving: boolean; onClose: () => void; onSave: (label: string) => void; onRemoveConnection: (serverId: string, connectionId: string) => Promise; onRemoveHost: (host: HostProfile) => void; onAddConnection: () => void; restartConfirmationMessage: string; waitForCondition: (predicate: () => boolean, timeoutMs: number, intervalMs?: number) => Promise; isScreenMountedRef: MutableRefObject; } function HostDetailModal({ visible, host, connectionStatus, activeConnection, lastError, isSaving, onClose, onSave, onRemoveConnection, onRemoveHost, onAddConnection, restartConfirmationMessage, waitForCondition, isScreenMountedRef, }: HostDetailModalProps) { const { theme } = useUnistyles(); const [draftLabel, setDraftLabel] = useState(""); 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; } void confirmDialog({ title: `Restart ${host.label}`, message: restartConfirmationMessage, confirmLabel: "Restart", cancelLabel: "Cancel", destructive: true, }).then((confirmed) => { if (!confirmed) { return; } beginServerRestart(); }).catch((error) => { console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error); Alert.alert("Error", "Unable to open the restart confirmation dialog."); }); }, [beginServerRestart, daemonClient, host, restartConfirmationMessage]); // Status display const statusLabel = formatConnectionStatus(connectionStatus); const statusTone = getConnectionStatusTone(connectionStatus); const statusColor = statusTone === "success" ? theme.colors.palette.green[400] : statusTone === "warning" ? theme.colors.palette.amber[500] : statusTone === "error" ? theme.colors.destructive : theme.colors.foregroundMuted; const statusPillBg = statusTone === "success" ? "rgba(74, 222, 128, 0.1)" : statusTone === "warning" ? "rgba(245, 158, 11, 0.1)" : statusTone === "error" ? "rgba(248, 113, 113, 0.1)" : "rgba(161, 161, 170, 0.1)"; const connectionBadge = (() => { if (!activeConnection) return null; if (activeConnection.type === "relay") { return { icon: , text: "Relay" }; } return { icon: , text: activeConnection.display, }; })(); const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; const handleDraftLabelChange = useCallback((nextValue: string) => { setDraftLabel(nextValue); }, []); useEffect(() => { if (!visible || !host) return; // Initialize once per modal open / host switch; keep user edits fully local while typing. setDraftLabel(host.label ?? ""); }, [visible, host?.serverId]); useEffect(() => { if (!visible) { setIsRestarting(false); setDraftLabel(""); } }, [visible]); return ( <> {/* Status row */} {statusLabel} {connectionBadge ? ( {connectionBadge.icon} {connectionBadge.text} ) : null} {connectionError ? ( {connectionError} ) : null} {/* Label */} Label {/* Connections */} {host ? ( Connections {host.connections.map((conn) => { const latency = latencyByConnectionId.get(conn.id); return ( { const title = conn.type === "relay" ? `Relay (${conn.relayEndpoint})` : `Direct (${conn.endpoint})`; setPendingRemoveConnection({ serverId: host.serverId, connectionId: conn.id, title }); }} /> ); })} + Add connection ) : null} {/* Save/Cancel + Advanced */} [ styles.advancedTrigger, pressed && { opacity: 0.85 }, ]} > Advanced } status={isRestarting ? "pending" : "idle"} pendingLabel="Restarting..." disabled={!daemonClient || !isConnectedRef.current} > Restart daemon { if (host) onRemoveHost(host); }} leading={} > Remove host {/* Remove connection confirmation */} {pendingRemoveConnection ? ( { if (isRemovingConnection) return; setPendingRemoveConnection(null); }} testID="remove-connection-confirm-modal" > Remove {pendingRemoveConnection.title}? This cannot be undone. ) : null} ); } interface DesktopPermissionRowProps { title: string; status: DesktopPermissionStatus | null; isRequesting: boolean; showBorder?: boolean; onRequest: () => void; } function DesktopPermissionRow({ title, status, isRequesting, showBorder, onRequest, }: DesktopPermissionRowProps) { const { theme } = useUnistyles(); const state = status?.state ?? "unknown"; const isGranted = state === "granted"; const shouldShowDetail = status !== null && status.detail.trim().length > 0 && state !== "granted" && state !== "prompt" && state !== "not-granted"; return ( {title} {isGranted ? ( Granted ) : ( )} {shouldShowDetail ? ( {status?.detail} ) : null} ); } function ConnectionRow({ connection, latencyMs, latencyLoading, latencyError, onRemove, }: { connection: HostConnection; latencyMs: number | null | undefined; latencyLoading: boolean; latencyError: boolean; onRemove: () => void; }) { const { theme } = useUnistyles(); const title = connection.type === "relay" ? `Relay (${connection.relayEndpoint})` : `Direct (${connection.endpoint})`; const latencyText = (() => { if (latencyLoading) return "..."; if (latencyError) return "Timeout"; if (latencyMs != null) return `${latencyMs}ms`; return "\u2014"; })(); const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted; return ( {title} {latencyText} Remove ); } interface DaemonCardProps { daemon: HostProfile; connectionStatus: ConnectionStatus; activeConnection: ActiveConnection | null; lastError: string | null; onOpenSettings: (daemon: HostProfile) => void; } function DaemonCard({ daemon, connectionStatus, activeConnection, lastError, onOpenSettings, }: DaemonCardProps) { const { theme } = useUnistyles(); 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 badgeText = statusLabel; const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; const statusPillBg = statusTone === "success" ? "rgba(74, 222, 128, 0.1)" : statusTone === "warning" ? "rgba(245, 158, 11, 0.1)" : statusTone === "error" ? "rgba(248, 113, 113, 0.1)" : "rgba(161, 161, 170, 0.1)"; const connectionBadge = (() => { if (!activeConnection) return null; if (activeConnection.type === "relay") { return { icon: , text: "Relay" }; } return { icon: , text: activeConnection.display, }; })(); return ( {daemon.label} {Platform.OS === "web" ? ( {badgeText} ) : null} {connectionBadge ? ( {connectionBadge.icon} {Platform.OS === "web" ? ( {connectionBadge.text} ) : null} ) : null} [ styles.hostSettingsButton, (pressed || hovered) && styles.hostSettingsButtonActive, ]} onPress={() => onOpenSettings(daemon)} testID={`daemon-card-settings-${daemon.serverId}`} accessibilityRole="button" accessibilityLabel={`Open settings for ${daemon.label}`} > {({ pressed, hovered }) => ( )} {connectionError ? {connectionError} : null} ); }