import { BrowserView, Updater, Utils } from "electrobun/bun"; import type { AppRPC, EditorOpenContext } from "../shared/rpc"; import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types"; import type { PanelContextMenu } from "./context-menu"; import { readFileText, writeFileText, type EditManager } from "./edits"; import { listLocalDrives } from "./fs/local"; import type { SessionRegistry } from "./fs/registry"; import type { LocalDirWatcher } from "./fs/watcher"; import { SftpProvider } from "./fs/sftp"; import type { ConnectionStore } from "./store/connections"; import type { SettingsStore } from "./store/settings"; import { loadSshConfigHosts } from "./store/ssh-config"; import { importWinScpIni } from "./store/winscp-import"; 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; contextMenu: PanelContextMenu; watcher: LocalDirWatcher; /** Assigned after the rpc object exists (it needs rpc.send). */ events: { sessionClosed: (info: { sessionId: string; reason: string }) => void; connectionsChanged: () => void; openEditor: (ctx: EditorOpenContext) => void; settingsChanged: (settings: AppSettings) => void; openConnectionManager: () => void; showAbout: () => 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) }; } } /** Connection-store requests, shared by the main and manager window rpcs. */ function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) { return { getConnections: () => wrap(async () => ({ store: await store.load() })), upsertConnection: ({ connection }: { connection: Parameters[0] }) => wrap(async () => { const id = await store.upsertConnection(connection); onChanged(); return { id }; }), deleteConnection: ({ id }: { id: string }) => wrap(async () => { await store.deleteConnection(id); onChanged(); return {}; }), upsertStack: ({ stack }: { stack: Parameters[0] }) => wrap(async () => { const id = await store.upsertStack(stack); onChanged(); return { id }; }), deleteStack: ({ id }: { id: string }) => wrap(async () => { await store.deleteStack(id); onChanged(); return {}; }), importWinScp: () => wrap(async () => { const { homedir } = await import("node:os"); const picked = await Utils.openFileDialog({ startingFolder: homedir(), allowedFileTypes: "*", canChooseFiles: true, canChooseDirectory: false, allowsMultipleSelection: false, }); const file = picked.find((p) => p.trim().length > 0); // dialog cancelled — all-zero result, UI shows nothing if (!file) return { connections: 0, stacks: 0, skipped: 0 }; const text = await (await import("node:fs/promises")).readFile(file, "utf8"); const result = await importWinScpIni(text, store); onChanged(); return result; }), getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })), }; } const pickKeyFileHandler = () => wrap(async () => { const { homedir } = await import("node:os"); const { join } = await import("node:path"); const picked = await Utils.openFileDialog({ startingFolder: join(homedir(), ".ssh"), allowedFileTypes: "*", canChooseFiles: true, canChooseDirectory: false, allowsMultipleSelection: false, }); const path = picked.find((p) => p.trim().length > 0) ?? null; return { path }; }); export function createAppRPC({ registry, engine, store, settings, edits, contextMenu, watcher, 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) }; }), setWatchedDirs: ({ dirs }) => wrap(async () => { watcher.setWatched(dirs); return {}; }), 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 ---- ...connectionStoreHandlers(store, () => events.connectionsChanged()), // ---- settings ---- getSettings: () => wrap(async () => ({ settings: await settings.load() })), saveSettings: ({ settings: next }) => wrap(async () => { await settings.save(next); engine.setConcurrency(effectiveConcurrency(next)); events.settingsChanged(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 () => { // PORTABILITY: apps live in different places per OS const { homedir } = await import("node:os"); const [startingFolder, allowedFileTypes] = process.platform === "darwin" ? ["/Applications", "app"] : process.platform === "win32" ? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"] : [homedir(), "*"]; const picked = await Utils.openFileDialog({ startingFolder, allowedFileTypes, canChooseFiles: true, canChooseDirectory: false, allowsMultipleSelection: false, }); const path = picked.find((p) => p.trim().length > 0) ?? null; return { path }; }), pickKeyFile: pickKeyFileHandler, getEditLeftovers: () => wrap(async () => ({ names: await edits.listLeftovers() })), clearEditLeftovers: () => wrap(async () => { await edits.clearLeftovers(); return {}; }), // ---- context menu + local shell integration ---- showRowMenu: (params) => wrap(async () => { contextMenu.show(params); return {}; }), moveToTrash: ({ path }) => wrap(async () => { Utils.moveToTrash(path); return {}; }), showColumnsMenu: ({ visible }) => wrap(async () => { contextMenu.showColumns(visible); return {}; }), // ---- editor windows ---- openEditor: (ctx) => wrap(async () => { events.openEditor(ctx); return {}; }), // ---- connection-manager window ---- openConnectionManager: () => wrap(async () => { events.openConnectionManager(); return {}; }), showAbout: () => wrap(async () => { events.showAbout(); return {}; }), listDrives: () => wrap(async () => ({ drives: listLocalDrives() })), // ---- auto-updater ---- checkForUpdate: () => wrap(async () => { const r = await Updater.checkForUpdate(); return { updateAvailable: r.updateAvailable, version: r.version }; }), downloadUpdate: () => wrap(async () => { await Updater.downloadUpdate(); return {}; }), applyUpdate: () => wrap(async () => { await Updater.applyUpdate(); // relaunches + quits; may never resolve return {}; }), }, }, }); } export interface EditorRpcHooks { ctx: EditorOpenContext; /** Draft mirror tick: store dirty flag + content on the window record. */ onDraft: (state: { dirty: boolean; content: string }) => void; /** editorSave succeeded: clean the record, retitle, refresh main panels. */ onSaved: () => void; /** Webview resolved its dirty prompt: close without the native fallback. */ onCloseConfirmed: () => void; } /** * RPC for one editor window. Each window MUST get its own instance (binding a * BrowserView steals the rpc's transport), and the hooks close over that * window's record — no ids on the wire. */ export function createEditorRPC( deps: { registry: SessionRegistry; settings: SettingsStore }, hooks: EditorRpcHooks, ) { return BrowserView.defineRPC({ maxRequestTime: MAX_REQUEST_TIME_MS, handlers: { requests: { readFile: ({ sessionId, path }) => wrap(async () => ({ content: await readFileText(deps.registry.get(sessionId), path), })), getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })), editorSave: ({ content }) => wrap(async () => { const { sessionId, path } = hooks.ctx; await writeFileText(deps.registry.get(sessionId), path, content); hooks.onSaved(); return {}; }), editorClose: () => wrap(async () => { hooks.onCloseConfirmed(); return {}; }), }, messages: { editorState: (state) => hooks.onDraft(state), }, }, }); } export interface ManagerRpcHooks { /** Store changed: rebuild the app menu + tell the main window to reload. */ onChanged: () => void; /** Connect / open-stack execute in the MAIN window's workspace. */ onConnect: (id: string) => void; onOpenStack: (id: string) => void; onClose: () => void; } /** RPC for the single connection-manager window (per-window instance). */ export function createManagerRPC( deps: { store: ConnectionStore; settings: SettingsStore }, hooks: ManagerRpcHooks, ) { return BrowserView.defineRPC({ maxRequestTime: MAX_REQUEST_TIME_MS, handlers: { requests: { ...connectionStoreHandlers(deps.store, hooks.onChanged), pickKeyFile: pickKeyFileHandler, getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })), managerConnect: ({ id }) => wrap(async () => { hooks.onConnect(id); return {}; }), managerOpenStack: ({ id }) => wrap(async () => { hooks.onOpenStack(id); return {}; }), managerClose: () => wrap(async () => { hooks.onClose(); return {}; }), }, }, }); }