Files
miniSCP/src/bun/index.ts
T
jgamboaandClaude 6bb4c3cd7d Merge main into feat/7css-skin + give the HTML context menu an Aero skin
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>
2026-08-15 14:59:02 +02:00

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})`);