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-24 12:17:08 +03:00
const { app , BrowserWindow , Menu , ipcMain , session , shell , dialog , safeStorage } = require ( "electron" );
const { createChatGPT , encryptedStore } = require ( "./chatgpt.cjs" );
const { attachChatGPTChild , trustedSender , chatGPTControl } = require ( "./chatgpt-ipc.cjs" );
const { desktopAPIHeaders } = require ( "./desktop-api.cjs" );
const { randomBytes } = require ( "node:crypto" );
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
2026-10-20 15:56:19 +03:00
// endpoint listens. Lifetime: parent IPC disconnect/death = quit.
2026-10-11 07:03:45 +03:00
// 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" )) {
2026-10-20 15:15:28 +03:00
const { waitForBrowserService } = require ( "./browser-service-readiness.cjs" );
// Own the helper's entire startup lifetime, including the readiness wait.
const lifetime = new AbortController ();
const parentClosed = () => {
2026-10-20 15:56:19 +03:00
lifetime . abort ( new Error ( "browser-service parent IPC disconnected" ));
2026-10-20 15:15:28 +03:00
app . quit ();
};
2026-10-11 07:03:45 +03:00
( async () => {
try {
2026-10-20 15:56:19 +03:00
// Full Windows Electron replaces stdin with synthetic EOF. Use the
// real Node IPC channel, and refuse to start an unowned helper.
if ( ! process . channel || ! process . connected ) throw new Error ( "browser-service requires connected parent IPC" );
process . once ( "disconnect" , parentClosed );
2026-10-11 07:03:45 +03:00
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" );
2026-10-20 15:15:28 +03:00
// Window load is not a DevTools HTTP readiness barrier. Confirm the
// endpoint before both production and smoke clients see the handshake.
await waitForBrowserService ( port , { signal : lifetime . signal });
if ( ! lifetime . signal . aborted ) console . log ( `BROWSER_SERVICE_CDP ${ port } ` );
2026-10-11 07:03:45 +03:00
} catch ( err ) {
2026-10-20 15:56:19 +03:00
if ( lifetime . signal . aborted ) return ; // Parent disconnect is normal.
2026-10-20 15:15:28 +03:00
console . error ( "browser-service failed:" , err );
2026-10-11 07:03:45 +03:00
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-24 12:17:08 +03:00
let chatgpt = null ;
let chatgptChild = null ;
// Per-launch request capability, never OAuth tokens and never renderer state.
let desktopAPISecret = null ;
const trustedAppWindows = new Set ();
2026-09-17 15:32:28 +03:00
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-10-24 12:17:08 +03:00
LH_DESKTOP_SERVER : "1" ,
LH_DESKTOP_API_SECRET : desktopAPISecret ,
2026-09-17 15:32:28 +03:00
},
2026-10-24 12:17:08 +03:00
stdio : [ "ignore" , "pipe" , "pipe" , "ipc" ],
2026-09-17 15:32:28 +03:00
},
);
serverProcess = child ;
2026-10-24 12:17:08 +03:00
chatgptChild = attachChatGPTChild ( child , chatgpt );
2026-09-17 15:32:28 +03:00
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 ;
2026-10-24 12:17:08 +03:00
chatgptChild ? . dispose ();
chatgptChild = null ;
chatgpt ? . close ();
2026-09-17 15:32:28 +03:00
});
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 () {
2026-10-24 12:17:08 +03:00
chatgpt ? . close ();
2026-09-17 15:32:28 +03:00
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
});
2026-10-24 12:17:08 +03:00
trustedAppWindows . add ( win );
win . once ( "closed" , () => trustedAppWindows . delete ( win ));
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" , () => {
2026-10-24 12:17:08 +03:00
chatgpt ? . pause ();
2026-09-17 15:32:28 +03:00
// 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 ) {
2026-10-24 12:17:08 +03:00
desktopAPISecret = randomBytes ( 32 ). toString ( "hex" );
const ownedOrigin = new URL ( url ). origin ;
// All URLs, not only /api: remove the header again on external redirects.
session . defaultSession . webRequest . onBeforeSendHeaders ({ urls : [ "<all_urls>" ] }, ( details , callback ) => {
callback ({ requestHeaders : desktopAPIHeaders ( details , ownedOrigin , desktopAPISecret , [... trustedAppWindows ]) });
});
chatgpt = createChatGPT ({
store : encryptedStore ( path . join ( app . getPath ( "userData" ), "chatgpt-credentials.enc.json" ), safeStorage ),
openExternal : ( target ) => shell . openExternal ( target ),
onChange : ( value ) => {
chatgptChild ? . send ( value );
for ( const win of BrowserWindow . getAllWindows ()) win . webContents . send ( "lh:chatgpt:changed" );
},
});
2026-09-17 15:32:28 +03:00
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 } ` );
}
2026-10-24 12:17:08 +03:00
// Renderer controls contain no token/URL/code operation. The separate server
// child is the only recipient of credential leases over inherited IPC.
ipcMain . handle ( "lh:chatgpt:control" , async ( event , command ) => {
if ( ! trustedAppWindows . has ( BrowserWindow . fromWebContents ( event . sender )) || ! trustedSender ( event , new URL ( url ). origin , BrowserWindow )) return { error : "Untrusted desktop request." };
if ( ! chatgpt || ! serverProcess ? . connected ) return { available : false , pending : false , activeId : null , accounts : [], error : "ChatGPT sign-in requires the desktop app's private server connection. Browser-only and externally launched development servers keep API-key connections." };
return chatGPTControl ( command , chatgpt );
});
2026-09-17 15:32:28 +03:00
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" , () => {
2026-10-24 12:17:08 +03:00
if ( BrowserWindow . getAllWindows (). length === 0 ) { chatgpt ? . resume (); createWindow ( url ); }
2026-09-17 15:32:28 +03:00
});
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
}