From 390e25efcbfdccb6d275eb544aec4fcc07fdca99 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 30 May 2026 11:48:35 -0500 Subject: [PATCH] feat(electron): implement backend process management and recovery features --- electron/backendProcess.js | 236 +++++++++++++++++ electron/main.js | 237 +++++++----------- electron/preload.js | 12 + meshchatx/meshchat.py | 10 +- meshchatx/src/frontend/components/App.vue | 78 +++++- .../components/layout/AppShellBanners.vue | 38 ++- meshchatx/src/frontend/locales/en.json | 8 + scripts/ci/ci-node-path.sh | 8 +- .../ci/github-build-linux-release-assets.sh | 14 ++ scripts/patch-electron-builder-fs.cjs | 12 +- tests/electron/backendProcess.test.js | 74 ++++++ tests/electron/preload.test.js | 20 ++ 12 files changed, 585 insertions(+), 162 deletions(-) create mode 100644 electron/backendProcess.js create mode 100644 tests/electron/backendProcess.test.js diff --git a/electron/backendProcess.js b/electron/backendProcess.js new file mode 100644 index 00000000..47b63d15 --- /dev/null +++ b/electron/backendProcess.js @@ -0,0 +1,236 @@ +const path = require("node:path"); +const { spawn: defaultSpawn } = require("child_process"); + +const { verifyBackendIntegrity } = require("./backendIntegrity"); + +const LOG_LINE_CAP = 100; + +function createInitialRuntimeState() { + return { + started: false, + running: false, + pid: null, + lastExitCode: null, + lastError: "", + lastEventAt: null, + }; +} + +function createBackendProcessManager(deps) { + const { + log, + getDefaultStorageDir, + getDefaultReticulumConfigDir, + getMainWindowPageKind, + notifyRenderer, + showCrashPage, + spawn: spawnFn = defaultSpawn, + } = deps; + + let childProcess = null; + let runtimeState = createInitialRuntimeState(); + let logBuffers = { stdout: [], stderr: [] }; + let lastCrash = null; + let resolvedExePath = null; + let userProvidedArguments = []; + + function isRunning() { + return !!childProcess && childProcess.exitCode === null && childProcess.signalCode === null; + } + + function getRuntimeState() { + return { + ...runtimeState, + running: isRunning() && runtimeState.started, + }; + } + + function pushLogLine(buffer, line) { + buffer.push(line); + if (buffer.length > LOG_LINE_CAP) { + buffer.shift(); + } + } + + function getJoinedLogs() { + return { + stdout: logBuffers.stdout.join(""), + stderr: logBuffers.stderr.join(""), + }; + } + + function getLastCrash() { + return lastCrash; + } + + function setUserProvidedArguments(args) { + userProvidedArguments = Array.isArray(args) ? args : []; + } + + function resolveExecutablePath(findExePath) { + resolvedExePath = findExePath(); + return resolvedExePath; + } + + function attachChildHandlers(proc) { + logBuffers = { stdout: [], stderr: [] }; + + proc.stdout.setEncoding("utf8"); + proc.stdout.on("data", (data) => { + const text = data.toString(); + log(text); + pushLogLine(logBuffers.stdout, text); + }); + + proc.stderr.setEncoding("utf8"); + proc.stderr.on("data", (data) => { + const text = data.toString(); + log(text); + pushLogLine(logBuffers.stderr, text); + }); + + proc.on("error", (error) => { + log(error); + runtimeState.lastError = error && error.message ? error.message : String(error); + runtimeState.lastEventAt = Date.now(); + }); + + proc.on("exit", async (code) => { + runtimeState.running = false; + runtimeState.lastExitCode = code; + runtimeState.lastEventAt = Date.now(); + childProcess = null; + + if (code == null || deps.isQuiting()) { + return; + } + + const logs = getJoinedLogs(); + lastCrash = { + code, + stdout: logs.stdout, + stderr: logs.stderr, + at: Date.now(), + }; + + notifyRenderer("backend-process-exited", { code, at: lastCrash.at }); + + const page = getMainWindowPageKind(); + if (page === "loading" || page === "app") { + return; + } + + if (page === "crash") { + return; + } + + await showCrashPage(lastCrash); + }); + } + + async function spawnBackend(exePath, integrityStatusRef) { + if (!exePath) { + throw new Error("Backend executable path is not set."); + } + if (isRunning()) { + return { ok: true, alreadyRunning: true }; + } + + resolvedExePath = exePath; + const exeDir = path.dirname(exePath); + integrityStatusRef.backend = verifyBackendIntegrity(exeDir); + if ( + integrityStatusRef.backend.ok && + integrityStatusRef.backend.issues.length === 1 && + integrityStatusRef.backend.issues[0] === "Manifest missing" + ) { + log("Backend integrity manifest missing, skipping check."); + } + if (!integrityStatusRef.backend.ok) { + log( + `INTEGRITY WARNING: Backend tampering detected! Issues: ${integrityStatusRef.backend.issues.join(", ")}` + ); + } + + const requiredArguments = ["--headless", "--port", "9337"]; + if (!userProvidedArguments.includes("--reticulum-config-dir")) { + requiredArguments.push("--reticulum-config-dir", getDefaultReticulumConfigDir()); + } + if (!userProvidedArguments.includes("--storage-dir")) { + requiredArguments.push("--storage-dir", getDefaultStorageDir()); + } + + const proc = spawnFn(exePath, [...requiredArguments, ...userProvidedArguments]); + if (!proc || !proc.pid) { + throw new Error("Failed to start backend process (no PID)."); + } + + childProcess = proc; + runtimeState = { + started: true, + running: true, + pid: proc.pid, + lastExitCode: null, + lastError: "", + lastEventAt: Date.now(), + }; + attachChildHandlers(proc); + return { ok: true, pid: proc.pid }; + } + + function getChildProcess() { + return childProcess; + } + + function killChild(signal) { + if (!childProcess) { + return; + } + if (childProcess.exitCode !== null || childProcess.signalCode !== null) { + return; + } + childProcess.kill(signal); + } + + async function restartBackend(integrityStatusRef) { + if (!resolvedExePath) { + return { ok: false, error: "Backend executable is not configured." }; + } + if (isRunning()) { + return { ok: false, error: "Backend is already running." }; + } + try { + const result = await spawnBackend(resolvedExePath, integrityStatusRef); + return { ok: true, pid: result.pid }; + } catch (error) { + return { ok: false, error: error && error.message ? error.message : String(error) }; + } + } + + async function openCrashReport(showCrashPageFn) { + if (!lastCrash) { + return { ok: false, error: "No backend crash report is available." }; + } + await showCrashPageFn(lastCrash); + return { ok: true }; + } + + return { + createInitialRuntimeState, + setUserProvidedArguments, + resolveExecutablePath, + spawnBackend, + restartBackend, + openCrashReport, + getRuntimeState, + getLastCrash, + getChildProcess, + isRunning, + killChild, + getJoinedLogs, + }; +} + +module.exports = { + createBackendProcessManager, +}; diff --git a/electron/main.js b/electron/main.js index 9b36fe09..d12ffae9 100644 --- a/electron/main.js +++ b/electron/main.js @@ -13,11 +13,10 @@ const { clipboard, } = require("electron"); const electronPrompt = require("electron-prompt"); -const { spawn } = require("child_process"); const fs = require("fs"); const path = require("node:path"); -const { verifyBackendIntegrity } = require("./backendIntegrity"); +const { createBackendProcessManager } = require("./backendProcess"); const { getUserProvidedArguments, formatRenderProcessGoneDetails, isLocalBackendUrl } = require("./mainHelpers"); const { isAllowedShellPath } = require("./shellPathGuard"); const { normalizeExternalUrlForOpen } = require("./safeExternalUrl"); @@ -45,16 +44,8 @@ var activePowerSaveBlockerId = null; // track if we are actually quiting var isQuiting = false; -// remember child process for exe so we can kill it when app exits -var exeChildProcess = null; -var backendRuntimeState = { - started: false, - running: false, - pid: null, - lastExitCode: null, - lastError: "", - lastEventAt: null, -}; +// backend child process (managed by backendProcess.js) +var backendManager = null; // store integrity status var integrityStatus = { @@ -187,15 +178,20 @@ ipcMain.handle("backend-http-only", () => { }); ipcMain.handle("backend-runtime-state", () => { - const isRunning = - !!exeChildProcess && - exeChildProcess.exitCode === null && - exeChildProcess.signalCode === null && - backendRuntimeState.started; - return { - ...backendRuntimeState, - running: isRunning, - }; + return getBackendManager().getRuntimeState(); +}); + +ipcMain.handle("restart-backend", async () => { + return await getBackendManager().restartBackend(integrityStatus); +}); + +ipcMain.handle("open-backend-crash-report", async () => { + const lastCrash = getBackendManager().getLastCrash(); + if (!lastCrash) { + return { ok: false, error: "No backend crash report is available." }; + } + await loadBackendCrashPage(lastCrash); + return { ok: true }; }); // add support for showing an alert window via ipc @@ -462,6 +458,68 @@ function getAppIconPath() { return fs.existsSync(iconPath) ? iconPath : fallbackIconPath; } +function getMainWindowPageKind() { + if (!mainWindow || mainWindow.isDestroyed()) { + return "none"; + } + const url = mainWindow.webContents.getURL(); + if (url.includes("loading.html")) { + return "loading"; + } + if (url.includes("crash.html")) { + return "crash"; + } + if (isLocalBackendUrl(url)) { + return "app"; + } + return "other"; +} + +async function loadBackendCrashPage(crash) { + const stdoutBase64 = Buffer.from((crash && crash.stdout) || "").toString("base64"); + const stderrBase64 = Buffer.from((crash && crash.stderr) || "").toString("base64"); + const code = crash && crash.code != null ? String(crash.code) : ""; + + if (!mainWindow || mainWindow.isDestroyed()) { + await dialog.showMessageBox({ + type: "error", + title: "MeshChatX Crashed", + message: `Backend exited with code: ${code}`, + }); + app.quit(); + return; + } + + mainWindow.show(); + mainWindow.focus(); + await mainWindow.loadFile(path.join(__dirname, "crash.html"), { + query: { + code: code, + stdout: stdoutBase64, + stderr: stderrBase64, + }, + }); +} + +function getBackendManager() { + if (!backendManager) { + backendManager = createBackendProcessManager({ + log, + getDefaultStorageDir, + getDefaultReticulumConfigDir, + getMainWindowPageKind, + isQuiting: () => quitInitiated, + notifyRenderer: (channel, payload) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(channel, payload); + } + }, + showCrashPage: loadBackendCrashPage, + }); + } + return backendManager; +} + function createTray() { tray = new Tray(getAppIconPath()); const contextMenu = Menu.buildFromTemplate([ @@ -724,131 +782,10 @@ app.whenReady().then(async () => { log(`Found executable at: ${exe}`); - // Verify backend integrity before spawning - const exeDir = path.dirname(exe); - integrityStatus.backend = verifyBackendIntegrity(exeDir); - if ( - integrityStatus.backend.ok && - integrityStatus.backend.issues.length === 1 && - integrityStatus.backend.issues[0] === "Manifest missing" - ) { - log("Backend integrity manifest missing, skipping check."); - } - if (!integrityStatus.backend.ok) { - log(`INTEGRITY WARNING: Backend tampering detected! Issues: ${integrityStatus.backend.issues.join(", ")}`); - } - + const manager = getBackendManager(); + manager.setUserProvidedArguments(userProvidedArguments); try { - // arguments we always want to pass in - const requiredArguments = [ - "--headless", // reticulum meshchatx usually launches default web browser, we don't want this when using electron - "--port", - "9337", - // '--test-exception-message', 'Test Exception Message', // uncomment to test the crash dialog - ]; - - // if user didn't provide reticulum config dir, we should provide it - if (!userProvidedArguments.includes("--reticulum-config-dir")) { - requiredArguments.push("--reticulum-config-dir", getDefaultReticulumConfigDir()); - } - - // if user didn't provide storage dir, we should provide it - if (!userProvidedArguments.includes("--storage-dir")) { - requiredArguments.push("--storage-dir", getDefaultStorageDir()); - } - - // spawn executable - exeChildProcess = spawn(exe, [ - ...requiredArguments, // always provide required arguments - ...userProvidedArguments, // also include any user provided arguments - ]); - - if (!exeChildProcess || !exeChildProcess.pid) { - throw new Error("Failed to start backend process (no PID)."); - } - backendRuntimeState = { - started: true, - running: true, - pid: exeChildProcess.pid, - lastExitCode: null, - lastError: "", - lastEventAt: Date.now(), - }; - - // log stdout - var stdoutLines = []; - exeChildProcess.stdout.setEncoding("utf8"); - exeChildProcess.stdout.on("data", function (data) { - // log - log(data.toString()); - - // keep track of last 100 stdout lines - stdoutLines.push(data.toString()); - if (stdoutLines.length > 100) { - stdoutLines.shift(); - } - }); - - // log stderr - var stderrLines = []; - exeChildProcess.stderr.setEncoding("utf8"); - exeChildProcess.stderr.on("data", function (data) { - // log - log(data.toString()); - - // keep track of last 100 stderr lines - stderrLines.push(data.toString()); - if (stderrLines.length > 100) { - stderrLines.shift(); - } - }); - - // log errors - exeChildProcess.on("error", function (error) { - log(error); - backendRuntimeState.lastError = error && error.message ? error.message : String(error); - backendRuntimeState.lastEventAt = Date.now(); - }); - - // quit electron app if exe dies - exeChildProcess.on("exit", async function (code) { - backendRuntimeState.running = false; - backendRuntimeState.lastExitCode = code; - backendRuntimeState.lastEventAt = Date.now(); - // if no exit code provided, we wanted exit to happen, so do nothing - if (code == null) { - return; - } - - // show crash log - const stdout = stdoutLines.join(""); - const stderr = stderrLines.join(""); - - // Base64 encode for safe URL passing - const stdoutBase64 = Buffer.from(stdout).toString("base64"); - const stderrBase64 = Buffer.from(stderr).toString("base64"); - - // Load crash page if main window exists - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.show(); // Ensure visible - mainWindow.focus(); - await mainWindow.loadFile(path.join(__dirname, "crash.html"), { - query: { - code: code.toString(), - stdout: stdoutBase64, - stderr: stderrBase64, - }, - }); - } else { - // Fallback for cases where window is gone - await dialog.showMessageBox({ - type: "error", - title: "MeshChatX Crashed", - message: `Backend exited with code: ${code}\n\nSTDOUT: ${stdout.slice(-500)}\n\nSTDERR: ${stderr.slice(-500)}`, - }); - app.quit(); - } - }); + await manager.spawnBackend(exe, integrityStatus); } catch (e) { log(e); } @@ -869,6 +806,7 @@ function quit() { } quitInitiated = true; + const exeChildProcess = getBackendManager().getChildProcess(); if (!exeChildProcess) { app.quit(); return; @@ -878,11 +816,11 @@ function quit() { return; } try { - exeChildProcess.kill("SIGTERM"); + getBackendManager().killChild("SIGTERM"); } catch (e) { log(e); try { - exeChildProcess.kill("SIGKILL"); + getBackendManager().killChild("SIGKILL"); } catch (e2) { log(e2); } @@ -892,8 +830,9 @@ function quit() { const timeoutMs = 5000; quitTimeoutId = setTimeout(() => { try { - if (exeChildProcess && exeChildProcess.exitCode === null && exeChildProcess.signalCode === null) { - exeChildProcess.kill("SIGKILL"); + const proc = getBackendManager().getChildProcess(); + if (proc && proc.exitCode === null && proc.signalCode === null) { + getBackendManager().killChild("SIGKILL"); } } catch (e) { log(e); diff --git a/electron/preload.js b/electron/preload.js index cb4315c8..eda33b85 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -99,4 +99,16 @@ contextBridge.exposeInMainWorld("electron", { backendRuntimeState: async function () { return await ipcRenderer.invoke("backend-runtime-state"); }, + restartBackend: async function () { + return await ipcRenderer.invoke("restart-backend"); + }, + openBackendCrashReport: async function () { + return await ipcRenderer.invoke("open-backend-crash-report"); + }, + onBackendProcessExited: function (callback) { + if (typeof callback !== "function") { + return; + } + ipcRenderer.on("backend-process-exited", (_event, payload) => callback(payload)); + }, }); diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 18fe1472..f6f7fdad 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -4826,7 +4826,15 @@ class ReticulumMeshChat: data, "max_reconnect_tries", ) - InterfaceEditor.update_value(interface_details, data, "fixed_mtu") + fixed_mtu_error = InterfaceEditor.apply_fixed_mtu( + interface_details, + data, + ) + if fixed_mtu_error is not None: + return web.json_response( + {"message": fixed_mtu_error}, + status=422, + ) if interface_type == "BackboneInterface": # BackboneInterface supports two distinct configurations: diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 16e8b28a..39dd3cd7 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -10,9 +10,15 @@ :show-emergency="Boolean(appInfo?.emergency)" :emergency-label="$t('app.emergency_mode_active')" :show-ws-disconnected="showWsDisconnectedBanner" - :ws-disconnected-label="`${$t('app.backend_disconnected')} · ${wsDisconnectedDurationText}`" + :ws-disconnected-label="backendOfflineBannerLabel" + :show-backend-recovery-actions="showBackendRecoveryActions" + :backend-restarting="backendRestarting" + :restart-backend-label="$t('app.restart_backend')" + :view-backend-logs-label="$t('app.view_backend_logs')" :show-ws-reconnected="wsReconnectedBanner" :ws-reconnected-label="$t('app.backend_reconnected')" + @restart-backend="onRestartBackend" + @view-backend-logs="onViewBackendCrashReport" /> @@ -753,6 +759,9 @@ export default { wsReconnectedBanner: false, wsDisconnectTickTimer: null, wsReconnectedHideTimer: null, + backendProcessExited: false, + backendExitCode: null, + backendRestarting: false, identitySwitchDedupeHash: null, identitySwitchDedupeAt: 0, @@ -787,6 +796,24 @@ export default { showWsDisconnectedBanner() { return this.shellRunning && this.wsDisconnected && this.$route?.name !== "auth"; }, + backendOfflineBannerLabel() { + const duration = this.wsDisconnectedDurationText; + const durationSuffix = duration ? ` · ${duration}` : ""; + if (this.backendProcessExited) { + const code = + this.backendExitCode != null && this.backendExitCode !== "" ? ` (${this.backendExitCode})` : ""; + return `${this.$t("app.backend_process_stopped")}${code}${durationSuffix}`; + } + return `${this.$t("app.backend_disconnected")}${durationSuffix}`; + }, + showBackendRecoveryActions() { + return ( + this.showWsDisconnectedBanner && + this.backendProcessExited && + ElectronUtils.isElectron() && + typeof window.electron?.restartBackend === "function" + ); + }, identitySidebarLabel() { const raw = this.displayName; const name = raw != null && String(raw).trim() !== "" ? String(raw).trim() : ""; @@ -864,6 +891,11 @@ export default { this.startShellAuthWatch(); this.applyShellAppearance(); if (ElectronUtils.isElectron()) { + if (typeof window.electron.onBackendProcessExited === "function") { + window.electron.onBackendProcessExited((payload) => { + this.onBackendProcessExited(payload); + }); + } window.electron.onProtocolLink((url) => { this.handleProtocolLink(url); }); @@ -973,6 +1005,9 @@ export default { this.wsDisconnectedAt = null; this.wsDisconnectedDurationText = ""; this.wsReconnectedBanner = false; + this.backendProcessExited = false; + this.backendExitCode = null; + this.backendRestarting = false; WebSocketConnection.destroy(); }, clearWsShellUiTimers() { @@ -985,6 +1020,45 @@ export default { this.wsReconnectedHideTimer = null; } }, + onBackendProcessExited(payload = {}) { + if (!this.shellRunning) { + return; + } + this.backendProcessExited = true; + this.backendExitCode = payload?.code ?? null; + this.onWsShellDisconnected(); + }, + async onRestartBackend() { + if (!window.electron?.restartBackend) { + return; + } + this.backendRestarting = true; + try { + const result = await window.electron.restartBackend(); + if (!result?.ok) { + ToastUtils.error(result?.error || this.$t("app.restart_backend_failed")); + return; + } + ToastUtils.info(this.$t("app.restart_backend_started")); + } catch { + ToastUtils.error(this.$t("app.restart_backend_failed")); + } finally { + this.backendRestarting = false; + } + }, + async onViewBackendCrashReport() { + if (!window.electron?.openBackendCrashReport) { + return; + } + try { + const result = await window.electron.openBackendCrashReport(); + if (!result?.ok) { + ToastUtils.error(result?.error || this.$t("app.view_backend_logs_failed")); + } + } catch { + ToastUtils.error(this.$t("app.view_backend_logs_failed")); + } + }, onWsShellDisconnected() { if (!this.shellRunning) { return; @@ -1011,6 +1085,8 @@ export default { this.wsDisconnected = false; this.wsDisconnectedAt = null; this.wsDisconnectedDurationText = ""; + this.backendProcessExited = false; + this.backendExitCode = null; if (this.wsDisconnectTickTimer != null) { clearInterval(this.wsDisconnectTickTimer); this.wsDisconnectTickTimer = null; diff --git a/meshchatx/src/frontend/components/layout/AppShellBanners.vue b/meshchatx/src/frontend/components/layout/AppShellBanners.vue index be8d95cf..f4f15d7a 100644 --- a/meshchatx/src/frontend/components/layout/AppShellBanners.vue +++ b/meshchatx/src/frontend/components/layout/AppShellBanners.vue @@ -14,11 +14,28 @@
- {{ wsDisconnectedLabel }} +

