diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index bf364f1040..653d69c4f1 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -11,10 +11,7 @@ import { serializeConnectionUri, serializeConnectionUriForStorage, } from "@/utils/daemon-endpoints"; -import { - DaemonConnectionTestError, - getConnectionAuthFailureReason, -} from "@/utils/test-daemon-connection"; +import { buildConnectionFailureCopy } from "./connection-failure-copy"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { PairingTargetTracker } from "./pair-link-credentials"; @@ -195,92 +192,6 @@ function draftFromConnectionUri(uri: string): DirectConnectionDraft { }; } -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)[], - labels: DirectConnectionLabels, -): 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 labels.noAdditionalDetails(unique[0] ?? ""); - } - - return unique.join(" — "); -} - -function buildConnectionFailureCopy(input: { - endpoint: string; - error: unknown; - labels: DirectConnectionLabels; -}): { title: string; detail: string | null; raw: string | null } { - const { endpoint, error, labels } = input; - const title = labels.failedToConnect(endpoint); - - const raw = (() => { - if (error instanceof DaemonConnectionTestError) { - return ( - formatTechnicalTransportDetails([error.reason, error.lastError], labels) ?? - normalizeTransportMessage(error.message) - ); - } - if (error instanceof Error) { - return normalizeTransportMessage(error.message); - } - return null; - })(); - - const rawLower = raw?.toLowerCase() ?? ""; - let detail: string | null = null; - - if (getConnectionAuthFailureReason(error)) { - detail = error instanceof Error ? error.message : raw; - } else if (rawLower.includes("timed out")) { - detail = labels.timedOut; - } else if ( - rawLower.includes("econnrefused") || - rawLower.includes("connection refused") || - rawLower.includes("err_connection_refused") - ) { - detail = labels.refused; - } else if (rawLower.includes("enotfound") || rawLower.includes("not found")) { - detail = labels.hostNotFound; - } else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) { - detail = labels.hostUnreachable; - } else if ( - rawLower.includes("certificate") || - rawLower.includes("tls") || - rawLower.includes("ssl") - ) { - detail = labels.tlsError; - } else { - detail = labels.unableToConnect; - } - - return { title, detail, raw }; -} - export interface AddHostModalProps { visible: boolean; onClose: () => void; diff --git a/packages/app/src/components/connection-failure-copy.test.ts b/packages/app/src/components/connection-failure-copy.test.ts new file mode 100644 index 0000000000..21cfcd40e5 --- /dev/null +++ b/packages/app/src/components/connection-failure-copy.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; +import { buildConnectionFailureCopy } from "./connection-failure-copy"; + +const labels = { + hostRequired: "host required", + invalidPort: "invalid port", + invalidConnection: "invalid connection", + failedToConnect: (endpoint: string) => `Failed: ${endpoint}`, + noAdditionalDetails: (detail: string) => `No details: ${detail}`, + timedOut: "timeout", + refused: "refused", + hostNotFound: "DNS failure", + hostUnreachable: "unreachable", + tlsError: "TLS failure", + unableToConnect: "connection failure", +}; + +function failure(message: string) { + return buildConnectionFailureCopy({ + endpoint: "tcp://example.test:443?ssl=true", + error: new DaemonConnectionTestError(message, { + reason: "transport error", + lastError: message, + }), + labels, + }); +} + +describe("direct connection failure copy", () => { + it("identifies Android certificate validation errors without calling them DNS failures", () => { + const message = + "java.security.cert.CertPathValidatorException: Trust anchor for certification path not found."; + expect(failure(message)).toEqual({ + title: "Failed: tcp://example.test:443?ssl=true", + detail: labels.tlsError, + raw: `transport error — ${message}`, + }); + }); + + it.each([ + ["certificate not found", labels.tlsError], + ["SSL handshake failed", labels.tlsError], + ["TLS handshake failed", labels.tlsError], + ["getaddrinfo ENOTFOUND example.test", labels.hostNotFound], + ["Host not found", labels.hostNotFound], + ["connect ECONNREFUSED", labels.refused], + ["connection timed out", labels.timedOut], + ["EHOSTUNREACH", labels.hostUnreachable], + ["unexpected transport failure", labels.unableToConnect], + ])("classifies %s", (message, expected) => { + expect(failure(message).detail).toBe(expected); + }); + + it("preserves authentication failures and their technical details", () => { + const message = "Invalid daemon password"; + expect( + buildConnectionFailureCopy({ + endpoint: "example.test:6767", + error: new DaemonConnectionTestError(message, { + reason: message, + lastError: message, + authFailureReason: "incorrect_password", + }), + labels, + }), + ).toMatchObject({ detail: message, raw: message }); + }); +}); diff --git a/packages/app/src/components/connection-failure-copy.ts b/packages/app/src/components/connection-failure-copy.ts new file mode 100644 index 0000000000..1491ed01d8 --- /dev/null +++ b/packages/app/src/components/connection-failure-copy.ts @@ -0,0 +1,103 @@ +import { + DaemonConnectionTestError, + getConnectionAuthFailureReason, +} from "@/utils/test-daemon-connection"; + +interface ConnectionFailureLabels { + failedToConnect: (endpoint: string) => string; + noAdditionalDetails: (detail: string) => string; + timedOut: string; + refused: string; + hostNotFound: string; + hostUnreachable: string; + tlsError: string; + unableToConnect: string; +} + +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)[], + labels: ConnectionFailureLabels, +): 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 labels.noAdditionalDetails(unique[0] ?? ""); + } + + return unique.join(" — "); +} + +export function buildConnectionFailureCopy(input: { + endpoint: string; + error: unknown; + labels: ConnectionFailureLabels; +}): { title: string; detail: string | null; raw: string | null } { + const { endpoint, error, labels } = input; + const title = labels.failedToConnect(endpoint); + + const raw = (() => { + if (error instanceof DaemonConnectionTestError) { + return ( + formatTechnicalTransportDetails([error.reason, error.lastError], labels) ?? + normalizeTransportMessage(error.message) + ); + } + if (error instanceof Error) { + return normalizeTransportMessage(error.message); + } + return null; + })(); + + const rawLower = raw?.toLowerCase() ?? ""; + let detail: string | null = null; + + if (getConnectionAuthFailureReason(error)) { + detail = error instanceof Error ? error.message : raw; + } else if (rawLower.includes("timed out")) { + detail = labels.timedOut; + } else if ( + rawLower.includes("econnrefused") || + rawLower.includes("connection refused") || + rawLower.includes("err_connection_refused") + ) { + detail = labels.refused; + } else if ( + rawLower.includes("certificate") || + rawLower.includes("certpathvalidator") || + rawLower.includes("trust anchor") || + rawLower.includes("tls") || + rawLower.includes("ssl") + ) { + detail = labels.tlsError; + } else if (rawLower.includes("enotfound") || rawLower.includes("not found")) { + detail = labels.hostNotFound; + } else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) { + detail = labels.hostUnreachable; + } else { + detail = labels.unableToConnect; + } + + return { title, detail, raw }; +}