import { BrowserView, Utils } from "electrobun/bun"; import type { AppRPC } from "../shared/rpc"; import { effectiveConcurrency, type Result } from "../shared/types"; import { readFileText, writeFileText, type EditManager } from "./edits"; import type { SessionRegistry } from "./fs/registry"; import { SftpProvider } from "./fs/sftp"; import type { ConnectionStore } from "./store/connections"; import type { SettingsStore } from "./store/settings"; import type { TransferEngine } from "./transfers/engine"; import { removeRecursive } from "./transfers/walk"; // SFTP ops can be slow (big listings, recursive deletes) — the 1s electrobun // default would cut them off. const MAX_REQUEST_TIME_MS = 10 * 60_000; export interface AppDeps { registry: SessionRegistry; engine: TransferEngine; store: ConnectionStore; settings: SettingsStore; edits: EditManager; /** Assigned after the rpc object exists (it needs rpc.send). */ events: { sessionClosed: (info: { sessionId: string; reason: string }) => void; connectionsChanged: () => void; }; } async function wrap(fn: () => Promise | T): Promise> { try { return { ok: true, ...(await fn()) }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } } export function createAppRPC({ registry, engine, store, settings, edits, events }: AppDeps) { return BrowserView.defineRPC({ maxRequestTime: MAX_REQUEST_TIME_MS, handlers: { requests: { // ---- sessions ---- connect: ({ config }) => wrap(async () => { let sessionId = ""; const provider = await SftpProvider.connect(config, (reason) => { if (!sessionId) return; registry.drop(sessionId); engine.failSession(sessionId, reason); edits.stopForSession(sessionId); events.sessionClosed({ sessionId, reason }); }); sessionId = registry.add(provider); const homeDir = await provider.homeDir(); console.log(`[rpc] connected ${sessionId} -> ${config.username}@${config.host}`); return { sessionId, homeDir, fsKey: provider.fsKey }; }), disconnect: ({ sessionId }) => wrap(async () => { edits.stopForSession(sessionId); await registry.remove(sessionId); console.log(`[rpc] disconnected ${sessionId}`); return {}; }), // ---- filesystem ---- listDir: ({ sessionId, path }) => wrap(async () => { const fs = registry.get(sessionId); const dir = fs.normalize(path ?? (await fs.homeDir())); return { path: dir, entries: await fs.list(dir) }; }), mkdir: ({ sessionId, path }) => wrap(async () => { await registry.get(sessionId).mkdir(path); return {}; }), createFile: ({ sessionId, path }) => wrap(async () => { await registry.get(sessionId).createEmptyFile(path); return {}; }), rename: ({ sessionId, oldPath, newPath }) => wrap(async () => { await registry.get(sessionId).rename(oldPath, newPath); return {}; }), deleteEntries: ({ sessionId, entries }) => wrap(async () => { const fs = registry.get(sessionId); for (const { path, type } of entries) { await removeRecursive(fs, path, type); } return {}; }), chmod: ({ sessionId, paths, mode }) => wrap(async () => { const fs = registry.get(sessionId); for (const path of paths) { await fs.chmod(path, mode); } return {}; }), // ---- transfers ---- enqueueTransfers: (params) => wrap(async () => ({ jobIds: engine.enqueue(params) })), cancelTransfer: ({ jobId }) => wrap(async () => { engine.cancel(jobId); return {}; }), dismissTransfer: ({ jobId }) => wrap(async () => { engine.dismiss(jobId); return {}; }), // ---- connection store ---- getConnections: () => wrap(async () => ({ store: await store.load() })), upsertConnection: ({ connection }) => wrap(async () => { const id = await store.upsertConnection(connection); events.connectionsChanged(); return { id }; }), deleteConnection: ({ id }) => wrap(async () => { await store.deleteConnection(id); events.connectionsChanged(); return {}; }), upsertFolder: ({ folder }) => wrap(async () => { const id = await store.upsertFolder(folder); events.connectionsChanged(); return { id }; }), deleteFolder: ({ id }) => wrap(async () => { await store.deleteFolder(id); events.connectionsChanged(); return {}; }), // ---- settings ---- getSettings: () => wrap(async () => ({ settings: await settings.load() })), saveSettings: ({ settings: next }) => wrap(async () => { await settings.save(next); engine.setConcurrency(effectiveConcurrency(next)); return {}; }), // ---- file editing ---- readFile: ({ sessionId, path }) => wrap(async () => ({ content: await readFileText(registry.get(sessionId), path), })), writeFile: ({ sessionId, path, content }) => wrap(async () => { await writeFileText(registry.get(sessionId), path, content); return {}; }), editExternal: ({ sessionId, path }) => wrap(() => edits.editExternal(sessionId, path)), pickEditorApp: () => wrap(async () => { const picked = await Utils.openFileDialog({ startingFolder: "/Applications", allowedFileTypes: "app", canChooseFiles: true, canChooseDirectory: false, allowsMultipleSelection: false, }); const path = picked.find((p) => p.trim().length > 0) ?? null; return { path }; }), getEditLeftovers: () => wrap(async () => ({ names: await edits.listLeftovers() })), clearEditLeftovers: () => wrap(async () => { await edits.clearLeftovers(); return {}; }), }, }, }); }