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;}
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;
}