import { type GitActionProgressEvent, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, type ContextMenuItem, type NativeApi, ServerConfigUpdatedPayload, WS_CHANNELS, WS_METHODS, type WsWelcomePayload,} from "@t3tools/contracts"; 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>();const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>(); /** * 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); const latestWelcome = instance?.transport.getLatestPush(WS_CHANNELS.serverWelcome)?.data ?? null; if (latestWelcome) { try { listener(latestWelcome); } 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); const latestConfig = instance?.transport.getLatestPush(WS_CHANNELS.serverConfigUpdated)?.data ?? null; if (latestConfig) { try { listener(latestConfig); } catch { // Swallow listener errors } } return () => { serverConfigUpdatedListeners.delete(listener); };} export function createWsNativeApi(): NativeApi { if (instance) return instance.api; const transport = new WsTransport(); transport.subscribe(WS_CHANNELS.serverWelcome, (message) => { const payload = message.data; for (const listener of welcomeListeners) { try { listener(payload); } catch { // Swallow listener errors } } }); transport.subscribe(WS_CHANNELS.serverConfigUpdated, (message) => { const payload = message.data; for (const listener of serverConfigUpdatedListeners) { try { listener(payload); } catch { // Swallow listener errors } } }); transport.subscribe(WS_CHANNELS.gitActionProgress, (message) => { const payload = message.data; for (const listener of gitActionProgressListeners) { 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, (message) => callback(message.data)), }, projects: { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, 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, { timeoutMs: null }), 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), resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input), preparePullRequestThread: (input) => transport.request(WS_METHODS.gitPreparePullRequestThread, input), onActionProgress: (callback) => { gitActionProgressListeners.add(callback); return () => { gitActionProgressListeners.delete(callback); }; }, }, contextMenu: { show: async <T extends string>( items: readonly ContextMenuItem<T>[], position?: { x: number; y: number }, ): Promise<T | null> => { if (window.desktopBridge) { return window.desktopBridge.showContextMenu(items, position) as Promise<T | null>; } return showContextMenuFallback(items, position); }, }, server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), getSettings: () => transport.request(WS_METHODS.serverGetSettings), updateSettings: (patch) => transport.request(WS_METHODS.serverUpdateSettings, { patch }), }, 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, (message) => callback(message.data), ), }, }; instance = { api, transport }; return api;}
import {
type GitActionProgressEvent,
ORCHESTRATION_WS_CHANNELS,
ORCHESTRATION_WS_METHODS,
type ContextMenuItem,
type NativeApi,
ServerConfigUpdatedPayload,
WS_CHANNELS,
WS_METHODS,
type WsWelcomePayload,
} from "@t3tools/contracts";
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>();
const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>();
/**
* 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);
const latestWelcome = instance?.transport.getLatestPush(WS_CHANNELS.serverWelcome)?.data ?? null;
if (latestWelcome) {
try {
listener(latestWelcome);
} 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);
const latestConfig =
instance?.transport.getLatestPush(WS_CHANNELS.serverConfigUpdated)?.data ?? null;
if (latestConfig) {
try {
listener(latestConfig);
} catch {
// Swallow listener errors
}
}
return () => {
serverConfigUpdatedListeners.delete(listener);
};
}
export function createWsNativeApi(): NativeApi {
if (instance) return instance.api;
const transport = new WsTransport();
transport.subscribe(WS_CHANNELS.serverWelcome, (message) => {
const payload = message.data;
for (const listener of welcomeListeners) {
try {
listener(payload);
} catch {
// Swallow listener errors
}
}
});
transport.subscribe(WS_CHANNELS.serverConfigUpdated, (message) => {
const payload = message.data;
for (const listener of serverConfigUpdatedListeners) {
try {
listener(payload);
} catch {
// Swallow listener errors
}
}
});
transport.subscribe(WS_CHANNELS.gitActionProgress, (message) => {
const payload = message.data;
for (const listener of gitActionProgressListeners) {
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, (message) => callback(message.data)),
},
projects: {
searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),
writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, 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, { timeoutMs: null }),
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),
resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input),
preparePullRequestThread: (input) =>
transport.request(WS_METHODS.gitPreparePullRequestThread, input),
onActionProgress: (callback) => {
gitActionProgressListeners.add(callback);
return () => {
gitActionProgressListeners.delete(callback);
};
},
},
contextMenu: {
show: async <T extends string>(
items: readonly ContextMenuItem<T>[],
position?: { x: number; y: number },
): Promise<T | null> => {
if (window.desktopBridge) {
return window.desktopBridge.showContextMenu(items, position) as Promise<T | null>;
}
return showContextMenuFallback(items, position);
},
},
server: {
getConfig: () => transport.request(WS_METHODS.serverGetConfig),
upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input),
getSettings: () => transport.request(WS_METHODS.serverGetSettings),
updateSettings: (patch) => transport.request(WS_METHODS.serverUpdateSettings, { patch }),
},
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, (message) =>
callback(message.data),
),
},
};
instance = { api, transport };
return api;
}