import { OrchestrationEvent, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, type NativeApi, ServerConfigUpdatedPayload, TerminalEvent, WS_CHANNELS, WS_METHODS, WsWelcomePayload, } from "@t3tools/contracts"; import { Cause, Schema } from "effect"; import { showContextMenuFallback } from "./contextMenuFallback"; import { WsTransport } from "./wsTransport"; let instance: { api: NativeApi; transport: WsTransport } | null = null; const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); let lastWelcome: WsWelcomePayload | null = null; let lastServerConfigUpdated: ServerConfigUpdatedPayload | null = null; const decodeAndWarnOnFailure = ( schema: Schema.Schema & { readonly DecodingServices: never }, raw: unknown, ): T | null => { const decoded = Schema.decodeUnknownExit(schema)(raw); if (decoded._tag === "Failure") { console.warn("Dropped inbound WebSocket push payload", { reason: "decode-failed", raw, issue: Cause.pretty(decoded.cause), }); return null; } return decoded.value; }; /** * Subscribe to the server welcome message. If a welcome was already received * before this call, the listener fires synchronously with the cached payload. * This avoids the race between WebSocket connect and React effect registration. */ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void { welcomeListeners.add(listener); // Replay cached welcome for late subscribers if (lastWelcome) { try { listener(lastWelcome); } catch { // Swallow listener errors } } return () => { welcomeListeners.delete(listener); }; } /** * Subscribe to server config update events. Replays the latest update for * late subscribers to avoid missing config validation feedback. */ export function onServerConfigUpdated( listener: (payload: ServerConfigUpdatedPayload) => void, ): () => void { serverConfigUpdatedListeners.add(listener); if (lastServerConfigUpdated) { try { listener(lastServerConfigUpdated); } catch { // Swallow listener errors } } return () => { serverConfigUpdatedListeners.delete(listener); }; } export function createWsNativeApi(): NativeApi { if (instance) return instance.api; const transport = new WsTransport(); // Listen for server welcome and forward to registered listeners. // Also cache it so late subscribers (React effects) get it immediately. transport.subscribe(WS_CHANNELS.serverWelcome, (data) => { const payload = decodeAndWarnOnFailure(WsWelcomePayload, data); if (!payload) return; lastWelcome = payload; for (const listener of welcomeListeners) { try { listener(payload); } catch { // Swallow listener errors } } }); transport.subscribe(WS_CHANNELS.serverConfigUpdated, (data) => { const payload = decodeAndWarnOnFailure(ServerConfigUpdatedPayload, data); if (!payload) return; lastServerConfigUpdated = payload; for (const listener of serverConfigUpdatedListeners) { try { listener(payload); } catch { // Swallow listener errors } } }); const api: NativeApi = { dialogs: { pickFolder: async () => { if (!window.desktopBridge) return null; return window.desktopBridge.pickFolder(); }, confirm: async (message) => { if (window.desktopBridge) { return window.desktopBridge.confirm(message); } return window.confirm(message); }, }, terminal: { open: (input) => transport.request(WS_METHODS.terminalOpen, input), write: (input) => transport.request(WS_METHODS.terminalWrite, input), resize: (input) => transport.request(WS_METHODS.terminalResize, input), clear: (input) => transport.request(WS_METHODS.terminalClear, input), restart: (input) => transport.request(WS_METHODS.terminalRestart, input), close: (input) => transport.request(WS_METHODS.terminalClose, input), onEvent: (callback) => transport.subscribe(WS_CHANNELS.terminalEvent, (data) => { const payload = decodeAndWarnOnFailure(TerminalEvent, data); if (payload) callback(payload); }), }, projects: { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), }, shell: { openInEditor: (cwd, editor) => transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), openExternal: async (url) => { if (window.desktopBridge) { const opened = await window.desktopBridge.openExternal(url); if (!opened) { throw new Error("Unable to open link."); } return; } // Some mobile browsers can return null here even when the tab opens. // Avoid false negatives and let the browser handle popup policy. window.open(url, "_blank", "noopener,noreferrer"); }, }, git: { pull: (input) => transport.request(WS_METHODS.gitPull, input), status: (input) => transport.request(WS_METHODS.gitStatus, input), runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), init: (input) => transport.request(WS_METHODS.gitInit, input), }, contextMenu: { show: async ( items: readonly { id: T; label: string }[], position?: { x: number; y: number }, ): Promise => { if (window.desktopBridge) { return window.desktopBridge.showContextMenu(items) as Promise; } return showContextMenuFallback(items, position); }, }, server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), }, orchestration: { getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), dispatchCommand: (command) => transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { command }), getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input), getFullThreadDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input), replayEvents: (fromSequenceExclusive) => transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), onDomainEvent: (callback) => transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (data) => { const payload = decodeAndWarnOnFailure(OrchestrationEvent, data); if (payload) callback(payload); }), }, }; instance = { api, transport }; return api; }