Real BrowserWindow per edited file with its own RPC instance, deduped by sessionId+path. The same Angular bundle boots an editor-only root via preload globals. Native close isn't cancellable in electrobun, so the editor mirrors its draft to bun; traffic-light closes get a native Save/Discard prompt after the fact, and failed saves park the draft in the edit-leftovers dir. In-window closes (Cmd+W, Close) use a styled prompt; the quit guard now also counts unsaved editors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
131 lines
4.7 KiB
TypeScript
131 lines
4.7 KiB
TypeScript
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<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
|
private ready: Promise<void> = Promise.resolve();
|
|
|
|
constructor() {
|
|
this.available = typeof window !== 'undefined' && '__electrobun' in window;
|
|
if (!this.available) return;
|
|
|
|
this.rpc = Electroview.defineRPC<AppRPC>({
|
|
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<unknown>
|
|
>;
|
|
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<string, (p?: unknown) => void>;
|
|
methods[method](payload);
|
|
});
|
|
},
|
|
});
|
|
}
|
|
}
|