207 lines · 7.1 KB · TypeScript
RawDownload
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 = <T>(  schema: Schema.Schema<T> & { 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;        }         const popup = window.open(url, "_blank", "noopener,noreferrer");        if (!popup) {          throw new Error("Unable to open link. Allow popups and try again.");        }      },    },    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 <T extends string>(        items: readonly { id: T; label: string }[],        position?: { x: number; y: number },      ): Promise<T | null> => {        if (window.desktopBridge) {          return window.desktopBridge.showContextMenu(items) as Promise<T | null>;        }        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;}