Electrobun 2.x replaces the vendored CLI with Hutch: the npm package is now a
bootstrap whose exports are a throwing stub, and the SDK is projected into
.hutch/devkit. The app stays on the Bun main process — `mainProcess: "bun"` is
load-bearing, since the 2.x default (Cottontail) ignores build.bun and with it
the ssh2 externals — and the bundle now ships the pinned Bun 1.4.0.
- electrobun 2.0.2 as an exact devDependency. `electrobun prepare` takes the
old CLI re-sign step's slot in postinstall and every dev/build/start/release
script: tsc (root tsconfig extends the devkit's) and the Angular build
(electrobun/view mapped into the devkit) resolve the SDK there.
- Imports move to electrobun/main. The CLI re-sign script and the v1 "three"
type shim are gone (Hutch's binaries are validly signed).
- UI: Electroview's bunSocket is hostSocket; noImplicitReturns is off because
the devkit's raw-TS browser SDK compiles inside the UI program.
- Updater.applyUpdate() now consults before-quit, so the transfers/unsaved
guard's "Quit Anyway" re-runs the apply instead of quitting without it.
- 2.x applies trafficLightOffset from macOS's default button position (1.18.1
clamped it inside the titlebar); {12, 4} centres the lights in the header.
- Releases: mac-x64 dropped (2.x publishes no macOS x64 core, so no Rosetta
path). artifacts/ is emptied before each build and published verbatim.
Installers lost the stable- prefix (the update feed kept it), so release.ts,
deploy.sh and the site links follow. Linux ships Electrobun's Setup
installer instead of the portable tarball and its miniSCP.sh wrapper.
- Docs cover all of the above, plus re-checks against 2.0.2: still no native
modal windows; window close is now cancellable through will-close.
Verified: tsc, build:ui, a stable mac-arm64 release (dmg + the v1-compatible
stable-macos-arm64 feed) and dev launches (RPC, Settings window, traffic
lights). Windows/Linux builds and the v1 -> 2.x Windows update bridge still
need their first run on native machines.
Co-Authored-By: Claude <noreply@anthropic.com>
490 lines
14 KiB
TypeScript
490 lines
14 KiB
TypeScript
import { BrowserView, Updater, Utils } from "electrobun/main";
|
|
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
|
import type { AppSettings, Result } from "../shared/types";
|
|
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;
|
|
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;
|
|
openSettings: () => void;
|
|
showAbout: () => void;
|
|
/** "Restart to apply" — lives next to the before-quit guard it must get past. */
|
|
applyUpdate: () => Promise<void>;
|
|
};
|
|
}
|
|
|
|
async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<T>> {
|
|
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<ConnectionStore["upsertConnection"]>[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<ConnectionStore["upsertStack"]>[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() })),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* saveSettings for every window kind: persist, then hand the new object to the
|
|
* composition root (index.ts), which applies the side effects once and
|
|
* rebroadcasts to every OTHER window. Known ceiling: whole-object writes — two
|
|
* windows saving in the same instant, the later one wins. Upgrade path: send a
|
|
* patch and merge it bun-side.
|
|
*/
|
|
const saveSettingsHandler =
|
|
(store: SettingsStore, onSaved: (next: AppSettings) => void) =>
|
|
({ settings: next }: { settings: AppSettings }) =>
|
|
wrap(async () => {
|
|
await store.save(next);
|
|
onSaved(next);
|
|
return {};
|
|
});
|
|
|
|
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 };
|
|
});
|
|
|
|
/** External-editor picker, shared by the main and settings window rpcs. */
|
|
const pickEditorAppHandler = () =>
|
|
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 };
|
|
});
|
|
|
|
export function createAppRPC({
|
|
registry,
|
|
engine,
|
|
store,
|
|
settings,
|
|
edits,
|
|
watcher,
|
|
events,
|
|
}: AppDeps) {
|
|
return BrowserView.defineRPC<AppRPC>({
|
|
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: saveSettingsHandler(settings, (next) => events.settingsChanged(next)),
|
|
|
|
// ---- 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: pickEditorAppHandler,
|
|
|
|
pickKeyFile: pickKeyFileHandler,
|
|
|
|
getEditLeftovers: () =>
|
|
wrap(async () => ({ names: await edits.listLeftovers() })),
|
|
|
|
clearEditLeftovers: () =>
|
|
wrap(async () => {
|
|
await edits.clearLeftovers();
|
|
return {};
|
|
}),
|
|
|
|
// ---- local shell integration (menu itself is HTML, see ui/) ----
|
|
shellItemAction: ({ action, path }) =>
|
|
wrap(async () => {
|
|
if (action === "reveal") Utils.showItemInFolder(path);
|
|
else if (action === "open") Utils.openPath(path);
|
|
else Utils.clipboardWriteText(path);
|
|
return {};
|
|
}),
|
|
|
|
moveToTrash: ({ path }) =>
|
|
wrap(async () => {
|
|
Utils.moveToTrash(path);
|
|
return {};
|
|
}),
|
|
|
|
// ---- editor windows ----
|
|
openEditor: (ctx) =>
|
|
wrap(async () => {
|
|
events.openEditor(ctx);
|
|
return {};
|
|
}),
|
|
|
|
// ---- connection-manager window ----
|
|
openConnectionManager: () =>
|
|
wrap(async () => {
|
|
events.openConnectionManager();
|
|
return {};
|
|
}),
|
|
|
|
// ---- settings window ----
|
|
openSettings: () =>
|
|
wrap(async () => {
|
|
events.openSettings();
|
|
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 events.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;
|
|
/** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
|
|
onSettingsSaved: (next: AppSettings) => 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<AppRPC>({
|
|
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() })),
|
|
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
|
|
|
|
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;
|
|
/** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
|
|
onSettingsSaved: (next: AppSettings) => void;
|
|
}
|
|
|
|
/** RPC for the single connection-manager window (per-window instance). */
|
|
export function createManagerRPC(
|
|
deps: { store: ConnectionStore; settings: SettingsStore },
|
|
hooks: ManagerRpcHooks,
|
|
) {
|
|
return BrowserView.defineRPC<AppRPC>({
|
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
|
handlers: {
|
|
requests: {
|
|
...connectionStoreHandlers(deps.store, hooks.onChanged),
|
|
pickKeyFile: pickKeyFileHandler,
|
|
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
|
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
|
|
|
|
managerConnect: ({ id }) =>
|
|
wrap(async () => {
|
|
hooks.onConnect(id);
|
|
return {};
|
|
}),
|
|
|
|
managerOpenStack: ({ id }) =>
|
|
wrap(async () => {
|
|
hooks.onOpenStack(id);
|
|
return {};
|
|
}),
|
|
|
|
managerClose: () =>
|
|
wrap(async () => {
|
|
hooks.onClose();
|
|
return {};
|
|
}),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export interface SettingsRpcHooks {
|
|
/** Every edit here is a settings save — rebroadcast it to the other windows. */
|
|
onSettingsSaved: (next: AppSettings) => void;
|
|
showAbout: () => void;
|
|
}
|
|
|
|
/** RPC for the single Settings window (per-window instance). */
|
|
export function createSettingsRPC(deps: { settings: SettingsStore }, hooks: SettingsRpcHooks) {
|
|
return BrowserView.defineRPC<AppRPC>({
|
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
|
handlers: {
|
|
requests: {
|
|
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
|
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
|
|
pickEditorApp: pickEditorAppHandler,
|
|
showAbout: () =>
|
|
wrap(async () => {
|
|
hooks.showAbout();
|
|
return {};
|
|
}),
|
|
},
|
|
},
|
|
});
|
|
}
|