import { Injectable } from '@angular/core'; import { Electroview } from 'electrobun/view'; import type { AppRPC } from '@shared/rpc'; import type { AppSettings, TransferSnapshot } from '@shared/types'; // Matches MAX_REQUEST_TIME_MS on the bun side — SFTP ops can be slow. const MAX_REQUEST_TIME_MS = 10 * 60_000; export type SessionClosedEvent = { sessionId: string; reason: string }; @Injectable({ providedIn: 'root' }) export class RpcService { /** False when the page is opened in a plain browser instead of the app. */ readonly available: boolean; // Push messages are delivered through plain callbacks, NOT signals: a signal // consumed from an effect that also writes signals re-triggers itself // (this froze the app), and signal coalescing would drop progress snapshots. private transferListeners = new Set<(s: TransferSnapshot) => void>(); private closedListeners = new Set<(e: SessionClosedEvent) => void>(); private menuListeners = new Set<(action: string) => void>(); private settingsListeners = new Set<(settings: AppSettings) => void>(); private pathRefreshListeners = new Set<(e: { sessionId: string; dir: string }) => void>(); private editorCloseListeners = new Set<() => void>(); private rpc: ReturnType> | undefined; private ready: Promise = Promise.resolve(); constructor() { this.available = typeof window !== 'undefined' && '__electrobun' in window; if (!this.available) return; this.rpc = Electroview.defineRPC({ maxRequestTime: MAX_REQUEST_TIME_MS, handlers: { messages: { transferUpdate: (snapshot) => { for (const listener of this.transferListeners) listener(snapshot); }, sessionClosed: (event) => { for (const listener of this.closedListeners) listener(event); }, menuAction: ({ action }) => { for (const listener of this.menuListeners) listener(action); }, settingsChanged: ({ settings }) => { for (const listener of this.settingsListeners) listener(settings); }, pathRefresh: (event) => { for (const listener of this.pathRefreshListeners) listener(event); }, editorCloseRequest: () => { for (const listener of this.editorCloseListeners) listener(); }, }, }, }); const electroview = new Electroview({ rpc: this.rpc }); // Requests fired before the RPC WebSocket finishes connecting are lost, so // everything below gates on it being open (services fire calls at startup). const socket = electroview.bunSocket; if (socket && socket.readyState !== WebSocket.OPEN) { this.ready = new Promise((resolve) => { socket.addEventListener('open', () => resolve(), { once: true }); socket.addEventListener('error', () => resolve(), { once: true }); }); } } onTransferUpdate(listener: (snapshot: TransferSnapshot) => void): void { this.transferListeners.add(listener); } onSessionClosed(listener: (event: SessionClosedEvent) => void): void { this.closedListeners.add(listener); } onMenuAction(listener: (action: string) => void): void { this.menuListeners.add(listener); } onSettingsChanged(listener: (settings: AppSettings) => void): void { this.settingsListeners.add(listener); } onPathRefresh(listener: (event: { sessionId: string; dir: string }) => void): void { this.pathRefreshListeners.add(listener); } onEditorCloseRequest(listener: () => void): void { this.editorCloseListeners.add(listener); } /** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */ get request() { const rpc = this.rpc; if (!rpc) throw new Error('RPC is only available inside the desktop app'); const ready = this.ready; return new Proxy({} as (typeof rpc)['request'], { get: (_target, method: string) => async (params?: unknown) => { await ready; const methods = rpc.request as unknown as Record< string, (p?: unknown) => Promise >; return methods[method](params); }, }); } /** Fire-and-forget message proxy: `rpc.send.editorState({...})`. Waits for the bridge. */ get send() { const rpc = this.rpc; if (!rpc) throw new Error('RPC is only available inside the desktop app'); const ready = this.ready; return new Proxy({} as (typeof rpc)['send'], { get: (_target, method: string) => (payload?: unknown) => { void ready.then(() => { const methods = rpc.send as unknown as Record void>; methods[method](payload); }); }, }); } }