import { Electroview } from "electrobun/view"; import { type ApplicationMenuSelectMessage, type CapabilityEventMessage, type ContextMenuSelectMessage, type DesktopDeepLinkMessage, type DesktopBackendRequestArgs, type DesktopBackendRequestMethod, type DesktopBackendRequestPayload, type DesktopBackendRequestResponse, type DesktopDockPreviewMessage, type DesktopRestartMessage, type DesktopStateMessage, type DesktopThemePreviewMessage, type ElectrobunBackendInit, type HttpStreamChunkMessage, type RemoteControlRequestMessage, type ElectrobunDesktopRpcSchema, type UpdateProgressMessage, } from "../shared/protocol"; import { decodeRpcResponse, decodeRpcValue, encodeRpcValue } from "./rpc-codec"; import type { RemoteControlRequest, RemoteControlResponse } from "../../../remote/types"; type ContextMenuSelectListener = (message: ContextMenuSelectMessage) => void; type ApplicationMenuSelectListener = (message: ApplicationMenuSelectMessage) => void; type DesktopDeepLinkListener = (message: DesktopDeepLinkMessage) => void; type DesktopStateListener = (message: DesktopStateMessage) => void; type DesktopDockPreviewListener = (message: DesktopDockPreviewMessage) => void; type DesktopThemePreviewListener = (message: DesktopThemePreviewMessage) => void; type UpdateProgressListener = (message: UpdateProgressMessage) => void; type CapabilityEventListener = (message: CapabilityEventMessage) => void; type HttpStreamChunkListener = (message: HttpStreamChunkMessage) => void; type RemoteControlRequestHandler = (request: RemoteControlRequest) => Promise; let initSnapshot: ElectrobunBackendInit | null = null; let remoteControlRequestHandler: RemoteControlRequestHandler | null = null; const contextMenuSelectListeners = new Map>(); const applicationMenuSelectListeners = new Set(); const desktopDeepLinkListeners = new Set(); const pendingDesktopDeepLinks: DesktopDeepLinkMessage[] = []; const desktopStateListeners = new Set(); const desktopDockPreviewListeners = new Set(); const desktopThemePreviewListeners = new Set(); const updateProgressListeners = new Set(); const capabilityEventListeners = new Map>(); const httpStreamChunkListeners = new Map>(); function dispatch( listeners: Map void>>, key: string, value: T, ): void { for (const listener of listeners.get(key) ?? []) { listener(value); } } function subscribe( listeners: Map void>>, key: string, listener: (value: T) => void, ): () => void { if (!listeners.has(key)) { listeners.set(key, new Set()); } listeners.get(key)!.add(listener); return () => { const bucket = listeners.get(key); if (!bucket) return; bucket.delete(listener); if (bucket.size === 0) { listeners.delete(key); } }; } function dispatchDesktopDeepLink(message: DesktopDeepLinkMessage): void { if (desktopDeepLinkListeners.size === 0) { pendingDesktopDeepLinks.push(message); if (pendingDesktopDeepLinks.length > 20) pendingDesktopDeepLinks.shift(); return; } for (const listener of desktopDeepLinkListeners) { listener(message); } } const rpc = Electroview.defineRPC({ maxRequestTime: 120_000, handlers: { requests: { "remote.request": async (message: RemoteControlRequestMessage) => { if (!remoteControlRequestHandler) { return { ok: false, error: { code: "remote_unavailable", message: "The desktop app has not installed a remote control handler yet.", }, }; } const decoded = decodeRpcValue(message.request); return encodeRpcValue(await remoteControlRequestHandler(decoded)) as RemoteControlResponse; }, }, messages: { "context-menu.select": (message) => { dispatch(contextMenuSelectListeners, message.requestId, message); }, "application-menu.select": (message) => { const decoded = { command: decodeRpcValue(message.command) }; for (const listener of applicationMenuSelectListeners) { listener(decoded); } }, "desktop.deepLink": (message) => { if (typeof message.url !== "string" || !message.url) return; dispatchDesktopDeepLink({ url: message.url }); }, "desktop.state": (message) => { for (const listener of desktopStateListeners) { listener({ snapshot: decodeRpcValue(message.snapshot) }); } }, "desktop.dockPreview": (message) => { for (const listener of desktopDockPreviewListeners) { listener({ preview: decodeRpcValue(message.preview) }); } }, "desktop.themePreview": (message) => { for (const listener of desktopThemePreviewListeners) { listener({ preview: decodeRpcValue(message.preview) }); } }, "update.progress": (message) => { const decoded = { progress: decodeRpcValue(message.progress) }; for (const listener of updateProgressListeners) { listener(decoded); } }, "capability.event": (message) => { dispatch(capabilityEventListeners, message.subscriptionId, { subscriptionId: message.subscriptionId, event: decodeRpcValue(message.event), }); }, "http.stream.chunk": (message) => { if (typeof message?.streamId !== "string") return; dispatch(httpStreamChunkListeners, message.streamId, message); }, }, }, }); const electroview = new Electroview({ rpc }); async function waitForBridgeReady(): Promise { const start = Date.now(); while (Date.now() - start < 5_000) { if (electroview.bunSocket?.readyState === WebSocket.OPEN) { return; } await new Promise((resolve) => setTimeout(resolve, 25)); } throw new Error("Electrobun RPC socket did not open in time."); } export function backendRequest( method: "capability.invoke", payload: DesktopBackendRequestPayload<"capability.invoke">, ): Promise; export function backendRequest>( method: K, ...args: DesktopBackendRequestArgs ): Promise>; export async function backendRequest( method: DesktopBackendRequestMethod, payload: unknown = null, ): Promise { await waitForBridgeReady(); const result = await rpc.request["backend.request"]({ method, payload: encodeRpcValue(payload), }); return decodeRpcResponse(result); } export async function initElectrobunBackend(payload?: { kind?: "main" | "detached"; paneId?: string }): Promise { initSnapshot = await backendRequest("init", payload ?? {}); return initSnapshot; } export function requestElectrobunRestart(message: DesktopRestartMessage = {}): void { rpc.send["host.restart"](message); } export function getElectrobunBackendInitSnapshot(): ElectrobunBackendInit | null { return initSnapshot; } /** * The registry reads capability manifests from the init snapshot, which is * what the Bun process had at launch. A plugin activated there afterwards * hands back the new set, and this is how the view adopts it. */ export function replaceElectrobunCapabilityManifests(manifests: ElectrobunBackendInit["capabilityManifests"]): void { if (!initSnapshot) return; initSnapshot = { ...initSnapshot, capabilityManifests: manifests }; } export function setElectrobunRemoteRequestHandler(handler: RemoteControlRequestHandler | null): () => void { remoteControlRequestHandler = handler; return () => { if (remoteControlRequestHandler === handler) { remoteControlRequestHandler = null; } }; } export function onCapabilityEvent( subscriptionId: string, listener: (message: CapabilityEventMessage) => void, ): () => void { return subscribe(capabilityEventListeners, subscriptionId, listener); } /** * Body slices of one proxied streaming response. Subscribe before opening the * stream: the Bun process starts forwarding as soon as `http.stream.open` * returns its head, and a late listener would miss the first tokens. */ export function onHttpStreamChunk( streamId: string, listener: HttpStreamChunkListener, ): () => void { return subscribe(httpStreamChunkListeners, streamId, listener); } export function onContextMenuSelect( requestId: string, listener: (message: ContextMenuSelectMessage) => void, ): () => void { return subscribe(contextMenuSelectListeners, requestId, listener); } export function onApplicationMenuSelect(listener: ApplicationMenuSelectListener): () => void { applicationMenuSelectListeners.add(listener); return () => { applicationMenuSelectListeners.delete(listener); }; } export function onDesktopDeepLink(listener: DesktopDeepLinkListener): () => void { desktopDeepLinkListeners.add(listener); for (const message of pendingDesktopDeepLinks.splice(0)) { listener(message); } return () => { desktopDeepLinkListeners.delete(listener); }; } export function onDesktopState(listener: DesktopStateListener): () => void { desktopStateListeners.add(listener); return () => { desktopStateListeners.delete(listener); }; } export function onDesktopDockPreview(listener: DesktopDockPreviewListener): () => void { desktopDockPreviewListeners.add(listener); return () => { desktopDockPreviewListeners.delete(listener); }; } export function onDesktopThemePreview(listener: DesktopThemePreviewListener): () => void { desktopThemePreviewListeners.add(listener); return () => { desktopThemePreviewListeners.delete(listener); }; } export function onUpdateProgress(listener: UpdateProgressListener): () => void { updateProgressListeners.add(listener); return () => { updateProgressListeners.delete(listener); }; }