import { useCallback, useMemo, useReducer, useState } from "react"; import { Alert, Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { parseConnectionUri, serializeConnectionUri, serializeConnectionUriForStorage, } from "@/utils/daemon-endpoints"; import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; const FLEX_ONE_STYLE = { flex: 1 } as const; const DIRECT_CONNECTION_HEADER: SheetHeader = { title: "Direct connection" }; interface DirectConnectionDraft { host: string; port: string; useTls: boolean; password: string; } interface PreparedDirectConnection { uri: string; endpoint: string; useTls: boolean; password?: string; } const styles = StyleSheet.create((theme) => ({ field: { gap: theme.spacing[2], }, label: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, input: { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], color: theme.colors.foreground, borderWidth: 1, borderColor: theme.colors.border, }, portRow: { flexDirection: "row", gap: theme.spacing[3], }, hostField: { flex: 1, minWidth: 0, }, portField: { width: 112, }, passwordRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, passwordInput: { flex: 1, minWidth: 0, }, iconButton: { width: 44, height: 44, alignItems: "center", justifyContent: "center", borderRadius: theme.borderRadius.lg, backgroundColor: theme.colors.surface2, borderWidth: 1, borderColor: theme.colors.border, }, checkboxRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], }, checkbox: { width: 22, height: 22, borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.border, alignItems: "center", justifyContent: "center", }, checkboxChecked: { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent, }, advancedToggle: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], alignSelf: "flex-start", paddingVertical: theme.spacing[1], }, advancedText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, actions: { flexDirection: "row", gap: theme.spacing[3], marginTop: theme.spacing[2], }, helper: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm, }, })); function isIpv6Host(host: string): boolean { return host.includes(":") && !host.startsWith("[") && !host.endsWith("]"); } function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string { const host = draft.host.trim(); const port = Number(draft.port.trim()); if (!host) { throw new Error("Host is required"); } if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("Port must be between 1 and 65535"); } return serializeConnectionUriForStorage({ host, port, isIpv6: isIpv6Host(host), useTls: draft.useTls, ...(draft.password ? { password: draft.password } : {}), }); } function prepareDirectConnection(draft: DirectConnectionDraft): PreparedDirectConnection { const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft)); const endpoint = parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`; return { uri: serializeConnectionUri(parsed), endpoint, useTls: parsed.useTls, ...(parsed.password ? { password: parsed.password } : {}), }; } function draftFromConnectionUri(uri: string): DirectConnectionDraft { const parsed = parseConnectionUri(uri); return { host: parsed.host, port: String(parsed.port), useTls: parsed.useTls, password: parsed.password ?? "", }; } function normalizeTransportMessage(message: string | null | undefined): string | null { if (!message) return null; const trimmed = message.trim(); if (!trimmed) return null; return trimmed; } function formatTechnicalTransportDetails(details: (string | null)[]): string | null { const unique = Array.from( new Set( details .map((value) => normalizeTransportMessage(value)) .filter((value): value is string => Boolean(value)) .map((value) => value.trim()) .filter((value) => value.length > 0), ), ); if (unique.length === 0) return null; const allGeneric = unique.every((value) => { const lower = value.toLowerCase(); return lower === "transport error" || lower === "transport closed"; }); if (allGeneric) { return `${unique[0]} (no additional details provided)`; } return unique.join(" — "); } function buildConnectionFailureCopy( endpoint: string, error: unknown, ): { title: string; detail: string | null; raw: string | null } { const title = `We failed to connect to ${endpoint}.`; const raw = (() => { if (error instanceof DaemonConnectionTestError) { return ( formatTechnicalTransportDetails([error.reason, error.lastError]) ?? normalizeTransportMessage(error.message) ); } if (error instanceof Error) { return normalizeTransportMessage(error.message); } return null; })(); const rawLower = raw?.toLowerCase() ?? ""; let detail: string | null = null; if (raw === "Incorrect password" || raw === "Password required") { detail = raw; } else if (rawLower.includes("timed out")) { detail = "Connection timed out. Check the host/port and your network."; } else if ( rawLower.includes("econnrefused") || rawLower.includes("connection refused") || rawLower.includes("err_connection_refused") ) { detail = "Connection refused. Is the server running at this address?"; } else if (rawLower.includes("enotfound") || rawLower.includes("not found")) { detail = "Host not found. Check the hostname and try again."; } else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) { detail = "Host is unreachable. Check your network and firewall."; } else if ( rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl") ) { detail = "TLS error. Direct connections use SSL only when a TLS terminator is in front of the daemon."; } else { detail = "Unable to connect. Check the host/port and that the daemon is reachable."; } return { title, detail, raw }; } export interface AddHostModalProps { visible: boolean; onClose: () => void; onCancel?: () => void; onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean; }) => void; } export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) { const { theme } = useUnistyles(); const daemons = useHosts(); const { probeAndUpsertDirectConnection } = useHostMutations(); const isMobile = useIsCompactFormFactor(); const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const [host, setHost] = useState(""); const [port, setPort] = useState("6767"); const [useTls, setUseTls] = useState(false); const [password, setPassword] = useState(""); const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const [advancedUri, setAdvancedUri] = useState(""); const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0); const clearInput = useCallback(() => { setHost(""); setPort("6767"); setUseTls(false); setPassword(""); setIsPasswordVisible(false); setIsAdvancedOpen(false); setAdvancedUri(""); bumpInputResetKey(); }, []); const connectIcon = useMemo( () => , [theme.colors.palette.white], ); const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []); const portFieldStyle = useMemo(() => [styles.field, styles.portField], []); const checkboxStyle = useMemo( () => [styles.checkbox, useTls ? styles.checkboxChecked : null], [useTls], ); const passwordInputStyle = useMemo(() => [styles.input, styles.passwordInput], []); const useTlsAccessibilityState = useMemo( () => ({ checked: useTls, disabled: isSaving }), [isSaving, useTls], ); const handleClose = useCallback(() => { if (isSaving) return; clearInput(); setErrorMessage(""); onClose(); }, [isSaving, clearInput, onClose]); const handleCancel = useCallback(() => { if (isSaving) return; clearInput(); setErrorMessage(""); (onCancel ?? onClose)(); }, [isSaving, clearInput, onCancel, onClose]); const handleSave = useCallback(async () => { if (isSaving) return; let connection: PreparedDirectConnection; try { connection = prepareDirectConnection({ host, port, useTls, password }); } catch (error) { const message = error instanceof Error ? error.message : "Invalid connection"; setErrorMessage(message); return; } try { setIsSaving(true); setErrorMessage(""); const { profile, serverId, hostname } = await probeAndUpsertDirectConnection({ endpoint: connection.endpoint, useTls: connection.useTls, ...(connection.password ? { password: connection.password } : {}), }); const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId); onSaved?.({ profile, serverId, hostname, isNewHost }); handleClose(); } catch (error) { const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(connection.uri, error); let combined: string; if (rawDetail && detail && rawDetail !== detail) { combined = `${title}\n${detail}\nDetails: ${rawDetail}`; } else if (detail) { combined = `${title}\n${detail}`; } else { combined = title; } setErrorMessage(combined); if (!isMobile) { Alert.alert("Connection failed", combined); } } finally { setIsSaving(false); } }, [ daemons, handleClose, host, isMobile, isSaving, onSaved, password, port, probeAndUpsertDirectConnection, useTls, ]); const handleSubmitEditing = useCallback(() => { void handleSave(); }, [handleSave]); const handleSavePress = useCallback(() => { void handleSave(); }, [handleSave]); const handleToggleUseTls = useCallback(() => { if (isSaving) return; setUseTls((current) => !current); }, [isSaving]); const handleTogglePasswordVisibility = useCallback(() => { setIsPasswordVisible((current) => !current); }, []); const handleToggleAdvanced = useCallback(() => { if (!isAdvancedOpen) { try { setAdvancedUri(buildConnectionUriFromDraft({ host, port, useTls, password })); } catch { setAdvancedUri(""); } setErrorMessage(""); setIsAdvancedOpen(true); return; } try { const next = draftFromConnectionUri(advancedUri); setHost(next.host); setPort(next.port); setUseTls(next.useTls); setPassword(next.password); setErrorMessage(""); bumpInputResetKey(); } catch { setErrorMessage(""); } setIsAdvancedOpen(false); }, [advancedUri, host, isAdvancedOpen, password, port, useTls]); const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight; const PasswordIcon = isPasswordVisible ? EyeOff : Eye; return ( Enter the address of a Paseo server. Host Port {useTls ? ( ) : null} Use SSL Password Advanced {isAdvancedOpen ? ( ) : null} {errorMessage ? {errorMessage} : null} ); }