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);