import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client"; import type { HostConnection } from "@/types/host-connection"; import { getOrCreateClientId } from "./client-id"; import { resolveAppVersion } from "./app-version"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, shouldUseTlsForDefaultHostedRelay, } from "./daemon-endpoints"; import { buildLocalDaemonTransportUrl, createDesktopLocalDaemonTransportFactory, } from "@/desktop/daemon/desktop-daemon-transport"; export interface DaemonProbeClient { readonly lastError: string | null; connect(): Promise; close(): Promise; getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null; } interface LocalTransportUrlInput { transportType: "socket" | "pipe"; transportPath: string; } export interface DaemonConnectionDependencies { getClientId(): Promise; resolveAppVersion(): string | null; createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null; buildLocalTransportUrl(input: LocalTransportUrlInput): string; createClient(config: DaemonClientConfig): TClient; } const defaultDaemonConnectionDependencies: DaemonConnectionDependencies = { getClientId: getOrCreateClientId, resolveAppVersion, createLocalTransportFactory: createDesktopLocalDaemonTransportFactory, buildLocalTransportUrl: buildLocalDaemonTransportUrl, createClient: (config) => new DaemonClient(config), }; function normalizeNonEmptyString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function pickBestReason(reason: string | null, lastError: string | null): string { const genericReason = reason && (reason.toLowerCase() === "transport error" || reason.toLowerCase() === "transport closed"); const genericLastError = lastError && (lastError.toLowerCase() === "transport error" || lastError.toLowerCase() === "transport closed" || lastError.toLowerCase() === "unable to connect"); if (genericReason && lastError && !genericLastError) { return lastError; } if (reason) return reason; if (lastError) return lastError; return "Unable to connect"; } function isIncorrectPasswordFailure(input: { config: DaemonClientConfig; reason: string | null; lastError: string | null; }): boolean { if (!input.config.password) { return false; } const details = [input.reason, input.lastError].filter(Boolean).join("\n").toLowerCase(); return ( details.includes("401") || details.includes("4001") || details.includes("unauthorized") || details.includes("code 1006") ); } export class DaemonConnectionTestError extends Error { reason: string | null; lastError: string | null; constructor(message: string, details: { reason: string | null; lastError: string | null }) { super(message); this.name = "DaemonConnectionTestError"; this.reason = details.reason; this.lastError = details.lastError; } } export async function buildClientConfig( connection: HostConnection, serverId?: string, deps: Pick< DaemonConnectionDependencies, "getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl" > = defaultDaemonConnectionDependencies, ): Promise { const clientId = await deps.getClientId(); const localTransportFactory = deps.createLocalTransportFactory(); const base = { clientId, clientType: "mobile" as const, appVersion: deps.resolveAppVersion() ?? undefined, suppressSendErrors: true, reconnect: { enabled: false }, ...((connection.type === "directSocket" || connection.type === "directPipe") && localTransportFactory ? { transportFactory: localTransportFactory } : {}), }; if (connection.type === "directSocket" || connection.type === "directPipe") { return { ...base, url: deps.buildLocalTransportUrl({ transportType: connection.type === "directSocket" ? "socket" : "pipe", transportPath: connection.path, }), }; } if (connection.type === "directTcp") { return { ...base, url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }), ...(connection.password ? { password: connection.password } : {}), }; } if (!serverId) { throw new Error("serverId is required to probe a relay connection"); } return { ...base, url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), serverId, }), e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 }, }; } export function connectAndProbe( config: DaemonClientConfig, timeoutMs: number, ): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>; export function connectAndProbe( config: DaemonClientConfig, timeoutMs: number, deps: Pick, "createClient">, ): Promise<{ client: TClient; serverId: string; hostname: string | null }>; export function connectAndProbe( config: DaemonClientConfig, timeoutMs: number, deps: Pick< DaemonConnectionDependencies, "createClient" > = defaultDaemonConnectionDependencies, ): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> { const client = deps.createClient(config); return new Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }>( (resolve, reject) => { const timer = setTimeout(() => { void client.close().catch(() => undefined); reject( new DaemonConnectionTestError("Connection timed out", { reason: "Connection timed out", lastError: client.lastError ?? null, }), ); }, timeoutMs); void client .connect() .then(() => { clearTimeout(timer); const serverInfo = client.getLastServerInfoMessage(); if (!serverInfo) { void client.close().catch(() => undefined); reject( new DaemonConnectionTestError("Missing server info message", { reason: "Missing server info message", lastError: client.lastError ?? null, }), ); return; } resolve({ client, serverId: serverInfo.serverId, hostname: serverInfo.hostname, }); return; }) .catch((error) => { clearTimeout(timer); const reason = normalizeNonEmptyString( error instanceof Error ? error.message : String(error), ); const lastError = normalizeNonEmptyString(client.lastError); const message = isIncorrectPasswordFailure({ config, reason, lastError }) ? "Incorrect password" : pickBestReason(reason, lastError); void client.close().catch(() => undefined); reject(new DaemonConnectionTestError(message, { reason, lastError })); }); }, ); } 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 function connectToDaemon( connection: HostConnection, options?: ProbeOptions, ): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>; export function connectToDaemon( connection: HostConnection, options: ProbeOptions | undefined, deps: DaemonConnectionDependencies, ): Promise<{ client: TClient; serverId: string; hostname: string | null }>; export async function connectToDaemon( connection: HostConnection, options?: ProbeOptions, deps: DaemonConnectionDependencies = defaultDaemonConnectionDependencies, ): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> { const config = await buildClientConfig(connection, options?.serverId, deps); return connectAndProbe(config, resolveTimeout(connection, options), deps); }