Merge upstream v0.4.0 into Gitea fork

This commit is contained in:
2026-10-31 13:45:06 +03:00
286 changed files with 24403 additions and 2546 deletions
+93 -21
View File
@@ -24,12 +24,13 @@
* would shadow every settings-page key change until restart.
*/
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
const { app, BrowserWindow, Menu, ipcMain, session, shell } = require("electron");
const { createChatGPT, fileStore } = require("./chatgpt.cjs");
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
const { createSubscriptions, loadSubscriptionSDK } = require("./subscriptions.cjs");
const { attachSubscriptionChild, subscriptionControl } = require("./subscription-ipc.cjs");
const { desktopAPIHeaders } = require("./desktop-api.cjs");
const { openWebSubscriptionBrowser } = require("./web-subscription-browser.cjs");
const { randomBytes } = require("node:crypto");
const { spawn } = require("node:child_process");
const fs = require("node:fs");
@@ -140,6 +141,7 @@ let subscriptions = null;
let subscriptionChild = null;
// Per-launch request capability, never OAuth tokens and never renderer state.
let desktopAPISecret = null;
let reopenWindow = null;
const trustedAppWindows = new Set();
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
@@ -315,6 +317,7 @@ function createWindow(url) {
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
...(process.env.LH_DEV_URL ? { additionalArguments: ["--lh-web-subscriptions"] } : {}),
preload: path.join(__dirname, "preload.cjs"),
},
// Live-resize + first-paint flash (owner, 2026-09-17): during a resize
@@ -437,31 +440,57 @@ const ZOOM_LADDER = [0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
* 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
// Observational only: this snapshot NEVER gates a check, download or install.
// The revision lets late subscribers reject an older in-flight IPC snapshot.
let updateState = { status: "idle", version: null, revision: 0 };
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 reportUpdateStatus(status, version = null) {
try {
// A later check/error must not hide an update already staged for quit.
if (updateState.status === "downloaded" && status !== "downloaded") return;
updateState = { status, version: typeof version === "string" ? version : null, revision: updateState.revision + 1 };
for (const win of BrowserWindow.getAllWindows()) {
try {
win.webContents.send("lh:update-status", updateState);
} catch {
/* window mid-teardown — snapshot retrieval covers a later load */
}
}
} catch {
/* Status reporting must never interrupt the updater. */
}
}
/** Read the SAME data directory passed to the packaged server. Only an
* explicit false opts out; absent/corrupt/unreadable settings keep updates ON.
* Re-read for each automatic check so saving the toggle needs no restart. */
function automaticUpdatesEnabled() {
try {
const settings = JSON.parse(fs.readFileSync(path.join(DATA_DIR(), "settings.json"), "utf8"));
return settings?.general?.autoUpdate !== false;
} catch {
return true;
}
}
function checkForUpdates({ manual = false } = {}) {
if (!autoUpdater) return;
if (!manual && !automaticUpdatesEnabled()) {
log("auto-update: automatic check skipped (turned off in Settings)");
return;
}
// Do not gate on display state: a stale spinner/error must never disable
// future background checks. electron-updater owns its in-flight work.
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: nt("You're up to date."), detail: nt("LearnHarness {version} is the latest version.", { version: app.getVersion() }), buttons: [nt("OK")] });
// A declined check (e.g. an unsupported package) establishes no
// latest-version claim. Real results arrive through updater events.
if (!result) reportUpdateStatus("idle");
})
.catch((err) => {
log(`update check failed: ${err?.message ?? err}`);
if (manual) dialog.showMessageBox({ type: "warning", message: nt("Couldn't check for updates."), buttons: [nt("OK")], detail: String(err?.message ?? err) });
reportUpdateStatus("error");
});
}
@@ -478,12 +507,14 @@ function installUpdateNow() {
function setupAutoUpdater() {
if (!app.isPackaged) {
log("auto-update: dev build — updater disabled");
reportUpdateStatus("disabled");
return;
}
try {
autoUpdater = require("electron-updater").autoUpdater;
} catch (err) {
log(`auto-update: electron-updater unavailable (${err?.message ?? err}) — updates disabled`);
reportUpdateStatus("disabled");
return;
}
const feed = process.env.LH_UPDATE_FEED;
@@ -501,11 +532,37 @@ function setupAutoUpdater() {
debug: (m) => {}, // too chatty for the persistent log
error: (m) => log(`updater error: ${m}`),
};
autoUpdater.on("checking-for-update", () => reportUpdateStatus("checking"));
autoUpdater.on("update-available", (info) => reportUpdateStatus("downloading", info?.version));
autoUpdater.on("update-not-available", () => reportUpdateStatus("up-to-date"));
autoUpdater.on("update-downloaded", (info) => {
log(`update downloaded: ${info?.version} — banner shown`);
broadcastUpdateStatus({ status: "downloaded", version: info?.version ?? null });
reportUpdateStatus("downloaded", info?.version);
});
autoUpdater.on("error", (err) => log(`updater error: ${err?.message ?? err}`));
autoUpdater.on("error", (err) => {
log(`updater error: ${err?.message ?? err}`);
reportUpdateStatus("error");
});
}
/** One result surface for manual checks. Preload buffers the navigation
* request until React subscribes, including when all macOS windows closed. */
function checkForUpdatesFromMenu() {
try {
const win = BrowserWindow.getAllWindows()[0] ?? reopenWindow?.();
if (win) {
if (win.isMinimized()) win.restore();
win.focus();
const openSettings = () => {
try { win.webContents.send("lh:open-settings", "general"); } catch { /* window closed */ }
};
if (win.webContents.isLoadingMainFrame()) win.webContents.once("did-finish-load", openSettings);
else openSettings();
}
} catch {
/* Navigation failure must not prevent the manual check. */
}
checkForUpdates({ manual: true });
}
function zoomStep(win, dir) {
@@ -541,9 +598,8 @@ function installMenu() {
label: app.name,
submenu: [
{ role: "about", label: nt("About LearnHarness") },
// Manual check (Wave C): answers via native dialog — no renderer
// round-trip. The 24h automatic checks stay silent.
{ label: nt("Check for Updates…"), click: () => checkForUpdates({ manual: true }) },
// Manual checks report their result in Settings → General.
{ label: nt("Check for Updates…"), click: () => checkForUpdatesFromMenu() },
{ type: "separator" },
{ role: "hide", label: nt("Hide LearnHarness") },
{ role: "hideOthers", label: nt("Hide Others") },
@@ -657,6 +713,13 @@ async function main() {
return subscriptionControl(command, subscriptions);
});
// The dev shell has no browser tabs. Only a one-use action on its own
// Next server may be resolved here; never accept an arbitrary external URL.
ipcMain.handle("lh:subscriptions:web-browser", async (event, action) => {
if (!devUrl || !trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted development request.");
await openWebSubscriptionBrowser(action, new URL(url).origin, { openExternal: (target) => shell.openExternal(target) });
});
installMenu();
ipcMain.on("lh:language", (event, language) => {
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) return;
@@ -672,6 +735,14 @@ async function main() {
// are log lines — this can never block boot.
setupAutoUpdater();
ipcMain.on("lh:update-install", () => installUpdateNow());
ipcMain.handle("lh:update:get", (event) => {
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
return updateState;
});
ipcMain.handle("lh:update:check", (event) => {
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
checkForUpdates({ manual: true });
});
// Sidebar-at-narrow-window (owner, 2026-09-17): the renderer auto-closes
// the sidebar below SIDEBAR_AUTOCLOSE (928px); opening it again at that
@@ -688,13 +759,14 @@ async function main() {
const target = SIDEBAR_AUTOCLOSE;
if (width < target) win.setBounds({ x, y, width: target, height });
});
reopenWindow = () => { chatgpt?.resume(); subscriptions?.resume(); return createWindow(url); };
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); subscriptions?.resume(); createWindow(url); }
if (BrowserWindow.getAllWindows().length === 0) reopenWindow();
});
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).
// Startup + 24h checks stay main-owned and read the current preference.
// No renderer subscription is required; late loads retrieve the snapshot.
if (autoUpdater) {
checkForUpdates();
setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS);
+35 -4
View File
@@ -19,17 +19,35 @@
*
* 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.
* {status, version, revision} on updater events; installUpdate asks main
* to kill the server and quit-and-install. getUpdateState retrieves the
* snapshot for a late subscriber; checkForUpdates requests a manual
* check (including download/install-on-quit, even with automatic checks OFF).
*
* window.lh.onOpenSettings(cb) — menu navigation, buffered until React
* subscribes after a window is reopened. Browser builds have no bridge.
*
* Channel names are mirrored in electron/main.cjs — keep them in sync.
*/
const { contextBridge, ipcRenderer } = require("electron");
const { contextBridge, ipcRenderer, webUtils } = require("electron");
let openSettingsListener = null;
let pendingOpenSettings = null;
ipcRenderer.on("lh:open-settings", (_event, section) => {
if (openSettingsListener) openSettingsListener(section);
else pendingOpenSettings = section;
});
contextBridge.exposeInMainWorld("lh", {
platform: process.platform,
setLanguage: (language) => ipcRenderer.send("lh:language", language),
// Resolve only a renderer-supplied, OS-backed File. No filesystem API,
// uploads, copies, or DOM File IPC to main; sandbox/isolation stay on.
getPathForFile: (file) => webUtils.getPathForFile(file),
...(process.argv?.includes("--lh-web-subscriptions") ? {
subscriptionTransport: "web",
openSubscriptionBrowser: (action) => ipcRenderer.invoke("lh:subscriptions:web-browser", action),
} : {}),
chatgpt: {
status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }),
login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }),
@@ -72,4 +90,17 @@ contextBridge.exposeInMainWorld("lh", {
return () => ipcRenderer.removeListener("lh:update-status", listener);
},
installUpdate: () => ipcRenderer.send("lh:update-install"),
getUpdateState: () => ipcRenderer.invoke("lh:update:get"),
checkForUpdates: () => ipcRenderer.invoke("lh:update:check"),
onOpenSettings: (cb) => {
openSettingsListener = cb;
if (pendingOpenSettings !== null) {
const section = pendingOpenSettings;
pendingOpenSettings = null;
cb(section);
}
return () => {
if (openSettingsListener === cb) openSettingsListener = null;
};
},
});
+11 -10
View File
@@ -1,7 +1,7 @@
"use strict";
const { randomUUID } = require("node:crypto");
const path = require("node:path");
const { createRequire, registerHooks } = require("node:module");
const { registerHooks } = require("node:module");
const { pathToFileURL } = require("node:url");
const { PROVIDERS, fileStore, validateCredential, safeHttps, failure } = require("./subscription-store.cjs");
@@ -21,17 +21,18 @@ const MESSAGES = {
function publicError(error) { return MESSAGES[error?.code] ?? MESSAGES.unavailable; }
/** Resolve the SAME SDK installation as the standalone server, through public exports. */
async function loadSubscriptionSDK(baseDirectory) {
const resolve = createRequire(path.join(baseDirectory, "package.json"));
const load = (id) => {
// Pi exports are import-only. Resolve the public export with ESM
// conditions, then remove this synchronous, exact-specifier hook
// BEFORE evaluation; no private package path or global hook remains.
const parentURL = pathToFileURL(path.join(baseDirectory, "package.json")).href;
const load = async (id) => {
// The manifest URL plus fragment is an exact resolution token, never
// evaluated. Normal ESM resolution selects the public import export
// from the server's installation, including under test-loader hooks.
const token = `${parentURL}#${encodeURIComponent(id)}`;
const hook = registerHooks({ resolve(specifier, context, next) {
return next(specifier, specifier === id ? { ...context, conditions: [...context.conditions, "import"] } : context);
if (specifier !== token) return next(specifier, context);
hook.deregister();
return next(id, { ...context, parentURL });
} });
let file;
try { file = resolve.resolve(id); } finally { hook.deregister(); }
return import(pathToFileURL(file).href);
try { return await import(token); } finally { hook.deregister(); }
};
const [sdk, copilot, xai] = await Promise.all([load("@earendil-works/pi-ai"), load("@earendil-works/pi-ai/providers/github-copilot"), load("@earendil-works/pi-ai/providers/xai")]);
return { ...sdk, ...copilot, ...xai };
+19
View File
@@ -0,0 +1,19 @@
"use strict";
/** Desktop-dev has no tabs. Resolve only an opaque action on its own Next
* server, then open the owner-validated provider page outside the renderer.
* The caller must authenticate its trusted app main frame first. */
async function openWebSubscriptionBrowser(action, origin, { fetch: request = globalThis.fetch, openExternal }) {
if (typeof action !== "string" || !/^\/api\/subscriptions\/browser\?id=[0-9a-f-]{36}$/.test(action)) throw new Error("Invalid sign-in action.");
const response = await request(new URL(action, origin), {
method: "POST", redirect: "manual", credentials: "omit",
headers: { origin, "x-learnharness-subscriptions": "1" },
signal: AbortSignal.timeout(15000),
});
const target = response.headers.get("location");
if (response.status !== 303 || !target) throw new Error("Sign-in action expired.");
const parsed = new URL(target);
if (parsed.protocol !== "https:" || parsed.username || parsed.password) throw new Error("Invalid provider page.");
await openExternal(target);
}
module.exports = { openWebSubscriptionBrowser };