Electrobun 2.x replaces the vendored CLI with Hutch: the npm package is now a
bootstrap whose exports are a throwing stub, and the SDK is projected into
.hutch/devkit. The app stays on the Bun main process — `mainProcess: "bun"` is
load-bearing, since the 2.x default (Cottontail) ignores build.bun and with it
the ssh2 externals — and the bundle now ships the pinned Bun 1.4.0.
- electrobun 2.0.2 as an exact devDependency. `electrobun prepare` takes the
old CLI re-sign step's slot in postinstall and every dev/build/start/release
script: tsc (root tsconfig extends the devkit's) and the Angular build
(electrobun/view mapped into the devkit) resolve the SDK there.
- Imports move to electrobun/main. The CLI re-sign script and the v1 "three"
type shim are gone (Hutch's binaries are validly signed).
- UI: Electroview's bunSocket is hostSocket; noImplicitReturns is off because
the devkit's raw-TS browser SDK compiles inside the UI program.
- Updater.applyUpdate() now consults before-quit, so the transfers/unsaved
guard's "Quit Anyway" re-runs the apply instead of quitting without it.
- 2.x applies trafficLightOffset from macOS's default button position (1.18.1
clamped it inside the titlebar); {12, 4} centres the lights in the header.
- Releases: mac-x64 dropped (2.x publishes no macOS x64 core, so no Rosetta
path). artifacts/ is emptied before each build and published verbatim.
Installers lost the stable- prefix (the update feed kept it), so release.ts,
deploy.sh and the site links follow. Linux ships Electrobun's Setup
installer instead of the portable tarball and its miniSCP.sh wrapper.
- Docs cover all of the above, plus re-checks against 2.0.2: still no native
modal windows; window close is now cancellable through will-close.
Verified: tsc, build:ui, a stable mac-arm64 release (dmg + the v1-compatible
stable-macos-arm64 feed) and dev launches (RPC, Settings window, traffic
lights). Windows/Linux builds and the v1 -> 2.x Windows update bridge still
need their first run on native machines.
Co-Authored-By: Claude <noreply@anthropic.com>
179 lines
6.2 KiB
TypeScript
179 lines
6.2 KiB
TypeScript
import { BrowserWindow } from "electrobun/main";
|
|
import type { AppSettings } from "../shared/types";
|
|
|
|
/**
|
|
* The preload globals every window gets. Single composition point: the UI
|
|
* reads them in ui/src/app/core/platform.ts before first paint (window chrome and
|
|
* root-component selection both depend on them).
|
|
*
|
|
* The persisted theme attributes are baked in here — mirroring the
|
|
* delete/set logic of SettingsService.applyX — so the FIRST paint is already
|
|
* themed (the inline <style> in ui/src/index.html consumes them). Without
|
|
* this, forced themes only apply after the async getSettings RPC and new
|
|
* windows flash the wrong background.
|
|
*/
|
|
export function buildPreload(
|
|
mode: "main" | "editor" | "manager" | "settings",
|
|
settings: AppSettings,
|
|
ctx?: unknown,
|
|
): string {
|
|
let script =
|
|
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
|
`window.__APP_MODE=${JSON.stringify(mode)};`;
|
|
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
|
|
|
const dataset: Record<string, string> = {};
|
|
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
|
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
|
// interface scale: mirrors SettingsService.applyUiScale so the FIRST paint
|
|
// is already scaled — without this every window opens at 100% and snaps.
|
|
// Written as an inline style, not a data attribute: the scale is continuous.
|
|
if (settings.uiScale !== 1) {
|
|
script += `document.documentElement.style.fontSize=${JSON.stringify(`${16 * settings.uiScale}px`)};`;
|
|
}
|
|
for (const [key, value] of Object.entries(dataset)) {
|
|
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
|
|
}
|
|
return script;
|
|
}
|
|
|
|
/**
|
|
* Native chrome for every window — the ONE place the frame is decided; the main
|
|
* window (src/bun/index.ts) and openSecondaryWindow both spread this. macOS
|
|
* fuses the titlebar with the app's own header row (hiddenInset: the traffic
|
|
* lights overlay it, which is what IS_MAC_CHROME reserves pl-24 for); other
|
|
* platforms keep the native titlebar. Constructor-only: there is no API to
|
|
* re-decorate a live window.
|
|
*
|
|
* electrobun 2.x adds trafficLightOffset to macOS's default button position
|
|
* (9/9 in the 32pt macOS 26 titlebar) and grows the titlebar to fit — 1.18.1
|
|
* clamped the shift inside the stock titlebar instead. {12, 4} puts the 14pt
|
|
* buttons at left 21 / top 13: vertically centred in the 40px h-10 header.
|
|
*/
|
|
export function chromeOptions() {
|
|
return process.platform === "darwin"
|
|
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 4 } }
|
|
: {};
|
|
}
|
|
|
|
/**
|
|
* Windows are created hidden and revealed on the webview's first dom-ready —
|
|
* showing them earlier paints the native (white) surface before the themed
|
|
* page exists. The fallback timer guarantees a missed event can never leave
|
|
* a window invisible.
|
|
*/
|
|
export function showWhenReady(win: BrowserWindow): void {
|
|
let shown = false;
|
|
const show = () => {
|
|
if (shown) return;
|
|
shown = true;
|
|
win.show();
|
|
};
|
|
const fallback = setTimeout(show, 800);
|
|
win.webview.on("dom-ready", () => {
|
|
clearTimeout(fallback);
|
|
show();
|
|
});
|
|
}
|
|
|
|
export interface SecondaryWindowOptions {
|
|
title: string;
|
|
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
|
url: string;
|
|
mode: "editor" | "manager" | "settings";
|
|
/** Current persisted settings — baked into the preload for flash-free paint. */
|
|
settings: AppSettings;
|
|
ctx?: unknown;
|
|
frame: { x: number; y: number; width: number; height: number };
|
|
/** Per-window rpc instance. NEVER share one across windows — each
|
|
* BrowserView steals the rpc's transport when it binds. */
|
|
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
|
|
}
|
|
|
|
/** App-chromed secondary window; its frame comes from chromeOptions. */
|
|
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
|
const win = new BrowserWindow({
|
|
title: opts.title,
|
|
url: opts.url,
|
|
frame: opts.frame,
|
|
...chromeOptions(),
|
|
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
|
rpc: opts.rpc,
|
|
hidden: true,
|
|
});
|
|
showWhenReady(win);
|
|
return win;
|
|
}
|
|
|
|
/**
|
|
* Keyed registry of secondary windows: dedupe/activate, cascade placement off
|
|
* an anchor window, focus tracking, close cleanup. A future "window follows
|
|
* window" grouping step slots in here — the registry already owns the window
|
|
* handles and the anchor it would need (anchor.on('move') + setPosition).
|
|
*/
|
|
export class WindowRegistry<Rec extends { win: BrowserWindow }> {
|
|
private records = new Map<string, Rec>();
|
|
private focused = new Set<string>();
|
|
private opened = 0;
|
|
|
|
constructor(private anchorFrame: () => { x: number; y: number }) {}
|
|
|
|
get(key: string): Rec | undefined {
|
|
return this.records.get(key);
|
|
}
|
|
|
|
/** True (and focuses the window) when the key already has a live window. */
|
|
activateExisting(key: string): boolean {
|
|
const rec = this.records.get(key);
|
|
if (!rec) return false;
|
|
rec.win.activate();
|
|
return true;
|
|
}
|
|
|
|
set(key: string, rec: Rec): void {
|
|
this.records.set(key, rec);
|
|
}
|
|
|
|
/** Cascade off the anchor so stacked windows never hide each other fully. */
|
|
nextFrame(width: number, height: number): { x: number; y: number; width: number; height: number } {
|
|
const anchor = this.anchorFrame();
|
|
const step = (this.opened++ % 8) * 28;
|
|
return { x: anchor.x + 90 + step, y: anchor.y + 70 + step, width, height };
|
|
}
|
|
|
|
/**
|
|
* Wire close/focus/blur for a window. electrobun's emitter has no off(),
|
|
* so these closures capture only (this, key) — never the record — letting
|
|
* closed-window content be garbage collected after delete().
|
|
*/
|
|
track(key: string, win: BrowserWindow, onClose: (key: string) => void): void {
|
|
win.on("focus", () => this.focused.add(key));
|
|
win.on("blur", () => this.focused.delete(key));
|
|
win.on("close", () => {
|
|
this.focused.delete(key);
|
|
onClose(key);
|
|
});
|
|
}
|
|
|
|
delete(key: string): void {
|
|
this.records.delete(key);
|
|
}
|
|
|
|
anyFocused(): boolean {
|
|
return this.focused.size > 0;
|
|
}
|
|
|
|
focusedKey(): string | null {
|
|
for (const key of this.focused) return key;
|
|
return null;
|
|
}
|
|
|
|
forEach(fn: (rec: Rec, key: string) => void): void {
|
|
for (const [key, rec] of this.records) fn(rec, key);
|
|
}
|
|
|
|
size(): number {
|
|
return this.records.size;
|
|
}
|
|
}
|