- SftpProvider (ssh2): password + key auth, keepalive, clean-dispose vs unexpected-close distinction, POSIX path helpers, s->ms attr normalization - ConnectionStore: plain JSON at appDataDir, 0600, atomic writes, folders - connect/disconnect + store CRUD handlers; sessionClosed push -> panels fall back to local and in-flight jobs fail - UI: session dropdown per panel (local/saved connections/manage), disconnect button, connection manager modal (folders, edit form, save/connect) - electrobun bundler: external cpu-features/*.node (ssh2 optional natives) - RpcService: gate requests on RPC socket open; defer initial panel loads past first render - verified against 192.168.10.122: full op matrix, upload/download/remote-remote recursive transfers, roundtrip integrity, recursive remote delete, store CRUD Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
68 lines
2.3 KiB
TypeScript
68 lines
2.3 KiB
TypeScript
import { Injectable, signal } 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;
|
|
|
|
readonly transferUpdate = signal<TransferSnapshot | null>(null);
|
|
readonly sessionClosed = signal<SessionClosedEvent | null>(null);
|
|
|
|
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) => this.transferUpdate.set(snapshot),
|
|
sessionClosed: (event) => this.sessionClosed.set(event),
|
|
},
|
|
},
|
|
});
|
|
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 });
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
/** 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);
|
|
},
|
|
});
|
|
}
|
|
}
|