main retired electrobun's native context menu (a documented no-op on Linux) in favour of an HTML one on every platform. Merging that here needs the menu to look right under the Win7 skin as well. Conflicts, all resolved by keeping both sides: - src/bun/index.ts — createAppRPC keeps the branch's mainWindow getter, drops main's removed contextMenu dep. - ui/src/app/app.ts — imports both TitleBar and ContextMenu. - ui/src/app/platform.ts — kept the branch's version (its IS_MAC_CHROME accounts for the frameless win7 chrome, and MOD_LABEL correctly keys on the real platform rather than the chrome), plus exporting IS_MAC and adding IS_LINUX for the menu's per-OS shell wording. Aero menu (branch-only): 7.css has no menu component, so it is hand-authored across both layers — the row <button>s shed their push-button slab in `skin` (revert-layer only reaches `utilities` from there), and the square-cornered slab, hairline border and gradient selection band are stated in `skinfix`. Built strictly from existing --w7-* vars, never literal hex, so the hand-written dark Win7 palette recolours it for free. Hover and the keyboard cursor share one band (the component marks the cursor with `bg-accent`, which is overridden alongside :hover) as a real Win7 menu does. Also answers a question the merge raised: frameless windows DO work on Linux. The GTK wrapper honours titleBarStyle "hidden" — it calls gtk_window_set_decorated(false) and logs "GTK: Created window without decorations (custom titlebar)". Verified on GNOME/Wayland and documented in architecture.md. Verified: backend `tsc --noEmit` clean, `bun run build:ui` clean (492.52 kB initial, still under the 500 kB budget), app launches with the skin active, frameless, no console errors and no GBM failures. Co-Authored-By: Claude <noreply@anthropic.com>
287 lines
9.6 KiB
TypeScript
287 lines
9.6 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 { 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, trackMaximizeState } 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 watcher = new LocalDirWatcher();
|
|
const events = {
|
|
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
|
connectionsChanged: () => {},
|
|
openEditor: (_ctx: EditorOpenContext) => {},
|
|
settingsChanged: (_settings: AppSettings) => {},
|
|
openConnectionManager: () => {},
|
|
showAbout: () => {},
|
|
};
|
|
// Late-bound for the same reason events is: `win` below takes this rpc in its
|
|
// constructor, so it cannot exist yet. Only the win7 title bar reads it.
|
|
let mainWin: BrowserWindow | undefined;
|
|
const rpc = createAppRPC({
|
|
registry,
|
|
engine,
|
|
store,
|
|
settings,
|
|
edits,
|
|
watcher,
|
|
mainWindow: () => mainWin,
|
|
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 (per skin) lives in chromeOptions — see windows.ts.
|
|
...chromeOptions(currentSettings.skin),
|
|
// 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);
|
|
mainWin = win;
|
|
trackMaximizeState(win, (maximized) => rpc.send.windowState({ maximized }));
|
|
|
|
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<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;
|
|
}
|
|
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<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})`);
|