Merge upstream v0.4.0 into Gitea fork
This commit is contained in:
+93
-21
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user