Wires electrobun's Updater to the /dl download host. The packaged app checks https://miniscp.com/dl on launch + every 6h; when a newer build exists, an accent-colored "update tool" appears next to Connections in the tab bar. It announces the update, becomes its own inline progress bar while downloading, then flips to "Restart to apply" (two-step: download, then user-triggered relaunch). - electrobun.config.ts: release.baseUrl = https://miniscp.com/dl (baked into version.json at build; miniscp.com fronted by an external proxy for TLS). - rpc.ts: checkForUpdate/downloadUpdate/applyUpdate requests + updateStatus push. - rpc-handlers.ts + index.ts: call Updater; poll on launch+6h; stream onStatusChange progress. checkForUpdate self-guards the dev channel. - rpc.service.ts + new updater.service.ts: signal-based state (available -> downloading -> ready, ready derived from downloadUpdate ok). - tab-bar.ts: single bg-primary pill; progress fills the pill itself. - icons.ts: register mynaDownload. i18n: tabBar + menu keys across all locales. - menu.ts + app.ts: "Check for Updates…" app-menu item -> UpdaterService.check(). - docs: build-and-release + architecture updated. Unsigned builds still update (applyUpdate strips the macOS quarantine xattr). Verified: tsc --noEmit (exit 0) + build:ui AOT (exit 0). Live pill + full download/apply flow require a two-build test on the host. Co-Authored-By: Claude <noreply@anthropic.com>
276 lines
9.4 KiB
TypeScript
276 lines
9.4 KiB
TypeScript
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 { PanelContextMenu } from "./context-menu";
|
|
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, 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<string> {
|
|
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 contextMenu = new PanelContextMenu();
|
|
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, contextMenu, 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 isMac = process.platform === "darwin";
|
|
|
|
const win = new BrowserWindow({
|
|
title: BRANDING.appName,
|
|
url,
|
|
frame: {
|
|
width: 1200,
|
|
height: 760,
|
|
x: 120,
|
|
y: 120,
|
|
},
|
|
// macOS: fuse the tab bar with the titlebar — traffic lights overlay the
|
|
// UI's h-10 tab row (offset centers them vertically). Other platforms keep
|
|
// the native titlebar and the tab bar stays a separate row.
|
|
...(isMac
|
|
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
|
: {}),
|
|
// 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);
|
|
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
|
|
|
|
// 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<string, string>();
|
|
|
|
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;
|
|
}
|
|
// 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<void> {
|
|
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})`);
|