import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/bun"; import { BRANDING } from "../shared/branding"; import type { EditorOpenContext } from "../shared/rpc"; import { effectiveConcurrency, type AppSettings } from "../shared/types"; import { EditManager } from "./edits"; import { EditorWindows } from "./editor-windows"; import { SessionRegistry } from "./fs/registry"; import { LocalDirWatcher } from "./fs/watcher"; import { ManagerWindow } from "./manager-window"; import { setLocale as setBackendLocale } from "./i18n"; import { applyApplicationMenu } from "./menu"; import { createAppRPC } from "./rpc-handlers"; import { ConnectionStore } from "./store/connections"; import { migrateLegacyDataDir } from "./store/paths"; import { SettingsStore } from "./store/settings"; import { AppTray } from "./tray"; import { TransferEngine } from "./transfers/engine"; import { buildPreload, chromeOptions, showWhenReady } from "./windows"; const PROD_URL = "views://app/index.html"; // In dev, scripts/dev.ts sets DEV_SERVER_URL to the running ng serve instance // so the UI gets HMR. Packaged builds always load the bundled Angular app. async function getMainViewUrl(): Promise { const devServerUrl = process.env.DEV_SERVER_URL; if (!devServerUrl) return PROD_URL; const channel = await Updater.localInfo.channel(); if (channel !== "dev") return PROD_URL; try { await fetch(devServerUrl, { method: "HEAD" }); return devServerUrl; } catch { console.log(`Dev server ${devServerUrl} not reachable, using ${PROD_URL}`); return PROD_URL; } } // after a rebrand, adopt the previous name's data dir before stores read it migrateLegacyDataDir(); const registry = new SessionRegistry(); const engine = new TransferEngine(registry); const store = new ConnectionStore(); const settings = new SettingsStore(); const edits = new EditManager(registry, engine, settings); const watcher = new LocalDirWatcher(); const events = { sessionClosed: (_info: { sessionId: string; reason: string }) => {}, connectionsChanged: () => {}, openEditor: (_ctx: EditorOpenContext) => {}, settingsChanged: (_settings: AppSettings) => {}, openConnectionManager: () => {}, showAbout: () => {}, }; const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events }); // late-bound like events: a watched local dir changed -> tell the main webview watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir }); const url = await getMainViewUrl(); // Loaded before any window exists: the persisted theme is baked into every // window's preload so the first paint is already themed (no flash). Kept // fresh via the saveSettings flow below. let currentSettings = await settings.load(); setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language const win = new BrowserWindow({ title: BRANDING.appName, url, frame: { width: 1200, height: 760, x: 120, y: 120, }, // frame decision lives in chromeOptions (windows.ts); constructor-only ...chromeOptions(), // lets the UI pick the fused layout + theme before first paint preload: buildPreload("main", currentSettings), rpc, // revealed on first dom-ready — avoids the unthemed native flash hidden: true, }); showWhenReady(win); const editors = new EditorWindows({ registry, settings, edits, url, currentSettings: () => currentSettings, mainFrame: () => win.getFrame(), notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) }, }); const manager = new ManagerWindow({ store, settings, url, currentSettings: () => currentSettings, mainFrame: () => win.getFrame(), notifyMain: { managerState: (info) => rpc.send.managerState(info), connectionsChanged: () => rpc.send.connectionsChanged({}), menuAction: (info) => rpc.send.menuAction(info), }, rebuildMenu: () => void applyApplicationMenu(store), }); events.openEditor = (ctx) => editors.open(ctx); events.settingsChanged = (s) => { const localeChanged = s.locale !== currentSettings.locale; currentSettings = s; // future windows' preloads bake the fresh theme if (localeChanged) { setBackendLocale(s.locale); void applyApplicationMenu(store); // relabel the native menu in the new language } editors.broadcastSettings(s); manager.broadcastSettings(s); }; events.openConnectionManager = () => manager.open(); function showAbout(): void { const { build } = BRANDING; void Utils.showMessageBox({ type: "info", title: BRANDING.appName, message: BRANDING.appName, detail: `${BRANDING.buildLabel} (${build.commit}) · ${build.date}\n\nElectrobun ${build.electrobun} · Bun ${Bun.version}`, buttons: ["OK"], defaultId: 0, cancelId: 0, }); } events.showAbout = showAbout; // clean editors close with the main window; dirty ones get to ask first, // and the modal manager has nothing to guard win.on("close", () => { manager.close(); editors.closeAllForAppExit(); }); new AppTray(win); // notify on finished transfers while the window is in the background let windowFocused = true; win.on("focus", () => { windowFocused = true; // modal bounce: the main window can't take focus while the manager is open if (manager.isOpen()) manager.activate(); }); win.on("blur", () => (windowFocused = false)); const notifiedStates = new Map(); engine.onUpdate = (snapshot) => { rpc.send.transferUpdate(snapshot); const previous = notifiedStates.get(snapshot.id); if (previous !== snapshot.state) { notifiedStates.set(snapshot.id, snapshot.state); if ( !windowFocused && !editors.anyFocused() && (snapshot.state === "completed" || snapshot.state === "failed") ) { Utils.showNotification({ title: snapshot.state === "completed" ? "Transfer complete" : "Transfer failed", body: snapshot.state === "completed" ? snapshot.name : `${snapshot.name}: ${snapshot.error ?? "unknown error"}`, silent: snapshot.state === "completed", }); } if (snapshot.state === "completed" || snapshot.state === "failed" || snapshot.state === "cancelled") { // keep the map from growing over long sessions setTimeout(() => notifiedStates.delete(snapshot.id), 60_000); } } }; // don't quit out from under running transfers or unsaved editors let quitConfirmed = false; Electrobun.events.on("before-quit", (event) => { const transfers = engine.activeJobCount(); const dirtyEditors = editors.dirtyCount(); if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return; (event as { response: { allow: boolean } }).response = { allow: false }; void (async () => { const parts = [ transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`, dirtyEditors > 0 && `${dirtyEditors} file${dirtyEditors === 1 ? " has" : "s have"} unsaved changes`, ].filter(Boolean); const { response } = await Utils.showMessageBox({ type: "warning", title: BRANDING.appName, message: `${parts.join(" and ")}.`, detail: "Quitting now will abort transfers and discard unsaved changes.", buttons: ["Quit Anyway", "Cancel"], defaultId: 1, cancelId: 1, }); if (response === 0) { quitConfirmed = true; Utils.quit(); } })(); }); events.sessionClosed = (info) => { rpc.send.sessionClosed(info); editors.notifySessionClosed(info); }; events.connectionsChanged = () => void applyApplicationMenu(store); engine.setConcurrency(effectiveConcurrency(currentSettings)); void applyApplicationMenu(store); ApplicationMenu.on("application-menu-clicked", (event) => { const action = (event as { data?: { action?: string } }).data?.action; if (!action) return; // ⌘W while an editor window is focused closes that editor (with its dirty // prompt), not a main-window tab. Other menu actions deliberately still // target the main window. if (action === "close-tab") { const focusedEditor = editors.focusedKey(); if (focusedEditor) { editors.requestClose(focusedEditor); return; } } if (action === "manage-connections") { manager.open(); return; } if (action === "about") { showAbout(); return; } if (action === "quit") { // Utils.quit() emits before-quit -> the guard runs (transfers/unsaved). // Handled before the manager check so ⌘Q quits even with it open. Utils.quit(); return; } // the manager is modal to the main window — don't drive main behind it if (manager.isOpen()) return; rpc.send.menuAction({ action }); }); // ---- auto-updater: check on launch + every 6h, stream status to the tab bar ---- // Updater.checkForUpdate() self-guards the dev channel (returns updateAvailable: // false), so the pill never appears during `bun run dev`. Updater.onStatusChange((entry) => { if (entry.status === "download-progress") { rpc.send.updateStatus({ phase: "downloading", progress: entry.details?.progress ?? 0 }); } else if (entry.status === "error") { rpc.send.updateStatus({ phase: "error", error: entry.message }); } }); async function runUpdateCheck(): Promise { try { const r = await Updater.checkForUpdate(); if (r.updateAvailable) rpc.send.updateStatus({ phase: "available", version: r.version }); } catch { // offline / feed unreachable — stay quiet and retry on the next tick } } setTimeout(() => void runUpdateCheck(), 5_000); // let the window settle first setInterval(() => void runUpdateCheck(), 6 * 60 * 60 * 1000); console.log(`${BRANDING.appName} started (${url})`);