diff --git a/app/layout.tsx b/app/layout.tsx index 6782118..2343a78 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,6 +6,7 @@ import { AppShell, SidebarProvider } from "@/components/sidebar"; import { ActivityProvider } from "@/components/activity-dock"; import { ServerEventsProvider } from "@/components/server-events"; import { AppearanceProvider } from "@/components/settings/appearance"; +import UpdateBanner from "@/components/update-banner"; // Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU // Serif (the bundled lh-cmu @font-face in globals.css — no CDN). @@ -32,6 +33,8 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + {/* Auto-update banner (Wave C): fixed card, Electron-only pushes. */} + ); diff --git a/components/sidebar.tsx b/components/sidebar.tsx index ee06757..b552a4a 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -60,6 +60,9 @@ declare global { /** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */ onZoom?: (cb: (factor: number) => void) => () => void; getZoom?: () => Promise; + /** Auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes once a staged update exists. */ + onUpdateStatus?: (cb: (status: { status: string; version: string | null }) => void) => () => void; + installUpdate?: () => void; }; } } diff --git a/components/update-banner.tsx b/components/update-banner.tsx new file mode 100644 index 0000000..cd63030 --- /dev/null +++ b/components/update-banner.tsx @@ -0,0 +1,50 @@ +"use client"; + +/** + * The auto-update banner (Wave C, owner 2026-10-10: silent download + + * restart-banner UX). The shell pushes "lh:update-status" once a staged + * update exists (electron/main.cjs update-downloaded); this is the whole + * renderer surface — a small fixed card, bottom-right, offering Restart. + * Browser builds have no window.lh and never see a push: renders null. + * + * No dismiss: a downloaded update installs on quit anyway + * (autoInstallOnAppQuit); the banner is a courtesy, not a gate. + */ + +import { useEffect, useState } from "react"; + +// The window.lh bridge type lives in components/sidebar.tsx (the incumbent +// declare-global) — onUpdateStatus/installUpdate are declared there too. + +type UpdateStatus = { status: string; version: string | null }; + +export default function UpdateBanner() { + const [ready, setReady] = useState(null); + + useEffect(() => { + // Absent bridge (browser / dev) → stays hidden forever. + return window.lh?.onUpdateStatus?.((s) => { + if (s?.status === "downloaded") setReady(s); + }); + }, []); + + if (!ready) return null; + return ( +
+
+ LearnHarness {ready.version ?? "update"} is ready + — restart to finish installing. +
+ +
+ ); +} diff --git a/electron-builder.yml b/electron-builder.yml index a79c196..ebbe9f7 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -41,6 +41,33 @@ files: - "!**/*" - electron/**/* - package.json + # Auto-update (Wave C, 2026-10-10): electron-updater + its runtime tree + # re-included explicitly. The negation above excludes node_modules, and + # the shell's bare positives can't re-include them — these explicit + # node_modules positives do (gitignore order: the LAST matching pattern + # wins; verified post-build by listing the asar — see desktop:dist notes). + # Tree fact-checked via each package's declared dependencies: + # electron-updater → fs-extra, js-yaml, lazy-val, lodash.escaperegexp, + # lodash.isequal, semver, tiny-typed-emitter, builder-util-runtime; + # builder-util-runtime → debug, sax; fs-extra → graceful-fs, jsonfile, + # universalify; debug → ms; jsonfile → universalify (again); js-yaml → + # argparse. Keep in sync when electron-updater bumps (npm ls --all). + - node_modules/electron-updater/**/* + - node_modules/fs-extra/**/* + - node_modules/js-yaml/**/* + - node_modules/lazy-val/**/* + - node_modules/lodash.escaperegexp/**/* + - node_modules/lodash.isequal/**/* + - node_modules/semver/**/* + - node_modules/tiny-typed-emitter/**/* + - node_modules/builder-util-runtime/**/* + - node_modules/debug/**/* + - node_modules/sax/**/* + - node_modules/graceful-fs/**/* + - node_modules/jsonfile/**/* + - node_modules/universalify/**/* + - node_modules/ms/**/* + - node_modules/argparse/**/* extraResources: # Copy from the PARENT dir (.next), not .next/standalone itself: # electron-builder hard-excludes a copy's root node_modules and the diff --git a/electron/main.cjs b/electron/main.cjs index 65a42c3..7ef876f 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -24,7 +24,7 @@ * would shadow every settings-page key change until restart. */ -const { app, BrowserWindow, Menu, ipcMain, session, shell } = require("electron"); +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"); @@ -298,6 +298,104 @@ function createWindow(url) { * 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 = @@ -321,6 +419,9 @@ function installMenu() { 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" }, @@ -393,6 +494,13 @@ async function main() { 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 @@ -413,4 +521,10 @@ async function main() { }); 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); + } } \ No newline at end of file diff --git a/electron/preload.cjs b/electron/preload.cjs index 7c4926f..421763f 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -14,6 +14,12 @@ * publishes it as --lh-zoom so physical-constant lengths (the traffic- * light gutter) can divide by it. * + * window.lh.onUpdateStatus(cb) / window.lh.installUpdate() — the + * auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes + * {status:"downloaded", version} once an update is staged; installUpdate + * asks main to kill the server and quit-and-install. Browser builds: + * both absent, the banner never renders. + * * Channel names are mirrored in electron/main.cjs — keep them in sync. */ const { contextBridge, ipcRenderer } = require("electron"); @@ -26,4 +32,10 @@ contextBridge.exposeInMainWorld("lh", { return () => ipcRenderer.removeListener("lh:zoom", listener); }, getZoom: () => ipcRenderer.invoke("lh:zoom:get"), + onUpdateStatus: (cb) => { + const listener = (_event, status) => cb(status); + ipcRenderer.on("lh:update-status", listener); + return () => ipcRenderer.removeListener("lh:update-status", listener); + }, + installUpdate: () => ipcRenderer.send("lh:update-install"), }); diff --git a/package-lock.json b/package-lock.json index 460fd7d..179d16f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "learning-harness", + "name": "learnharness", "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "learning-harness", + "name": "learnharness", "version": "0.2.0", + "license": "AGPL-3.0-only", "dependencies": { "@earendil-works/pi-agent-core": "1.0.2", "@earendil-works/pi-ai": "1.0.2", @@ -27,6 +28,7 @@ "d3-shape": "^3.2.0", "d3-zoom": "^3.0.0", "dagre-d3-es": "7.0.14", + "electron-updater": "^6.8.9", "embla-carousel-react": "^8.6.0", "katex": "^0.16.47", "linkedom": "^0.18.13", @@ -6398,7 +6400,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -6782,7 +6783,6 @@ "version": "9.7.0", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -8590,6 +8590,34 @@ "mime": "^2.5.2" } }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -9735,7 +9763,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -10150,7 +10177,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/hachure-fill": { @@ -11339,7 +11365,6 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, "funding": [ { "type": "github", @@ -11426,7 +11451,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -11497,7 +11521,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/levn": { @@ -11837,6 +11860,19 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -15184,7 +15220,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -15938,6 +15973,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", @@ -16456,7 +16497,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" diff --git a/package.json b/package.json index 7d8185b..20808a0 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "d3-shape": "^3.2.0", "d3-zoom": "^3.0.0", "dagre-d3-es": "7.0.14", + "electron-updater": "^6.8.9", "embla-carousel-react": "^8.6.0", "katex": "^0.16.47", "linkedom": "^0.18.13", diff --git a/tests/render-update-banner.test.tsx b/tests/render-update-banner.test.tsx new file mode 100644 index 0000000..0a9c307 --- /dev/null +++ b/tests/render-update-banner.test.tsx @@ -0,0 +1,82 @@ +/** + * Update-banner render tests (Wave C, 2026-10-10): the banner is a pure + * function of the last lh:update-status push — hidden without one (the + * browser/dev world: no window.lh at all), visible with the downloaded + * status, Restart wired to the bridge. The subscribe effect only runs + * client-side, so the SSR pass pins the hidden state; the pushed state is + * exercised by mounting with the bridge stubbed (classic-JSX shim as in + * render-sidebar.test.tsx). + */ +import React from "react"; +(globalThis as Record).React = React; + +import assert from "node:assert/strict"; +import { describe, it, afterEach } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import * as ReactDOMClient from "react-dom/client"; +import { flushSync } from "react-dom"; +import { parseHTML } from "linkedom"; +import UpdateBanner from "../components/update-banner.tsx"; + +// linkedom as the DOM for the interactive pass (first createRoot test in +// the repo — the banner's push→render→click flow needs real effects). +// The window.lh bridge type is declared globally by components/sidebar.tsx. +const dom = parseHTML(""); +(globalThis as Record).window = dom.window; +(globalThis as Record).document = dom.document; + +describe("update banner (Wave C)", () => { + afterEach(() => { + delete (window as { lh?: unknown }).lh; + }); + + it("renders nothing with no bridge (browser / SSR)", () => { + const html = renderToStaticMarkup(); + assert.equal(html, ""); + }); + + it("renders nothing when the bridge exists but no status has arrived", () => { + const listeners: Array<(s: { status: string; version: string | null }) => void> = []; + (window as { lh?: unknown }).lh = { + onUpdateStatus: (cb: (s: { status: string; version: string | null }) => void) => { + listeners.push(cb); + return () => {}; + }, + installUpdate: () => {}, + }; + const html = renderToStaticMarkup(); + assert.equal(html, ""); + void listeners; + }); + + it("shows the version + Restart once the downloaded push arrives, and Restart installs", async () => { + let pushed: ((s: { status: string; version: string | null }) => void) | null = null; + let installed = 0; + (window as { lh?: unknown }).lh = { + onUpdateStatus: (cb: (s: { status: string; version: string | null }) => void) => { + pushed = cb; + return () => {}; + }, + installUpdate: () => { + installed += 1; + }, + }; + const host = document.createElement("div"); + document.body.appendChild(host); + const root = ReactDOMClient.createRoot(host); + flushSync(() => root.render()); + assert.equal(host.innerHTML, "", "hidden before the push"); + + await new Promise((r) => setTimeout(r, 0)); + flushSync(() => pushed?.({ status: "downloaded", version: "0.3.1" })); + assert.ok(host.innerHTML.includes("0.3.1"), "the version renders"); + assert.ok(host.innerHTML.includes("Restart"), "the restart affordance renders"); + + const button = host.querySelector("[data-testid='update-banner'] button"); + assert.ok(button, "restart button exists"); + button!.click(); + assert.equal(installed, 1, "Restart asks the shell to quit-and-install"); + root.unmount(); + host.remove(); + }); +});