{{ wsDisconnectedLabel }}

+
+ + +
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json index 4fe76a61..d1463f43 100644 --- a/meshchatx/src/frontend/locales/en.json +++ b/meshchatx/src/frontend/locales/en.json @@ -310,7 +310,13 @@ "loading_overlay_subtitle": "Loading your chats and settings. This usually takes a few seconds.", "emergency_mode_active": "Emergency Mode Active - Using in-memory database and restricted services.", "backend_disconnected": "Disconnected from backend", + "backend_process_stopped": "Reticulum backend stopped", "backend_reconnected": "Reconnected to backend", + "restart_backend": "Restart backend", + "restart_backend_started": "Restarting backend…", + "restart_backend_failed": "Could not restart backend", + "view_backend_logs": "View crash log", + "view_backend_logs_failed": "No crash log available", "blackhole_integration_enabled": "Blackhole Integration", "blackhole_integration_description": "Automatically blackhole identities at the Reticulum transport layer when banishing users in MeshChatX.", "announce_limits": "Announce Limits", @@ -853,6 +859,8 @@ "all_types": "All types", "no_interfaces_found": "No interfaces found", "no_interfaces_description": "Adjust your search or add a new interface.", + "fixed_mtu_hint": "Optional. If set, must be at least {min} bytes (Reticulum minimum MTU).", + "fixed_mtu_min": "Fixed MTU must be at least {min} bytes (Reticulum minimum).", "restart_required": "Restart required", "restart_description": "Reticulum MeshChatX must be restarted for any interface changes to take effect.", "restart_now": "Restart now", diff --git a/scripts/ci/ci-node-path.sh b/scripts/ci/ci-node-path.sh index eb5b3987..ffbddbb6 100644 --- a/scripts/ci/ci-node-path.sh +++ b/scripts/ci/ci-node-path.sh @@ -1,2 +1,8 @@ #!/bin/sh -export PATH="/usr/local/bin:$PATH" +# Ensure /usr/local/bin (go-task, etc.) is on PATH without shadowing actions/setup-node. +# ARM64 GitHub-hosted images may ship Node 20 in /usr/local/bin; prepending it breaks pnpm 11. +case ":${PATH}:" in + *:/usr/local/bin:*) ;; + *) PATH="${PATH}:/usr/local/bin" ;; +esac +export PATH diff --git a/scripts/ci/github-build-linux-release-assets.sh b/scripts/ci/github-build-linux-release-assets.sh index 33ed490a..e80783ed 100644 --- a/scripts/ci/github-build-linux-release-assets.sh +++ b/scripts/ci/github-build-linux-release-assets.sh @@ -10,6 +10,20 @@ cd "$ROOT" # shellcheck source=scripts/ci/ci-node-path.sh . "$(dirname "$0")/ci-node-path.sh" +require_node_min() { + _min_major="${1:-22}" + _ver="$(node -v 2>/dev/null || true)" + _major="${_ver#v}" + _major="${_major%%.*}" + if [ -z "$_major" ] || [ "$_major" -lt "$_min_major" ]; then + echo "Node.js ${_min_major}+ required (got: ${_ver:-unknown}); check PATH does not prefer /usr/local/bin over setup-node." >&2 + command -v node >&2 || true + exit 1 + fi +} + +require_node_min 24 + mkdir -p release-assets HOST_ARCH="$(uname -m)" diff --git a/scripts/patch-electron-builder-fs.cjs b/scripts/patch-electron-builder-fs.cjs index ddd0ae48..473174d2 100644 --- a/scripts/patch-electron-builder-fs.cjs +++ b/scripts/patch-electron-builder-fs.cjs @@ -45,22 +45,18 @@ function patchBinDownload(source) { } if (!next.includes("__ebElectronDownloadCacheMode")) { - const injectAfterGetRequire = - /(const get_1 = require\("@electron\/get"\);)/; + const injectAfterGetRequire = /(const get_1 = require\("@electron\/get"\);)/; if (injectAfterGetRequire.test(next)) { next = next.replace( injectAfterGetRequire, `$1 -const __ebElectronDownloadCacheMode = get_1.ElectronDownloadCacheMode ?? ${JSON.stringify(cacheModeFallback)};`, +const __ebElectronDownloadCacheMode = get_1.ElectronDownloadCacheMode ?? ${JSON.stringify(cacheModeFallback)};` ); next = next.replace( /get_1\.ElectronDownloadCacheMode\.ReadWrite/g, - "__ebElectronDownloadCacheMode.ReadWrite", - ); - next = next.replace( - /in get_1\.ElectronDownloadCacheMode/g, - "in __ebElectronDownloadCacheMode", + "__ebElectronDownloadCacheMode.ReadWrite" ); + next = next.replace(/in get_1\.ElectronDownloadCacheMode/g, "in __ebElectronDownloadCacheMode"); changed = true; } } diff --git a/tests/electron/backendProcess.test.js b/tests/electron/backendProcess.test.js new file mode 100644 index 00000000..351da891 --- /dev/null +++ b/tests/electron/backendProcess.test.js @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createBackendProcessManager } from "../../electron/backendProcess.js"; + +function createFakeChildProcess() { + const { EventEmitter } = require("node:events"); + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stdout.setEncoding = () => {}; + proc.stderr = new EventEmitter(); + proc.stderr.setEncoding = () => {}; + proc.pid = 4242; + proc.exitCode = null; + proc.signalCode = null; + return proc; +} + +describe("electron/backendProcess", () => { + let fakeProc; + let spawnMock; + + beforeEach(() => { + fakeProc = createFakeChildProcess(); + spawnMock = vi.fn(() => fakeProc); + }); + + it("keeps the shell open and notifies renderer when backend exits during app use", async () => { + const notifyRenderer = vi.fn(); + const showCrashPage = vi.fn(); + const manager = createBackendProcessManager({ + log: vi.fn(), + getDefaultStorageDir: () => "/tmp/storage", + getDefaultReticulumConfigDir: () => "/tmp/reticulum", + getMainWindowPageKind: () => "app", + isQuiting: () => false, + notifyRenderer, + showCrashPage, + spawn: spawnMock, + }); + + manager.setUserProvidedArguments([]); + await manager.spawnBackend("/tmp/ReticulumMeshChatX", { backend: { ok: true, issues: [] } }); + + fakeProc.emit("exit", 255); + await new Promise((resolve) => setImmediate(resolve)); + + expect(notifyRenderer).toHaveBeenCalledWith("backend-process-exited", expect.objectContaining({ code: 255 })); + expect(showCrashPage).not.toHaveBeenCalled(); + expect(manager.getRuntimeState().running).toBe(false); + expect(manager.getRuntimeState().lastExitCode).toBe(255); + }); + + it("opens the crash page when backend exits outside the main shell", async () => { + const notifyRenderer = vi.fn(); + const showCrashPage = vi.fn(); + const manager = createBackendProcessManager({ + log: vi.fn(), + getDefaultStorageDir: () => "/tmp/storage", + getDefaultReticulumConfigDir: () => "/tmp/reticulum", + getMainWindowPageKind: () => "other", + isQuiting: () => false, + notifyRenderer, + showCrashPage, + spawn: spawnMock, + }); + + manager.setUserProvidedArguments([]); + await manager.spawnBackend("/tmp/ReticulumMeshChatX", { backend: { ok: true, issues: [] } }); + fakeProc.emit("exit", 1); + await new Promise((resolve) => setImmediate(resolve)); + + expect(notifyRenderer).toHaveBeenCalled(); + expect(showCrashPage).toHaveBeenCalledWith(expect.objectContaining({ code: 1 })); + }); +}); diff --git a/tests/electron/preload.test.js b/tests/electron/preload.test.js index ccb77117..dac2a827 100644 --- a/tests/electron/preload.test.js +++ b/tests/electron/preload.test.js @@ -69,6 +69,26 @@ describe("electron/preload", () => { expect(cb).toHaveBeenCalledWith("rns://x"); }); + it("exposes backend recovery IPC helpers", async () => { + const exposeInMainWorld = vi.fn(); + const invoke = vi.fn(); + const on = vi.fn(); + loadPreloadWithElectronMock({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on }, + }); + const api = exposeInMainWorld.mock.calls[0][1]; + invoke.mockResolvedValueOnce({ ok: true }); + await expect(api.restartBackend()).resolves.toEqual({ ok: true }); + expect(invoke).toHaveBeenCalledWith("restart-backend"); + + const cb = vi.fn(); + api.onBackendProcessExited(cb); + const handler = on.mock.calls.find((c) => c[0] === "backend-process-exited")?.[1]; + handler({}, { code: 255 }); + expect(cb).toHaveBeenCalledWith({ code: 255 }); + }); + it("subscribes to log channel on load", () => { const exposeInMainWorld = vi.fn(); const on = vi.fn();