Files
miniSCP/ui/src/app/rpc.service.ts
T
JokinandClaude Fable 5 42e5e5bb9a Tray icon with live transfer progress and tabs menu
- AppTray: template menu-bar icon (22pt, adapts to light/dark). While
  transfers run it swaps to pre-rendered frames — faded arrows + a fill bar
  showing aggregate doneBytes/totalBytes of the queue (nearest 5%, throttled
  500ms); back to the glyph when idle. No runtime image encoding.
- Menu: one entry per tab (checkmark on the active one) with live per-tab
  transfer count and percentage (jobs mapped via session ids), an overall
  progress row while transferring, Show <app>, Quit. Selecting a tab
  activates the window and switches to it.
- First use of webview->bun messages: PanelsService pushes workspacesSync
  (tab names, sessions, active id) via a new ready-gated send proxy; verified
  end-to-end in the running app.
- engine.aggregateProgress(): totals + per-session breakdown over non-terminal
  jobs (unit-verified idle/mid-transfer/idle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:41:51 +02:00

107 lines
3.8 KiB
TypeScript

import { Injectable } from '@angular/core';
import { Electroview } from 'electrobun/view';
import type { AppRPC } from '@shared/rpc';
import type { 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 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);
},
},
},
});
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);
}
/** 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 messages to the bun process (e.g. workspacesSync). */
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, message: string) =>
(payload?: unknown) => {
void ready.then(() => {
const messages = rpc.send as unknown as Record<string, (p?: unknown) => void>;
messages[message](payload);
});
},
});
}
}