The dual-skin arrangement existed to let one build toggle between the modern
shadcn look and 7.css. Keeping both correct is what forced the cascade tricks
that fight 7.css, so the branch drops the toggle: this build IS the Windows 7
build.
- `skin` is gone from AppSettings/DEFAULT_SETTINGS, the settings store, the
SettingsService (signal, applySkin, persist payload), the options popover
control, and the i18n keys.
- buildPreload adds `.win7` unconditionally; `chromeOptions()` no longer takes a
skin and always returns frameless `titleBarStyle: "hidden"`.
- `IS_WIN7_CHROME` and `IS_MAC_CHROME` are deleted along with every
`isMacChrome` branch in the tab bar, editor window and connection manager.
Those reserved `pl-24` for macOS traffic lights, which frameless chrome
removes on every platform, so the branches were dead by construction.
- The title bar is no longer conditional — it is the window's only chrome.
- index.html keeps just the win7 anti-flash arm (four theme states).
Not done here, deliberately: stripping Tailwind's element styling. The
neutralizers in styles.css (notably `.win7 button { min-width: 0 }`) are still
load-bearing until each component's markup is converted to 7.css's real
contract, so they come out per component rather than in one sweep.
Verified: backend `tsc --noEmit` clean, `bun run build:ui` clean; bundle
490.50 kB, down from 492.52 kB.
Co-Authored-By: Claude <noreply@anthropic.com>
287 lines
9.5 KiB
TypeScript
287 lines
9.5 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 lives in chromeOptions — see windows.ts.
|
|
...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);
|
|
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})`);
|