electron-updater in the shell (packaged builds only; dev never updates). Checks at startup + every 24h, downloads silently, and the renderer shows a Restart banner once the update is staged (it installs on quit either way). Manual "Check for Updates..." under the app menu answers via dialog. Every updater event routes through the shell log and is caught - the updater can never take the app down (verified live: a feed failure logs three lines and the app runs on). LH_UPDATE_FEED overrides the baked GitHub feed with a generic local server - the pre-release drill: serve latest-mac.yml + zip + blockmap from localhost and a real installed copy updates itself, no release, no public repo. The asar allow-list re-includes electron-updater + its 16-package runtime tree explicitly (gitignore last-match-wins; asar verified at 2.1 MB). The banner rides the preload bridge (onUpdateStatus/installUpdate) and renders nothing in the browser. window.lh typing centralized in sidebar.tsx's declaration.
530 lines
21 KiB
JavaScript
530 lines
21 KiB
JavaScript
/**
|
||
* LearnHarness — the Electron shell (wrapper milestone, 2026-09-17).
|
||
*
|
||
* What this process is and is not:
|
||
* IS: a thin launcher + security guard around the app's real server —
|
||
* `next build`'s standalone server.js (Next 16), spawned as a child
|
||
* on 127.0.0.1 with the BYOK auth file and the LH_DATA_DIR seam.
|
||
* NOT: a host for any app logic. Everything — teacher turns, extraction,
|
||
* the graph — runs in the Next server, unchanged.
|
||
*
|
||
* Pattern (fact-checked 2026-09-17 against current Electron 44 / Next 16
|
||
* docs — see .memory release-strategy + electron-wrapper notes):
|
||
* - spawn via process.execPath + ELECTRON_RUN_AS_NODE=1 (Electron's
|
||
* bundled Node 24; the runAsNode fuse must stay enabled);
|
||
* - the server dir ships UNPACKED via extraResources (asar archives are
|
||
* read-only and native .node/dylib can't dlopen from one);
|
||
* - all mutable state under userData (never inside the .app bundle —
|
||
* translocated apps are read-only);
|
||
* - keys ride the child's ENV, never argv; auth.json (BYOK) is a flat
|
||
* {VAR: value} JSON map, plaintext, mode 0600 — server-managed since
|
||
* the auth-keys wave (2026-09-19): the child reads/writes it DIRECTLY
|
||
* (LH_AUTH_FILE below) and reflects values into its own env. The old
|
||
* spawn-time env injection retired with that wave — a copy made here
|
||
* would shadow every settings-page key change until restart.
|
||
*/
|
||
|
||
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
|
||
const { spawn } = require("node:child_process");
|
||
const fs = require("node:fs");
|
||
const http = require("node:http");
|
||
const net = require("node:net");
|
||
const path = require("node:path");
|
||
|
||
// Mirrors SIDEBAR_AUTOCLOSE in components/sidebar.tsx — keep in sync (the
|
||
// renderer owns the number; this is the IPC widen target).
|
||
const SIDEBAR_AUTOCLOSE = 928;
|
||
|
||
const PRODUCT_NAME = "LearnHarness";
|
||
|
||
// APP NAME → userData (owner, 2026-10-10): the Application Support dir is
|
||
// exactly "LearnHarness" in dev AND packaged. Packaged Electron would
|
||
// otherwise derive it from package.json "name" (now lowercase
|
||
// "learnharness"), and productName metadata alone doesn't reach app.name.
|
||
// Must run before the LH_DEV_URL suffix below — dev becomes
|
||
// "LearnHarness-dev".
|
||
app.setName(PRODUCT_NAME);
|
||
const AUTH_FILE = () => path.join(app.getPath("userData"), "auth.json");
|
||
const DATA_DIR = () => path.join(app.getPath("userData"), "data");
|
||
|
||
/** The packaged standalone server (extraResources → Resources/server).
|
||
* In dev LH_DEV_URL short-circuits everything: no spawn, no packaging —
|
||
* the window points at the running `next dev` server (full HMR). */
|
||
function serverEntry() {
|
||
// extraResources ships the whole .next payload under server/standalone/
|
||
// (copied from .next with an explicit filter — see electron-builder.yml).
|
||
return path.join(process.resourcesPath, "server", "standalone", "server.js");
|
||
}
|
||
|
||
// ─── Single instance: two instances would fight over the data dir ─────────
|
||
// Dev shell (LH_DEV_URL) runs BESIDE the packaged app, not instead of it:
|
||
// its own userData so (a) the single-instance lock — checked below — doesn't
|
||
// silently kill it, and (b) it never reads/writes the real app's
|
||
// localStorage, auth.json, or logs. Must run before the lock check.
|
||
if (process.env.LH_DEV_URL) {
|
||
app.setPath("userData", `${app.getPath("userData")}-dev`);
|
||
}
|
||
|
||
if (!app.requestSingleInstanceLock()) {
|
||
app.quit();
|
||
} else {
|
||
app.on("second-instance", () => {
|
||
const win = BrowserWindow.getAllWindows()[0];
|
||
if (win) {
|
||
if (win.isMinimized()) win.restore();
|
||
win.focus();
|
||
}
|
||
});
|
||
main();
|
||
}
|
||
|
||
let serverProcess = null;
|
||
|
||
/** A free TCP port (listen(0) → the kernel assigns one). The close→spawn
|
||
* race is theoretical; localhost ports are rarely contended, and a failed
|
||
* spawn surfaces in the health check with the child's stderr in the log. */
|
||
function freePort() {
|
||
return new Promise((resolve, reject) => {
|
||
const srv = net.createServer();
|
||
srv.listen(0, "127.0.0.1", () => {
|
||
const { port } = srv.address();
|
||
srv.close(() => resolve(port));
|
||
});
|
||
srv.on("error", reject);
|
||
});
|
||
}
|
||
|
||
/** BYOK keys: auth.json in userData, flat {VAR: value} map, 0600 —
|
||
* SERVER-MANAGED since the auth-keys wave (2026-09-19). This shell no
|
||
* longer reads or injects it: the child gets LH_AUTH_FILE below and does
|
||
* its own read/reflect/write, so a key saved in the settings page takes
|
||
* effect on the next model call — no restart. Real environment variables
|
||
* still win over the file inside the child (its documented rule). */
|
||
|
||
function log(line) {
|
||
const stamp = new Date().toISOString();
|
||
console.log(`[lh ${stamp}] ${line}`);
|
||
// Belt and braces: also append to userData/logs so a misbehaving child
|
||
// can be diagnosed after the window is gone. Best effort only.
|
||
try {
|
||
const dir = path.join(app.getPath("userData"), "logs");
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.appendFileSync(path.join(dir, "main.log"), `[${stamp}] ${line}\n`);
|
||
} catch {
|
||
/* best effort */
|
||
}
|
||
}
|
||
|
||
function startServer(port) {
|
||
return new Promise((resolve, reject) => {
|
||
const child = spawn(
|
||
process.execPath,
|
||
[serverEntry()],
|
||
{
|
||
cwd: path.dirname(serverEntry()),
|
||
env: {
|
||
...process.env,
|
||
ELECTRON_RUN_AS_NODE: "1",
|
||
NODE_ENV: "production",
|
||
PORT: String(port),
|
||
HOSTNAME: "127.0.0.1", // the server defaults to 0.0.0.0 — never
|
||
LH_DATA_DIR: DATA_DIR(),
|
||
// The auth/keys store — the child reads AND writes this file itself
|
||
// (no env injection anymore: a spawn-time copy would shadow every
|
||
// settings-page key change until restart; env wins over file).
|
||
LH_AUTH_FILE: AUTH_FILE(),
|
||
},
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
},
|
||
);
|
||
serverProcess = child;
|
||
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
|
||
child.stdout.on("data", fwd);
|
||
child.stderr.on("data", fwd);
|
||
child.on("exit", (code, signal) => {
|
||
log(`server exited (code=${code} signal=${signal})`);
|
||
serverProcess = null;
|
||
});
|
||
resolve(child);
|
||
});
|
||
}
|
||
|
||
/** No "ready" event exists — poll until the server answers (docs-confirmed). */
|
||
function waitUntilUp(url, tries = 150) {
|
||
return new Promise((resolve, reject) => {
|
||
const attempt = (n) => {
|
||
const req = http.get(url, (res) => {
|
||
res.resume();
|
||
resolve(res.statusCode !== undefined);
|
||
});
|
||
req.on("error", () => {
|
||
if (n <= 0) reject(new Error(`server never answered on ${url}`));
|
||
else setTimeout(() => attempt(n - 1), 200);
|
||
});
|
||
};
|
||
attempt(tries);
|
||
});
|
||
}
|
||
|
||
function killServer() {
|
||
if (!serverProcess) return;
|
||
const child = serverProcess;
|
||
serverProcess = null;
|
||
child.kill("SIGTERM");
|
||
setTimeout(() => {
|
||
try {
|
||
child.kill("SIGKILL");
|
||
} catch {
|
||
/* already gone */
|
||
}
|
||
}, 3000);
|
||
}
|
||
|
||
/** Security posture (all defaults asserted explicitly; the guards are the
|
||
* checklist's own recommendations for localhost-app shells):
|
||
* - deny-by-default permission handler allowing only our loopback origin
|
||
* (groundwork for the mic/STT milestone — Electron auto-approves by
|
||
* default, we do not);
|
||
* - will-navigate pinned to the app origin; window.open always denied —
|
||
* external links go to the real browser via shell.openExternal. */
|
||
function createWindow(url) {
|
||
const win = new BrowserWindow({
|
||
width: 1440,
|
||
height: 900,
|
||
title: PRODUCT_NAME,
|
||
// Merged chrome (owner, 2026-09-17): the system title bar is gone;
|
||
// the app's own 60px header bands ARE the title bar. The traffic
|
||
// lights float over the sidebar's LH wordmark band, vertically
|
||
// Aligned to the PHASE PILL's drawn box, not the band's geometric
|
||
// center (owner ask, 2026-09-17): macOS centers each 12px circle at
|
||
// y+6.75, and the pill box center measures 29.25 in the running app
|
||
// (band 0–59.5, pill box 19–39.5) — y 22.5 lands the circles at
|
||
// 29.25, exactly on the pill. Wordmark gutter: components/sidebar.tsx.
|
||
// Dragging comes from the renderer's .lh-drag bands (app/globals.css).
|
||
titleBarStyle: "hiddenInset",
|
||
trafficLightPosition: { x: 16, y: 22.5 },
|
||
webPreferences: {
|
||
contextIsolation: true,
|
||
sandbox: true,
|
||
nodeIntegration: false,
|
||
preload: path.join(__dirname, "preload.cjs"),
|
||
},
|
||
// Live-resize + first-paint flash (owner, 2026-09-17): during a resize
|
||
// the native window surface changes size before Chromium's compositor
|
||
// has a frame at the new size, and the gap shows the window's NATIVE
|
||
// background — Electron's default is white, glaring on a dark app.
|
||
// The app background (#141414) fills the lag instead. `show: false` +
|
||
// ready-to-show is the same treatment for first paint: the window
|
||
// appears only once the page has actually rendered.
|
||
backgroundColor: "#141414",
|
||
show: false,
|
||
// Narrow-window behavior (owner, 2026-09-17): 640px is the floor —
|
||
// below it even a wrapped chrome row gets cramped and the chat header
|
||
// (lights gutter 92 + toggle + title) runs out of room. The sidebar
|
||
// auto-closes below 928 = 640 + 288 (sidebar) in the renderer
|
||
// (components/sidebar.tsx, SIDEBAR_AUTOCLOSE); its "lh:expand-window"
|
||
// IPC widens the window back to that threshold.
|
||
minWidth: 640,
|
||
minHeight: 600,
|
||
});
|
||
|
||
const allowedOrigin = () => new URL(url).origin;
|
||
// Deny-by-default; the ONLY grants are media (mic/STT groundwork) and
|
||
// clipboard-sanitized-write — the copy affordances in the transcript
|
||
// (md-blocks copy corners) call navigator.clipboard.writeText(), which
|
||
// Chromium only honors with that permission (chrome-melt tweaks wave,
|
||
// 2026-09-19; before this grant every copy silently rejected). Still
|
||
// loopback-origin only — Electron's default is auto-approve-everything,
|
||
// and we do not ship that.
|
||
// "local-fonts": the appearance section's reading-font picker (2026-09-26).
|
||
const allowedPermissions = new Set(["media", "clipboard-sanitized-write", "local-fonts"]);
|
||
const fromOwnOrigin = (origin, permission) =>
|
||
origin === allowedOrigin() && allowedPermissions.has(permission);
|
||
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback, details) => {
|
||
const requestOrigin = details.requestingUrl ? new URL(details.requestingUrl).origin : null;
|
||
callback(fromOwnOrigin(requestOrigin, permission));
|
||
});
|
||
session.defaultSession.setPermissionCheckHandler((_wc, permission, origin) => {
|
||
return fromOwnOrigin(origin, permission);
|
||
});
|
||
|
||
win.webContents.on("will-navigate", (event, target) => {
|
||
if (new URL(target).origin !== allowedOrigin()) {
|
||
event.preventDefault();
|
||
void shell.openExternal(target);
|
||
}
|
||
});
|
||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||
void shell.openExternal(target);
|
||
return { action: "deny" };
|
||
});
|
||
|
||
void win.loadURL(url);
|
||
// Zoom truth reaches the renderer for EVERY load (reload, nav): the
|
||
// renderer's --lh-zoom must never go stale. The fallback catcher below
|
||
// covers ⌘= / layout variants the menu's ⌘Plus may not claim — menu-
|
||
// claimed keys never reach before-input-event, so exactly one of the
|
||
// two applies any given zoom keypress.
|
||
win.webContents.on("did-finish-load", () => {
|
||
win.webContents.send("lh:zoom", win.webContents.getZoomFactor());
|
||
});
|
||
win.webContents.on("before-input-event", (event, input) => {
|
||
if (input.type !== "keyDown" || !(input.meta || input.control)) return;
|
||
if (input.key === "=" || input.key === "+") zoomStep(win, +1);
|
||
else if (input.key === "-") zoomStep(win, -1);
|
||
else if (input.key === "0") applyZoom(win, 1);
|
||
else return;
|
||
event.preventDefault();
|
||
});
|
||
|
||
win.once("ready-to-show", () => win.show());
|
||
return win;
|
||
}
|
||
|
||
/* ── Zoom (owner round 7, 2026-10-05): ⌘±/⌘0 as an OWNED ladder ──────────
|
||
* The stock zoomIn/zoomOut roles change zoomLevel silently — the renderer
|
||
* never learns the factor, and the native traffic lights (physical-fixed,
|
||
* hiddenInset) drift out of relation to the scaled chrome: the graph
|
||
* button rides away from them zoomed in, content slides under them
|
||
* zoomed out. Here zoom is explicit: a factor ladder, applied through
|
||
* setZoomFactor, and broadcast to the renderer ("lh:zoom" — it publishes
|
||
* --lh-zoom so gutter lengths can divide by it and stay PHYSICAL-
|
||
* constant). The traffic lights themselves stay at their construction
|
||
* position ({x:16, y:22.5} — Electron 44 has NO runtime setter, only
|
||
* the BrowserWindow option), which is HIG-correct anyway: native lights
|
||
* hug the window corner while the web chrome compensates around them.
|
||
* Vertical note: at large factors the taller bands' content centers
|
||
* drift below the fixed lights — accepted (the alternative, moving the
|
||
* lights, is impossible without rebuilding the window). */
|
||
const ZOOM_LADDER = [0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
|
||
|
||
/* ── Auto-update (Wave C, owner 2026-10-10: silent download + restart ─────
|
||
* banner; checks at startup + every 24h) ─────────────────────────────────
|
||
*
|
||
* Feed: GitHub Releases (electron-builder.yml publish → app-update.yml,
|
||
* baked + signed into the bundle). macOS needs the app signed (it is —
|
||
* Developer ID) and updates ride the zip target (Squirrel.Mac); blockmaps
|
||
* give differential downloads for free.
|
||
*
|
||
* Hard rules:
|
||
* - DEV NEVER UPDATES: !app.isPackaged skips the updater entirely — the
|
||
* dev world runs `next dev` and has no signed bundle.
|
||
* - THE UPDATER CAN NEVER TAKE THE APP DOWN: every event and check is
|
||
* caught and logged (feed down, disk full, bad signature — all just
|
||
* log lines; the app keeps working, update or no update).
|
||
* - LH_UPDATE_FEED (env): overrides the baked GitHub feed with a generic
|
||
* local server — the pre-release verification drill (serve
|
||
* latest-mac.yml + zip + blockmap from any localhost HTTP dir and
|
||
* watch a real installed copy find/download/restart). Test-only seam.
|
||
*
|
||
* Install path: the downloaded update applies on quit (autoInstallOnAppQuit
|
||
* — Squirrel.Mac stages natively and swaps on relaunch). The renderer
|
||
* banner asks for an immediate restart via "lh:update-install"; we kill
|
||
* the child server FIRST — deterministic ordering: the server lives
|
||
* inside the .app bundle Squirrel is about to replace (extraResources),
|
||
* and before-quit alone would race the swap. */
|
||
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||
let autoUpdater = null; // electron-updater's, packaged builds only
|
||
|
||
function broadcastUpdateStatus(status) {
|
||
for (const win of BrowserWindow.getAllWindows()) {
|
||
try {
|
||
win.webContents.send("lh:update-status", status);
|
||
} catch {
|
||
/* window mid-teardown — the next push retries */
|
||
}
|
||
}
|
||
}
|
||
|
||
function checkForUpdates({ manual = false } = {}) {
|
||
if (!autoUpdater) return;
|
||
autoUpdater
|
||
.checkForUpdates()
|
||
.then((result) => {
|
||
// The download itself is silent (autoDownload); the banner fires on
|
||
// update-downloaded. Manual checks additionally answer directly.
|
||
if (!manual) return;
|
||
const version = result?.updateInfo?.version;
|
||
if (!version) dialog.showMessageBox({ type: "info", message: "You're up to date.", detail: `LearnHarness ${app.getVersion()} is the latest version.` });
|
||
})
|
||
.catch((err) => {
|
||
log(`update check failed: ${err?.message ?? err}`);
|
||
if (manual) dialog.showMessageBox({ type: "warning", message: "Couldn't check for updates.", detail: String(err?.message ?? err) });
|
||
});
|
||
}
|
||
|
||
function installUpdateNow() {
|
||
// Kill the server BEFORE the quit-and-install (see block comment).
|
||
try {
|
||
killServer();
|
||
} catch {
|
||
/* already dead */
|
||
}
|
||
autoUpdater?.quitAndInstall();
|
||
}
|
||
|
||
function setupAutoUpdater() {
|
||
if (!app.isPackaged) {
|
||
log("auto-update: dev build — updater disabled");
|
||
return;
|
||
}
|
||
try {
|
||
autoUpdater = require("electron-updater").autoUpdater;
|
||
} catch (err) {
|
||
log(`auto-update: electron-updater unavailable (${err?.message ?? err}) — updates disabled`);
|
||
return;
|
||
}
|
||
const feed = process.env.LH_UPDATE_FEED;
|
||
if (feed) {
|
||
autoUpdater.setFeedURL({ provider: "generic", url: feed });
|
||
log(`auto-update: LH_UPDATE_FEED override → ${feed}`);
|
||
}
|
||
autoUpdater.autoDownload = true;
|
||
autoUpdater.autoInstallOnAppQuit = true;
|
||
// Route every updater log line through the shell's own log (userData/
|
||
// logs/main.log) — feed failures must be diagnosable after the fact.
|
||
autoUpdater.logger = {
|
||
info: (m) => log(`updater: ${m}`),
|
||
warn: (m) => log(`updater warn: ${m}`),
|
||
debug: (m) => {}, // too chatty for the persistent log
|
||
error: (m) => log(`updater error: ${m}`),
|
||
};
|
||
autoUpdater.on("update-downloaded", (info) => {
|
||
log(`update downloaded: ${info?.version} — banner shown`);
|
||
broadcastUpdateStatus({ status: "downloaded", version: info?.version ?? null });
|
||
});
|
||
autoUpdater.on("error", (err) => log(`updater error: ${err?.message ?? err}`));
|
||
}
|
||
|
||
function zoomStep(win, dir) {
|
||
const current = win.webContents.getZoomFactor();
|
||
const next =
|
||
dir > 0
|
||
? ZOOM_LADDER.find((f) => f > current + 0.001) ?? ZOOM_LADDER[ZOOM_LADDER.length - 1]
|
||
: [...ZOOM_LADDER].reverse().find((f) => f < current - 0.001) ?? ZOOM_LADDER[0];
|
||
applyZoom(win, next);
|
||
}
|
||
|
||
function applyZoom(win, factor) {
|
||
factor = Math.min(2, Math.max(0.75, factor)); // ladder clamps; ⌘0 = 1
|
||
win.webContents.setZoomFactor(factor);
|
||
win.webContents.send("lh:zoom", factor);
|
||
}
|
||
|
||
/** macOS needs an application menu with the Edit roles — without one,
|
||
* Cmd+C/V/A do nothing in the renderer. Everything else stays minimal. */
|
||
function installMenu() {
|
||
const template = [
|
||
{
|
||
label: app.name,
|
||
submenu: [
|
||
{ role: "about" },
|
||
// Manual check (Wave C): answers via native dialog — no renderer
|
||
// round-trip. The 24h automatic checks stay silent.
|
||
{ label: "Check for Updates…", click: () => checkForUpdates({ manual: true }) },
|
||
{ type: "separator" },
|
||
{ role: "hide" },
|
||
{ role: "hideOthers" },
|
||
{ type: "separator" },
|
||
{ role: "quit" },
|
||
],
|
||
},
|
||
{ label: "Edit", submenu: [{ role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "selectAll" }] },
|
||
{
|
||
label: "View",
|
||
submenu: [
|
||
{ role: "reload" },
|
||
{ role: "toggleDevTools" },
|
||
{ type: "separator" },
|
||
// OWNED zoom (see applyZoom): the roles change zoomLevel
|
||
// silently; these broadcast the factor and re-seat the
|
||
// traffic lights. The renderer's --lh-zoom depends on
|
||
// hearing every change.
|
||
{
|
||
label: "Actual Size",
|
||
accelerator: "CommandOrControl+0",
|
||
click: () => applyZoom(BrowserWindow.getAllWindows()[0], 1),
|
||
},
|
||
{
|
||
label: "Zoom In",
|
||
accelerator: "CommandOrControl+Plus",
|
||
click: () => zoomStep(BrowserWindow.getAllWindows()[0], +1),
|
||
},
|
||
{
|
||
label: "Zoom Out",
|
||
accelerator: "CommandOrControl+-",
|
||
click: () => zoomStep(BrowserWindow.getAllWindows()[0], -1),
|
||
},
|
||
{ type: "separator" },
|
||
{ role: "togglefullscreen" },
|
||
],
|
||
},
|
||
{ role: "windowMenu" },
|
||
];
|
||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||
}
|
||
|
||
async function main() {
|
||
app.on("window-all-closed", () => {
|
||
// macOS convention: the app stays resident, reactivated from the Dock.
|
||
if (process.platform !== "darwin") app.quit();
|
||
});
|
||
|
||
// The child must not outlive us — cover every quit path.
|
||
app.on("before-quit", killServer);
|
||
process.on("exit", killServer);
|
||
process.on("SIGHUP", killServer);
|
||
|
||
await app.whenReady();
|
||
|
||
// Dev mode: point at a running `next dev` server (LH_DEV_URL) — no
|
||
// spawn, no packaging, full HMR. The rest of this shell still applies
|
||
// (guards, menu, single instance).
|
||
const devUrl = process.env.LH_DEV_URL;
|
||
const url = devUrl ?? `http://127.0.0.1:${await freePort()}`;
|
||
if (!devUrl) {
|
||
log(`starting server: ${serverEntry()}`);
|
||
log(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
|
||
await startServer(new URL(url).port);
|
||
await waitUntilUp(url);
|
||
log(`server up: ${url}`);
|
||
} else {
|
||
log(`dev mode: ${devUrl}`);
|
||
}
|
||
|
||
installMenu();
|
||
|
||
// Auto-update (Wave C): wire the updater before the window so the
|
||
// banner channel is live from first paint; the first check and the 24h
|
||
// interval fire after it below. setupAutoUpdater's contract: failures
|
||
// are log lines — this can never block boot.
|
||
setupAutoUpdater();
|
||
ipcMain.on("lh:update-install", () => installUpdateNow());
|
||
|
||
// Sidebar-at-narrow-window (owner, 2026-09-17): the renderer auto-closes
|
||
// the sidebar below SIDEBAR_AUTOCLOSE (928px); opening it again at that
|
||
// width asks the window to widen instead — the layout never has to
|
||
// accommodate a narrow window + open sidebar. Top-left stays fixed, the
|
||
// window grows right. No-op when already wide.
|
||
// The renderer's first paint can race did-finish-load — it asks for
|
||
// the current factor itself (mirrors "lh:zoom" pushes).
|
||
ipcMain.handle("lh:zoom:get", (event) => BrowserWindow.fromWebContents(event.sender)?.webContents.getZoomFactor() ?? 1);
|
||
ipcMain.on("lh:expand-window", (event) => {
|
||
const win = BrowserWindow.fromWebContents(event.sender);
|
||
if (!win) return;
|
||
const { x, y, width, height } = win.getBounds();
|
||
const target = SIDEBAR_AUTOCLOSE;
|
||
if (width < target) win.setBounds({ x, y, width: target, height });
|
||
});
|
||
app.on("activate", () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) createWindow(url);
|
||
});
|
||
createWindow(url);
|
||
log(`window open: ${url}`);
|
||
// First update check AFTER the window exists (the downloaded-banner
|
||
// push needs a listener); dev builds no-op (no autoUpdater).
|
||
if (autoUpdater) {
|
||
checkForUpdates();
|
||
setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS);
|
||
}
|
||
} |