Files
MeshChatX/electron/main.js
Sudo-Ivan eff722ee18
Some checks failed
CI / test-backend (push) Successful in 4s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 10s
CI / lint (push) Successful in 9m42s
CI / build-frontend (push) Successful in 9m49s
CI / test-lang (push) Successful in 9m45s
Tests / test (push) Successful in 13m18s
Build Test / Build and Test (push) Failing after 32m38s
Update backend process spawning in Electron by adding error handling for failed process initiation and allow overriding the Python command in the build script for cross-platform compatibility.
2026-01-10 18:35:43 -06:00

644 lines
21 KiB
JavaScript

const {
app,
BrowserWindow,
dialog,
ipcMain,
shell,
systemPreferences,
Tray,
Menu,
Notification,
powerSaveBlocker,
session,
} = require("electron");
const electronPrompt = require("electron-prompt");
const { spawn } = require("child_process");
const fs = require("fs");
const path = require("node:path");
const crypto = require("crypto");
// remember main window
var mainWindow = null;
// tray instance
var tray = null;
// power save blocker id
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;
// store integrity status
var integrityStatus = {
backend: { ok: true, issues: [] },
data: { ok: true, issues: [] },
};
// Check for hardware acceleration preference in storage dir
try {
const storageDir = getDefaultStorageDir();
const disableGpuFile = path.join(storageDir, "disable-gpu");
if (fs.existsSync(disableGpuFile)) {
app.disableHardwareAcceleration();
console.log("Hardware acceleration disabled via storage flag.");
}
} catch {
// ignore errors reading storage dir this early
}
// Handle hardware acceleration disabling via CLI
if (process.argv.includes("--disable-gpu") || process.argv.includes("--disable-software-rasterizer")) {
app.disableHardwareAcceleration();
}
// Protocol registration
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient("lxmf", process.execPath, [path.resolve(process.argv[1])]);
app.setAsDefaultProtocolClient("rns", process.execPath, [path.resolve(process.argv[1])]);
}
} else {
app.setAsDefaultProtocolClient("lxmf");
app.setAsDefaultProtocolClient("rns");
}
// Single instance lock
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on("second-instance", (event, commandLine) => {
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
// Handle protocol links from second instance
const url = commandLine.pop();
if (url && (url.startsWith("lxmf://") || url.startsWith("rns://"))) {
mainWindow.webContents.send("open-protocol-link", url);
}
}
});
}
// Handle protocol links on macOS
app.on("open-url", (event, url) => {
event.preventDefault();
if (mainWindow) {
mainWindow.show();
mainWindow.webContents.send("open-protocol-link", url);
}
});
function verifyBackendIntegrity(exeDir) {
const manifestPath = path.join(exeDir, "backend-manifest.json");
if (!fs.existsSync(manifestPath)) {
log("Backend integrity manifest missing, skipping check.");
return { ok: true, issues: ["Manifest missing"] };
}
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const issues = [];
const filesToVerify = manifest.files || manifest;
const metadata = manifest._metadata || {};
// The exeDir is build/exe when running or unpacked
// we only care about files in the manifest
for (const [relPath, expectedHash] of Object.entries(filesToVerify)) {
const fullPath = path.join(exeDir, relPath);
if (!fs.existsSync(fullPath)) {
issues.push(`Missing: ${relPath}`);
continue;
}
const fileBuffer = fs.readFileSync(fullPath);
const actualHash = crypto.createHash("sha256").update(fileBuffer).digest("hex");
if (actualHash !== expectedHash) {
issues.push(`Modified: ${relPath}`);
}
}
if (issues.length > 0 && metadata.date && metadata.time) {
issues.unshift(`Backend build timestamp: ${metadata.date} ${metadata.time}`);
}
return {
ok: issues.length === 0,
issues: issues,
};
} catch (error) {
log(`Backend integrity check failed: ${error.message}`);
return { ok: false, issues: [error.message] };
}
}
// allow fetching app version via ipc
ipcMain.handle("app-version", () => {
return app.getVersion();
});
// allow fetching hardware acceleration status via ipc
ipcMain.handle("is-hardware-acceleration-enabled", () => {
return app.isHardwareAccelerationEnabled();
});
// allow fetching integrity status
ipcMain.handle("get-integrity-status", () => {
return integrityStatus;
});
// Native Notification IPC
ipcMain.handle("show-notification", (event, { title, body, silent }) => {
const notification = new Notification({
title: title,
body: body,
silent: silent,
});
notification.show();
notification.on("click", () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
});
});
// Power Management IPC
ipcMain.handle("set-power-save-blocker", (event, enabled) => {
if (enabled) {
if (activePowerSaveBlockerId === null) {
activePowerSaveBlockerId = powerSaveBlocker.start("prevent-app-suspension");
log("Power save blocker started.");
}
} else {
if (activePowerSaveBlockerId !== null) {
powerSaveBlocker.stop(activePowerSaveBlockerId);
activePowerSaveBlockerId = null;
log("Power save blocker stopped.");
}
}
return activePowerSaveBlockerId !== null;
});
// ignore ssl errors
app.commandLine.appendSwitch("ignore-certificate-errors");
// add support for showing an alert window via ipc
ipcMain.handle("alert", async (event, message) => {
return await dialog.showMessageBox(mainWindow, {
message: message,
});
});
// add support for showing a confirm window via ipc
ipcMain.handle("confirm", async (event, message) => {
// show confirm dialog
const result = await dialog.showMessageBox(mainWindow, {
type: "question",
title: "Confirm",
message: message,
cancelId: 0, // esc key should press cancel button
defaultId: 1, // enter key should press ok button
buttons: [
"Cancel", // 0
"OK", // 1
],
});
// check if user clicked OK
return result.response === 1;
});
// add support for showing a prompt window via ipc
ipcMain.handle("prompt", async (event, message) => {
return await electronPrompt({
title: message,
label: "",
value: "",
type: "input",
inputAttrs: {
type: "text",
},
});
});
// allow relaunching app via ipc
ipcMain.handle("relaunch", () => {
app.relaunch();
app.exit();
});
ipcMain.handle("relaunch-emergency", () => {
app.relaunch({ args: process.argv.slice(1).concat(["--emergency"]) });
app.exit();
});
ipcMain.handle("shutdown", () => {
quit();
});
ipcMain.handle("get-memory-usage", async () => {
return process.getProcessMemoryInfo();
});
// allow showing a file path in os file manager
ipcMain.handle("showPathInFolder", (event, path) => {
shell.showItemInFolder(path);
});
function log(message) {
// log to stdout of this process
console.log(message);
// make sure main window exists
if (!mainWindow) {
return;
}
// make sure window is not destroyed
if (mainWindow.isDestroyed()) {
return;
}
// log to web console
mainWindow.webContents.send("log", message);
}
function getDefaultStorageDir() {
// if we are running a windows portable exe, we want to use .reticulum-meshchat in the portable exe dir
// e.g if we launch "E:\Some\Path\MeshChat.exe" we want to use "E:\Some\Path\.reticulum-meshchat"
const portableExecutableDir = process.env.PORTABLE_EXECUTABLE_DIR;
if (process.platform === "win32" && portableExecutableDir != null) {
return path.join(portableExecutableDir, ".reticulum-meshchat");
}
// otherwise, we will fall back to putting the storage dir in the users home directory
// e.g: ~/.reticulum-meshchat
return path.join(app.getPath("home"), ".reticulum-meshchat");
}
function getDefaultReticulumConfigDir() {
// if we are running a windows portable exe, we want to use .reticulum in the portable exe dir
// e.g if we launch "E:\Some\Path\MeshChat.exe" we want to use "E:\Some\Path\.reticulum"
const portableExecutableDir = process.env.PORTABLE_EXECUTABLE_DIR;
if (process.platform === "win32" && portableExecutableDir != null) {
return path.join(portableExecutableDir, ".reticulum");
}
// otherwise, we will fall back to using the .reticulum folder in the users home directory
// e.g: ~/.reticulum
return path.join(app.getPath("home"), ".reticulum");
}
function createTray() {
const iconPath = path.join(__dirname, "build", "icon.png");
const fallbackIconPath = path.join(__dirname, "assets", "images", "logo.png");
const trayIcon = fs.existsSync(iconPath) ? iconPath : fallbackIconPath;
tray = new Tray(trayIcon);
const contextMenu = Menu.buildFromTemplate([
{
label: "Show App",
click: function () {
if (mainWindow) {
mainWindow.show();
}
},
},
{
label: "Quit",
click: function () {
isQuiting = true;
quit();
},
},
]);
tray.setToolTip("Reticulum MeshChatX");
tray.setContextMenu(contextMenu);
tray.on("click", () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
}
});
}
app.whenReady().then(async () => {
// Security: Enforce CSP for all requests as a shell-level fallback
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const responseHeaders = { ...details.responseHeaders };
// Define a robust fallback CSP that matches our backend's policy
const fallbackCsp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://*.cartocdn.com",
"font-src 'self' data:",
"connect-src 'self' http://localhost:9337 https://localhost:9337 ws://localhost:* wss://localhost:* blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://nominatim.openstreetmap.org https://git.quad4.io https://*.cartocdn.com",
"media-src 'self' blob:",
"worker-src 'self' blob:",
"frame-src 'self'",
"object-src 'none'",
"base-uri 'self'",
].join("; ");
// If the response doesn't already have a CSP, apply our fallback
if (!responseHeaders["Content-Security-Policy"] && !responseHeaders["content-security-policy"]) {
responseHeaders["Content-Security-Policy"] = [fallbackCsp];
}
callback({ responseHeaders });
});
// Log Hardware Acceleration status (New in Electron 39)
const isHardwareAccelerationEnabled = app.isHardwareAccelerationEnabled();
log(`Hardware Acceleration Enabled: ${isHardwareAccelerationEnabled}`);
// Create system tray
createTray();
// get arguments passed to application, and remove the provided application path
const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"];
const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg));
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
if (!shouldLaunchHeadless) {
// create browser window
mainWindow = new BrowserWindow({
width: 1500,
height: 800,
webPreferences: {
// used to inject logging over ipc
preload: path.join(__dirname, "preload.js"),
// Security: disable node integration in renderer
nodeIntegration: false,
// Security: enable context isolation (default in Electron 12+)
contextIsolation: true,
// Security: enable sandbox for additional protection
sandbox: true,
// Security: disable remote module (deprecated but explicit)
enableRemoteModule: false,
},
});
// minimize to tray behavior
mainWindow.on("close", (event) => {
if (!isQuiting) {
event.preventDefault();
mainWindow.hide();
return false;
}
});
// open external links in default web browser instead of electron
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
var shouldShowInNewElectronWindow = false;
// we want to open call.html in a new electron window
// but all other target="_blank" links should open in the system web browser
// we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
if (
(url.startsWith("http://localhost") || url.startsWith("https://localhost")) &&
url.includes("/call.html")
) {
shouldShowInNewElectronWindow = true;
}
// we want to open blob urls in a new electron window
else if (url.startsWith("blob:")) {
shouldShowInNewElectronWindow = true;
}
// open in new electron window
if (shouldShowInNewElectronWindow) {
return {
action: "allow",
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
enableRemoteModule: false,
},
},
};
}
// fallback to opening any other url in external browser
shell.openExternal(url);
return {
action: "deny",
};
});
// navigate to loading page
await mainWindow.loadFile(path.join(__dirname, "loading.html"));
// ask mac users for microphone access for audio calls to work
if (process.platform === "darwin") {
await systemPreferences.askForMediaAccess("microphone");
}
}
// find path to python/cxfreeze reticulum meshchatx executable
// Note: setup.py creates ReticulumMeshChatX (with X), not ReticulumMeshChat
const exeName = process.platform === "win32" ? "ReticulumMeshChatX.exe" : "ReticulumMeshChatX";
// get app path (handles both development and packaged app)
const appPath = app.getAppPath();
// get resources path (where extraFiles are placed)
const resourcesPath = process.resourcesPath || path.join(appPath, "..", "..");
var exe = null;
// when packaged, extraResources are placed at resources/backend
// when packaged with extraFiles, they were at resources/app/electron/build/exe
// when packaged with asar, unpacked files are in app.asar.unpacked/ directory
// app.getAppPath() returns the path to app.asar, so unpacked is at the same level
const possiblePaths = [
// packaged app - extraResources location (resources/backend)
path.join(resourcesPath, "backend", exeName),
// electron-forge extraResource location (resources/exe)
path.join(resourcesPath, "exe", exeName),
// legacy packaged app - extraFiles location (resources/app/electron/build/exe)
path.join(resourcesPath, "app", "electron", "build", "exe", exeName),
// packaged app with asar (unpacked files from asarUnpack)
path.join(appPath, "..", "app.asar.unpacked", "build", "exe", exeName),
// packaged app without asar (relative to app path)
path.join(appPath, "build", "exe", exeName),
// development mode (relative to electron directory)
path.join(__dirname, "build", "exe", exeName),
// development mode (relative to project root)
path.join(__dirname, "..", "build", "exe", exeName),
];
// find the first path that exists
for (const possibleExe of possiblePaths) {
if (fs.existsSync(possibleExe)) {
exe = possibleExe;
break;
}
}
// verify executable exists
if (!exe || !fs.existsSync(exe)) {
const errorMsg = `Could not find executable: ${exeName}\nChecked paths:\n${possiblePaths.join("\n")}\n\nApp path: ${appPath}\nResources path: ${resourcesPath}`;
log(errorMsg);
if (mainWindow) {
await dialog.showMessageBox(mainWindow, {
message: errorMsg,
});
}
app.quit();
return;
}
log(`Found executable at: ${exe}`);
// Verify backend integrity before spawning
const exeDir = path.dirname(exe);
integrityStatus.backend = verifyBackendIntegrity(exeDir);
if (!integrityStatus.backend.ok) {
log(`INTEGRITY WARNING: Backend tampering detected! Issues: ${integrityStatus.backend.issues.join(", ")}`);
}
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", // FIXME: let system pick a random unused port?
// '--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).");
}
// 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);
});
// quit electron app if exe dies
exeChildProcess.on("exit", async function (code) {
// 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();
}
});
} catch (e) {
log(e);
}
});
function quit() {
// kill python process
if (exeChildProcess) {
exeChildProcess.kill("SIGKILL");
}
// quit electron app
app.quit();
}
// quit electron if all windows are closed
app.on("window-all-closed", () => {
quit();
});
// make sure child process is killed if app is quiting
app.on("quit", () => {
quit();
});