Unify the initial connection handshake and capability update paths. The server now sends a server_info status message on connect and whenever capabilities change, enabling the onboarding flow where voice becomes available after models are configured.
162 lines
5.2 KiB
TypeScript
162 lines
5.2 KiB
TypeScript
import { DaemonClient } from "@server/client/daemon-client";
|
|
import type { DaemonClientConfig } from "@server/client/daemon-client";
|
|
import type { HostConnection } from "@/types/host-connection";
|
|
import { getOrCreateClientId } from "./client-id";
|
|
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
|
|
import {
|
|
buildLocalDaemonTransportUrl,
|
|
createTauriLocalDaemonTransportFactory,
|
|
} from "./managed-tauri-daemon-transport";
|
|
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
|
|
|
|
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";
|
|
}
|
|
|
|
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
|
|
): Promise<DaemonClientConfig> {
|
|
const clientId = await getOrCreateClientId();
|
|
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
|
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
|
const base = {
|
|
clientId,
|
|
clientType: "mobile" as const,
|
|
suppressSendErrors: true,
|
|
...(connection.type === "directSocket" || connection.type === "directPipe"
|
|
? localTransportFactory
|
|
? { transportFactory: localTransportFactory }
|
|
: {}
|
|
: tauriTransportFactory
|
|
? { transportFactory: tauriTransportFactory }
|
|
: {}),
|
|
};
|
|
|
|
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
|
return {
|
|
...base,
|
|
url: buildLocalDaemonTransportUrl({
|
|
transportType: connection.type === "directSocket" ? "socket" : "pipe",
|
|
transportPath: connection.path,
|
|
}),
|
|
};
|
|
}
|
|
|
|
if (connection.type === "directTcp") {
|
|
return {
|
|
...base,
|
|
url: buildDaemonWebSocketUrl(connection.endpoint),
|
|
};
|
|
}
|
|
|
|
if (!serverId) {
|
|
throw new Error("serverId is required to probe a relay connection");
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
url: buildRelayWebSocketUrl({
|
|
endpoint: connection.relayEndpoint,
|
|
serverId,
|
|
}),
|
|
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
|
|
};
|
|
}
|
|
|
|
export function connectAndProbe(
|
|
config: DaemonClientConfig,
|
|
timeoutMs: number,
|
|
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
|
|
const client = new DaemonClient(config);
|
|
|
|
return new Promise<{ client: DaemonClient; 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,
|
|
});
|
|
}).catch((error) => {
|
|
clearTimeout(timer);
|
|
const reason = normalizeNonEmptyString(error instanceof Error ? error.message : String(error));
|
|
const lastError = normalizeNonEmptyString(client.lastError);
|
|
const message = 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 async function connectToDaemon(
|
|
connection: HostConnection,
|
|
options?: ProbeOptions,
|
|
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
|
|
const config = await buildClientConfig(connection, options?.serverId);
|
|
return connectAndProbe(config, resolveTimeout(connection, options));
|
|
}
|