"use client"; import type { DesktopTransportEvent, DesktopTransportMessage, DesktopTransportRequest, DesktopTransportResponse, DesktopTransportState, } from "@/lib/desktop-transport"; // Lazily import the Tauri invoke API only when available. When running in // sidecar/web mode (without Tauri), this module may not exist or the bridge // may not be initialised, so we fall back to a hardcoded local WS endpoint. async function tryTauriInvoke( command: string, args?: Record, ): Promise { try { const { invoke } = await import("@tauri-apps/api/core"); return await invoke(command, args); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Tauri invoke failed for ${command}: ${message}`); } } /** Resolved once on first call; cached for subsequent calls. */ let resolvedEndpointCache: string | null = null; /** * Resolve the backend WebSocket endpoint. * * Priority order: * 1. `window.__SIDECAR_WS_ENDPOINT__` — injected by the sidecar's HTML scaffold * or an integration test harness. * 2. Tauri `get_desktop_backend_endpoint` command — used when running inside * the full Tauri app shell. * 3. Fallback to `ws://127.0.0.1:3126/transport` — the sidecar's default port * when running in plain web/dev mode (`bun run dev:sidecar` + `bun run dev:web`). */ export async function resolveDesktopBackendWsEndpoint(): Promise { if (resolvedEndpointCache) return resolvedEndpointCache; // 1. Explicit injection from sidecar or test harness. const injected = typeof window !== "undefined" ? (window as unknown as Record).__SIDECAR_WS_ENDPOINT__ : undefined; if (typeof injected === "string" && injected.trim()) { resolvedEndpointCache = injected.trim(); return resolvedEndpointCache; } // 2. Tauri command (full desktop app). if (isTauriAvailable()) { const endpoint = await tryTauriInvoke( "get_desktop_backend_endpoint", ); const trimmed = endpoint.trim(); if (trimmed) { resolvedEndpointCache = trimmed; return resolvedEndpointCache; } throw new Error("Tauri returned an empty desktop backend endpoint"); } // 3. Default sidecar port for local dev mode without the Tauri bridge. resolvedEndpointCache = "ws://127.0.0.1:3126/transport"; return resolvedEndpointCache; } export async function resolveDesktopBackendHttpEndpoint(): Promise { const wsEndpoint = await resolveDesktopBackendWsEndpoint(); const endpoint = new URL(wsEndpoint); endpoint.protocol = endpoint.protocol === "wss:" ? "https:" : "http:"; if (endpoint.pathname.endsWith("/transport")) { endpoint.pathname = endpoint.pathname.slice(0, -"/transport".length); } endpoint.search = ""; endpoint.hash = ""; return endpoint.toString().replace(/\/$/, ""); } type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void; timeoutId: ReturnType; }; type EventHandler = (payload: unknown) => void; type TransportStateHandler = (state: DesktopTransportState) => void; const REQUEST_TIMEOUT_MS = 120_000; const RECONNECT_BASE_DELAY_MS = 400; const RECONNECT_MAX_DELAY_MS = 4_000; // Commands that should be routed to Tauri's native invoke bridge instead of // the WebSocket transport — only applicable in the full Tauri app shell. // In sidecar/web mode these commands are handled by the sidecar over WebSocket. function isTauriAvailable(): boolean { return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; } const NATIVE_COMMANDS = new Set([ "pick_workspace_directory", "open_mcp_settings_file", ]); class DesktopClient { private socket: WebSocket | null = null; private connectPromise: Promise | null = null; private reconnectTimer: ReturnType | null = null; private requestCounter = 0; private pending = new Map(); private handlers = new Map>(); private transportStateHandlers = new Set(); private transportState: DesktopTransportState = "connecting"; private transportError: string | null = null; private hasConnectedOnce = false; private endpoint: string | null = null; private setTransportState(next: DesktopTransportState) { this.transportState = next; for (const handler of this.transportStateHandlers) { handler(next); } } private async getBackendEndpoint(): Promise { if (this.endpoint?.trim()) { return this.endpoint; } const endpoint = await resolveDesktopBackendWsEndpoint(); this.endpoint = endpoint; return this.endpoint; } private rejectPending(errorMessage: string) { for (const [requestId, pending] of this.pending.entries()) { clearTimeout(pending.timeoutId); this.pending.delete(requestId); pending.reject(new Error(errorMessage)); } } private dispatchEvent(message: DesktopTransportEvent) { const handlers = this.handlers.get(message.event.name); if (!handlers || handlers.size === 0) { return; } for (const handler of handlers) { handler(message.event.payload); } } private handleMessage(raw: string) { let parsed: DesktopTransportMessage; try { parsed = JSON.parse(raw) as DesktopTransportMessage; } catch { return; } if (parsed.type === "event") { this.dispatchEvent(parsed); return; } const response = parsed as DesktopTransportResponse; const pending = this.pending.get(response.id); if (!pending) { return; } clearTimeout(pending.timeoutId); this.pending.delete(response.id); if (!response.ok) { pending.reject(new Error(response.error || "Desktop command failed")); return; } pending.resolve(response.result); } private scheduleReconnect() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } const attempt = Math.max(this.pending.size, 1); const delayMs = Math.min( RECONNECT_BASE_DELAY_MS * 2 ** Math.min(attempt, 4), RECONNECT_MAX_DELAY_MS, ); this.reconnectTimer = setTimeout(() => { void this.ensureConnected(true); }, delayMs); } private async ensureConnected(isReconnect = false): Promise { if ( this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING) ) { return; } if (this.connectPromise) { return this.connectPromise; } this.setTransportState( this.hasConnectedOnce || isReconnect ? "reconnecting" : "connecting", ); this.connectPromise = (async () => { const endpoint = await this.getBackendEndpoint(); await new Promise((resolve, reject) => { const socket = new WebSocket(endpoint); this.socket = socket; socket.onopen = () => { this.hasConnectedOnce = true; this.transportError = null; this.setTransportState("connected"); resolve(); }; socket.onmessage = (event) => { this.handleMessage(String(event.data)); }; socket.onerror = () => { // Wait for onclose to reject or reconnect. }; socket.onclose = () => { if (this.socket === socket) { this.socket = null; } if (this.transportState !== "connected") { reject( new Error(`Desktop backend transport unavailable at ${endpoint}`), ); return; } this.setTransportState("reconnecting"); this.rejectPending("Desktop backend transport closed"); this.scheduleReconnect(); }; }); })() .catch((error) => { const message = error instanceof Error ? error.message : String(error); this.transportError = message; if (!this.hasConnectedOnce) { this.setTransportState("unavailable"); } throw error; }) .finally(() => { this.connectPromise = null; }); return this.connectPromise; } async invoke(command: string, args?: Record): Promise { // Route native OS commands (directory picker, file opener) through Tauri // only when running inside the full Tauri app shell. In sidecar/web mode // these are handled by the sidecar over WebSocket. if (NATIVE_COMMANDS.has(command) && isTauriAvailable()) { return await tryTauriInvoke(command, args); } await this.ensureConnected(); const socket = this.socket; if (!socket || socket.readyState !== WebSocket.OPEN) { throw new Error("Desktop backend transport unavailable"); } const id = `desktop_${Date.now()}_${this.requestCounter++}`; const request: DesktopTransportRequest = { type: "command", id, command, args, }; return await new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { const pending = this.pending.get(id); if (!pending) { return; } this.pending.delete(id); pending.reject( new Error(`Desktop command timed out waiting for ${command}`), ); }, REQUEST_TIMEOUT_MS); this.pending.set(id, { resolve: (value) => resolve(value as T), reject, timeoutId, }); socket.send(JSON.stringify(request)); }); } subscribe(eventName: string, handler: EventHandler): () => void { void this.ensureConnected(true).catch(() => { // Keep UI functional enough to surface later retries. }); const handlers = this.handlers.get(eventName) ?? new Set(); handlers.add(handler); this.handlers.set(eventName, handlers); return () => { const current = this.handlers.get(eventName); if (!current) { return; } current.delete(handler); if (current.size === 0) { this.handlers.delete(eventName); } }; } subscribeTransportState(handler: TransportStateHandler): () => void { this.transportStateHandlers.add(handler); handler(this.transportState); void this.ensureConnected(true).catch(() => { // Ignore eager connect failures; commands will surface them. }); return () => { this.transportStateHandlers.delete(handler); }; } getTransportState(): DesktopTransportState { return this.transportState; } getTransportError(): string | null { return this.transportError; } } export const desktopClient = new DesktopClient();