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"
/>
{{ wsDisconnectedLabel }}
+