2026-09-17 15:32:28 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
2026-09-19 20:54:16 +03:00
|
|
|
|
* on 127.0.0.1 with the BYOK auth file and the LH_DATA_DIR seam.
|
2026-09-17 15:32:28 +03:00
|
|
|
|
* 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
|
2026-09-19 20:54:16 +03:00
|
|
|
|
* {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.
|
2026-09-17 15:32:28 +03:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-10-10 14:11:19 +03:00
|
|
|
|
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
|
2026-09-17 15:32:28 +03:00
|
|
|
|
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");
|
|
|
|
|
|
|
2026-09-17 18:57:02 +03:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-09-17 15:32:28 +03:00
|
|
|
|
const PRODUCT_NAME = "LearnHarness";
|
2026-10-10 11:20:20 +03:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-09-17 15:32:28 +03:00
|
|
|
|
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 ─────────
|
2026-10-11 07:03:45 +03:00
|
|
|
|
// ─── Browser-service helper (Wave E stage 3, owner 2026-10-10) ────────
|
|
|
|
|
|
// The render engines for agent jobs: PDFs and SVGs rasterize natively
|
|
|
|
|
|
// (pdfjs+Skia, resvg — no browser); DEMOS execute JS and need one. The
|
|
|
|
|
|
// app's own Chromium serves that via this helper — the server spawns
|
|
|
|
|
|
// <this binary> --browser-service
|
|
|
|
|
|
// and connects over CDP (system Chrome stays the fallback). Handshake:
|
|
|
|
|
|
// the helper prints "BROWSER_SERVICE_CDP <port>" on stdout once the CDP
|
|
|
|
|
|
// endpoint listens. Lifetime: stdin stays open; parent death = EOF = quit.
|
|
|
|
|
|
// MUST run before the single-instance lock — a helper is a second
|
|
|
|
|
|
// instance of this very binary, and before the LH_DEV_URL redirect — the
|
|
|
|
|
|
// helper owns no user data at all.
|
|
|
|
|
|
if (process.argv.includes("--browser-service")) {
|
|
|
|
|
|
(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
app.dock?.hide();
|
|
|
|
|
|
const port = await freePort();
|
|
|
|
|
|
// Must land before app-ready for Chromium to pick it up.
|
|
|
|
|
|
app.commandLine.appendSwitch("remote-debugging-port", String(port));
|
|
|
|
|
|
await app.whenReady();
|
|
|
|
|
|
// A hidden window keeps the app lifecycle ordinary on every
|
|
|
|
|
|
// platform (no window-all-closed handler is installed here).
|
|
|
|
|
|
const win = new BrowserWindow({ show: false });
|
|
|
|
|
|
await win.loadURL("about:blank");
|
|
|
|
|
|
console.log(`BROWSER_SERVICE_CDP ${port}`);
|
|
|
|
|
|
// Parent-tied lifetime: the server holds our stdin open.
|
|
|
|
|
|
process.stdin.resume();
|
|
|
|
|
|
process.stdin.on("end", () => app.quit());
|
|
|
|
|
|
process.stdin.on("close", () => app.quit());
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(`browser-service failed: ${err?.stack ?? err}`);
|
|
|
|
|
|
app.exit(1);
|
2026-09-17 15:32:28 +03:00
|
|
|
|
}
|
2026-10-11 07:03:45 +03:00
|
|
|
|
})();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 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`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The whole normal boot — lock included — lives in this else: a helper is
|
|
|
|
|
|
// a SECOND instance of this very binary on purpose (the running app owns
|
|
|
|
|
|
// the lock), so it must never reach this check.
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-09-17 15:32:28 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let serverProcess = null;
|
|
|
|
|
|
|
2026-10-10 14:20:11 +03:00
|
|
|
|
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
|
|
|
|
|
|
// in an event handler): the window + child server are the product, and
|
|
|
|
|
|
// anything main-side is recoverable by logging it. Log-and-continue.
|
|
|
|
|
|
process.on("uncaughtException", (err) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
log(`uncaught: ${err?.stack ?? err}`);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* nothing left to write to */
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-17 15:32:28 +03:00
|
|
|
|
/** 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 20:54:16 +03:00
|
|
|
|
/** 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). */
|
2026-09-17 15:32:28 +03:00
|
|
|
|
|
|
|
|
|
|
function log(line) {
|
|
|
|
|
|
const stamp = new Date().toISOString();
|
2026-10-10 14:20:11 +03:00
|
|
|
|
// The stdout write is guarded (EPIPE hardening, 2026-10-10): a packaged
|
|
|
|
|
|
// app launched with a consumer-owned pipe (a launcher that exits, a
|
|
|
|
|
|
// piped run) must not die on its next log line when the pipe breaks.
|
|
|
|
|
|
// The file below stays the durable channel.
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log(`[lh ${stamp}] ${line}`);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* stdout gone — userData/logs/main.log still carries it */
|
|
|
|
|
|
}
|
2026-09-17 15:32:28 +03:00
|
|
|
|
// 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(),
|
2026-09-19 20:54:16 +03:00
|
|
|
|
// 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(),
|
2026-09-17 15:32:28 +03:00
|
|
|
|
},
|
|
|
|
|
|
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,
|
2026-09-17 16:55:07 +03:00
|
|
|
|
// 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
|
2026-09-17 17:18:41 +03:00
|
|
|
|
// 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.
|
2026-09-17 16:55:07 +03:00
|
|
|
|
// Dragging comes from the renderer's .lh-drag bands (app/globals.css).
|
2026-10-11 10:12:13 +03:00
|
|
|
|
...(process.platform === "darwin"
|
|
|
|
|
|
? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 22.5 } }
|
|
|
|
|
|
: {
|
|
|
|
|
|
titleBarStyle: "hidden",
|
2026-10-12 09:46:42 +03:00
|
|
|
|
// Native min/max/close on the right, at the OS's compact default
|
|
|
|
|
|
// height. Do not force the zoom-invariant native overlay to match
|
|
|
|
|
|
// the renderer's 60 CSS px bar: zooming out would cover its border.
|
|
|
|
|
|
// The renderer reserves the actual width via env(titlebar-area-*).
|
|
|
|
|
|
titleBarOverlay: { color: "#141414", symbolColor: "#e5e5e5" },
|
2026-10-11 10:12:13 +03:00
|
|
|
|
// Keep the custom bar alone; Alt reveals the native menu.
|
|
|
|
|
|
autoHideMenuBar: true,
|
|
|
|
|
|
}),
|
2026-09-17 15:32:28 +03:00
|
|
|
|
webPreferences: {
|
|
|
|
|
|
contextIsolation: true,
|
|
|
|
|
|
sandbox: true,
|
|
|
|
|
|
nodeIntegration: false,
|
2026-09-17 18:57:02 +03:00
|
|
|
|
preload: path.join(__dirname, "preload.cjs"),
|
2026-09-17 15:32:28 +03:00
|
|
|
|
},
|
2026-09-17 18:43:35 +03:00
|
|
|
|
// 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,
|
2026-09-17 18:57:02 +03:00
|
|
|
|
// 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,
|
2026-09-17 15:32:28 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const allowedOrigin = () => new URL(url).origin;
|
2026-09-19 13:16:34 +03:00
|
|
|
|
// 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.
|
2026-09-25 20:02:19 +03:00
|
|
|
|
// "local-fonts": the appearance section's reading-font picker (2026-09-26).
|
|
|
|
|
|
const allowedPermissions = new Set(["media", "clipboard-sanitized-write", "local-fonts"]);
|
2026-09-19 13:16:34 +03:00
|
|
|
|
const fromOwnOrigin = (origin, permission) =>
|
|
|
|
|
|
origin === allowedOrigin() && allowedPermissions.has(permission);
|
2026-09-17 15:32:28 +03:00
|
|
|
|
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback, details) => {
|
|
|
|
|
|
const requestOrigin = details.requestingUrl ? new URL(details.requestingUrl).origin : null;
|
2026-09-19 13:16:34 +03:00
|
|
|
|
callback(fromOwnOrigin(requestOrigin, permission));
|
2026-09-17 15:32:28 +03:00
|
|
|
|
});
|
|
|
|
|
|
session.defaultSession.setPermissionCheckHandler((_wc, permission, origin) => {
|
2026-09-19 13:16:34 +03:00
|
|
|
|
return fromOwnOrigin(origin, permission);
|
2026-09-17 15:32:28 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-10-05 05:42:27 +03:00
|
|
|
|
// 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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-17 18:43:35 +03:00
|
|
|
|
win.once("ready-to-show", () => win.show());
|
2026-09-17 15:32:28 +03:00
|
|
|
|
return win;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-05 05:42:27 +03:00
|
|
|
|
/* ── 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];
|
|
|
|
|
|
|
2026-10-10 14:11:19 +03:00
|
|
|
|
/* ── 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
|
2026-10-11 10:12:13 +03:00
|
|
|
|
* give differential downloads for free. Windows uses NSIS; Linux uses
|
|
|
|
|
|
* AppImage or DebUpdater (6.8.9 supports deb; installation can request
|
|
|
|
|
|
* administrator permission via the package manager — owner 2026-10-11).
|
2026-10-10 14:11:19 +03:00
|
|
|
|
*
|
|
|
|
|
|
* 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}`));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-05 05:42:27 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 15:32:28 +03:00
|
|
|
|
/** 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" },
|
2026-10-10 14:11:19 +03:00
|
|
|
|
// 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 }) },
|
2026-09-17 15:32:28 +03:00
|
|
|
|
{ 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",
|
2026-10-05 05:42:27 +03:00
|
|
|
|
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" },
|
|
|
|
|
|
],
|
2026-09-17 15:32:28 +03:00
|
|
|
|
},
|
|
|
|
|
|
{ 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();
|
2026-09-17 18:57:02 +03:00
|
|
|
|
|
2026-10-10 14:11:19 +03:00
|
|
|
|
// 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());
|
|
|
|
|
|
|
2026-09-17 18:57:02 +03:00
|
|
|
|
// 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.
|
2026-10-05 05:42:27 +03:00
|
|
|
|
// 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);
|
2026-09-17 18:57:02 +03:00
|
|
|
|
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 });
|
|
|
|
|
|
});
|
2026-09-17 15:32:28 +03:00
|
|
|
|
app.on("activate", () => {
|
|
|
|
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindow(url);
|
|
|
|
|
|
});
|
|
|
|
|
|
createWindow(url);
|
|
|
|
|
|
log(`window open: ${url}`);
|
2026-10-10 14:11:19 +03:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-09-17 15:32:28 +03:00
|
|
|
|
}
|