mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
1262 lines
40 KiB
JavaScript
1262 lines
40 KiB
JavaScript
const {
|
|
app,
|
|
BrowserWindow,
|
|
dialog,
|
|
ipcMain,
|
|
shell,
|
|
systemPreferences,
|
|
Tray,
|
|
Menu,
|
|
Notification,
|
|
powerSaveBlocker,
|
|
powerMonitor,
|
|
session,
|
|
clipboard,
|
|
} = require("electron");
|
|
const electronPrompt = require("electron-prompt");
|
|
const fs = require("fs");
|
|
const path = require("node:path");
|
|
|
|
const { createBackendProcessManager } = require("./backendProcess");
|
|
const { readPackagedAppVersion } = require("./appVersion");
|
|
const { getCrashRecoveryInfo } = require("./offlineRecovery");
|
|
const {
|
|
getUserProvidedArguments,
|
|
resolvePortableStorageRoots,
|
|
formatRenderProcessGoneDetails,
|
|
isLocalBackendUrl,
|
|
shouldOpenInElectronWindow,
|
|
shouldAllowInWindowNavigation,
|
|
isTrustedIpcEvent,
|
|
} = require("./mainHelpers");
|
|
const { isAllowedShellPath } = require("./shellPathGuard");
|
|
const { normalizeExternalUrlForOpen } = require("./safeExternalUrl");
|
|
const { attachHardwareDevicePermissionHandlers } = require("./hardwareDevicePermissions");
|
|
const {
|
|
loadCloseSettings,
|
|
saveCloseSettings,
|
|
resolveCloseAction,
|
|
rememberedCloseSettings,
|
|
createCloseRequestGuard,
|
|
} = require("./closeBehavior");
|
|
const {
|
|
loadDesktopPrivacySettings,
|
|
saveDesktopPrivacySettings,
|
|
isScreenSecurityPlatformSupported,
|
|
applyContentProtection,
|
|
applyContentProtectionToWindows,
|
|
} = require("./desktopPrivacySettings");
|
|
const { getLogsDir } = require("./backendCrashReport");
|
|
const { installBrokenPipeGuards, createMainProcessLogger } = require("./safeConsole");
|
|
|
|
function resolvePreloadScriptPath() {
|
|
const bundled = path.join(__dirname, "preload.bundle.js");
|
|
if (fs.existsSync(bundled)) {
|
|
return bundled;
|
|
}
|
|
return path.join(__dirname, "preload.js");
|
|
}
|
|
|
|
installBrokenPipeGuards(process);
|
|
|
|
const mainProcessLogger = createMainProcessLogger({
|
|
getLogsDir: () => getLogsDir(getDefaultStorageDir()),
|
|
});
|
|
|
|
// remember main window
|
|
var mainWindow = null;
|
|
var closeSettings = null;
|
|
var desktopPrivacySettings = null;
|
|
var closeRequestGuard = createCloseRequestGuard();
|
|
|
|
function getDialogParentWindow() {
|
|
const focused = BrowserWindow.getFocusedWindow();
|
|
if (focused && !focused.isDestroyed()) {
|
|
return focused;
|
|
}
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
return mainWindow;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// tray instance
|
|
var tray = null;
|
|
|
|
// power save blocker id
|
|
var activePowerSaveBlockerId = null;
|
|
|
|
// track if we are actually quiting
|
|
var isQuiting = false;
|
|
|
|
// backend child process (managed by backendProcess.js)
|
|
var backendManager = 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();
|
|
mainProcessLogger.write("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();
|
|
}
|
|
|
|
if (process.platform === "linux") {
|
|
app.setName("reticulum-meshchatx");
|
|
}
|
|
|
|
// Detect if running in Flatpak sandbox
|
|
const isRunningInFlatpak = !!process.env.FLATPAK_ID;
|
|
if (isRunningInFlatpak) {
|
|
mainProcessLogger.write(`Running in Flatpak sandbox: ${process.env.FLATPAK_ID}`);
|
|
}
|
|
|
|
// 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 trustedIpcHandle(channel, listener) {
|
|
ipcMain.handle(channel, async (event, ...args) => {
|
|
if (!isTrustedIpcEvent(event)) {
|
|
throw new Error("MeshChatX IPC blocked for this origin");
|
|
}
|
|
return listener(event, ...args);
|
|
});
|
|
}
|
|
|
|
// allow fetching app version via ipc
|
|
trustedIpcHandle("app-version", () => {
|
|
return readPackagedAppVersion(app.getVersion());
|
|
});
|
|
|
|
// allow fetching hardware acceleration status via ipc
|
|
trustedIpcHandle("is-hardware-acceleration-enabled", () => {
|
|
return app.isHardwareAccelerationEnabled();
|
|
});
|
|
|
|
// allow fetching integrity status
|
|
trustedIpcHandle("get-integrity-status", () => {
|
|
return integrityStatus;
|
|
});
|
|
|
|
// Native Notification IPC
|
|
const {
|
|
trackMessageNotification,
|
|
untrackMessageNotification,
|
|
closeMessageNotificationsFor,
|
|
closeAllMessageNotifications,
|
|
} = require("./messageNotifications.js");
|
|
|
|
trustedIpcHandle("show-notification", (event, { title, body, silent, destinationHash }) => {
|
|
const notification = new Notification({
|
|
title: title,
|
|
body: body,
|
|
silent: silent,
|
|
});
|
|
trackMessageNotification(destinationHash, notification);
|
|
notification.on("close", () => {
|
|
untrackMessageNotification(destinationHash, notification);
|
|
});
|
|
notification.show();
|
|
|
|
notification.on("click", () => {
|
|
if (mainWindow) {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
});
|
|
});
|
|
|
|
trustedIpcHandle("close-message-notifications", (_event, destinationHash) => {
|
|
if (destinationHash) {
|
|
return closeMessageNotificationsFor(destinationHash);
|
|
}
|
|
return closeAllMessageNotifications();
|
|
});
|
|
|
|
// Power Management IPC
|
|
trustedIpcHandle("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");
|
|
|
|
trustedIpcHandle("backend-http-only", () => {
|
|
return getUserProvidedArguments(process.argv).includes("--no-https");
|
|
});
|
|
|
|
trustedIpcHandle("backend-runtime-state", () => {
|
|
return getBackendManager().getRuntimeState();
|
|
});
|
|
|
|
trustedIpcHandle("backend-startup-diagnostics", () => {
|
|
return getBackendManager().getStartupDiagnostics();
|
|
});
|
|
|
|
trustedIpcHandle("mark-backend-healthy", () => {
|
|
getBackendManager().markBackendHealthy();
|
|
return { ok: true };
|
|
});
|
|
|
|
trustedIpcHandle("restart-backend", async () => {
|
|
return await getBackendManager().restartBackend(integrityStatus);
|
|
});
|
|
|
|
trustedIpcHandle("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 };
|
|
});
|
|
|
|
trustedIpcHandle("crash-recovery-info", () => {
|
|
const manager = getBackendManager();
|
|
const lastCrash = manager.getLastCrash() || {};
|
|
const logs = manager.getJoinedLogs();
|
|
return getCrashRecoveryInfo({
|
|
storageDir: getDefaultStorageDir(),
|
|
reticulumConfigDir: getDefaultReticulumConfigDir(),
|
|
platform: process.platform,
|
|
portableExecutableDir: process.env.PORTABLE_EXECUTABLE_DIR || null,
|
|
stderr: lastCrash.stderr || logs.stderr || "",
|
|
stdout: lastCrash.stdout || logs.stdout || "",
|
|
exitCode: lastCrash.code != null ? lastCrash.code : null,
|
|
});
|
|
});
|
|
|
|
trustedIpcHandle("restore-database-backup", async (_event, backupPath) => {
|
|
if (!backupPath || typeof backupPath !== "string") {
|
|
return { ok: false, error: "No backup path provided." };
|
|
}
|
|
const ctx = {
|
|
app,
|
|
getDefaultStorageDir,
|
|
getDefaultReticulumConfigDir,
|
|
getUserProvidedArguments,
|
|
};
|
|
const { isAllowedShellPath } = require("./shellPathGuard");
|
|
if (!isAllowedShellPath(backupPath, ctx)) {
|
|
return { ok: false, error: "Backup path is not allowed." };
|
|
}
|
|
if (!fs.existsSync(backupPath)) {
|
|
return { ok: false, error: "Backup file was not found." };
|
|
}
|
|
return await getBackendManager().runMaintenanceTask(["--restore-db", backupPath]);
|
|
});
|
|
|
|
trustedIpcHandle("pick-database-backup", async () => {
|
|
const win = getDialogParentWindow();
|
|
if (!win) {
|
|
return null;
|
|
}
|
|
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
|
|
properties: ["openFile"],
|
|
filters: [{ name: "MeshChatX backup", extensions: ["zip"] }],
|
|
});
|
|
if (canceled || !filePaths || filePaths.length === 0) {
|
|
return null;
|
|
}
|
|
return filePaths[0];
|
|
});
|
|
|
|
// add support for showing an alert window via ipc
|
|
trustedIpcHandle("alert", async (event, message) => {
|
|
return await dialog.showMessageBox(mainWindow, {
|
|
message: message,
|
|
});
|
|
});
|
|
|
|
// add support for showing a confirm window via ipc
|
|
trustedIpcHandle("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
|
|
trustedIpcHandle("prompt", async (event, message, defaultValue = "") => {
|
|
return await electronPrompt({
|
|
title: message,
|
|
label: "",
|
|
value: defaultValue == null ? "" : String(defaultValue),
|
|
type: "input",
|
|
inputAttrs: {
|
|
type: "text",
|
|
},
|
|
});
|
|
});
|
|
|
|
// allow relaunching app via ipc
|
|
trustedIpcHandle("relaunch", () => {
|
|
const relaunchOptions = {};
|
|
if (!process.defaultApp && process.platform === "linux" && process.env.APPIMAGE) {
|
|
relaunchOptions.execPath = process.env.APPIMAGE;
|
|
}
|
|
app.relaunch(relaunchOptions);
|
|
isQuiting = true;
|
|
quit();
|
|
});
|
|
|
|
trustedIpcHandle("relaunch-emergency", () => {
|
|
const relaunchOptions = {
|
|
args: process.argv.slice(1).concat(["--emergency"]),
|
|
};
|
|
if (!process.defaultApp && process.platform === "linux" && process.env.APPIMAGE) {
|
|
relaunchOptions.execPath = process.env.APPIMAGE;
|
|
}
|
|
app.relaunch(relaunchOptions);
|
|
isQuiting = true;
|
|
quit();
|
|
});
|
|
|
|
trustedIpcHandle("relaunch-auto-recover", () => {
|
|
const relaunchOptions = {
|
|
args: process.argv.slice(1).concat(["--auto-recover"]),
|
|
};
|
|
if (!process.defaultApp && process.platform === "linux" && process.env.APPIMAGE) {
|
|
relaunchOptions.execPath = process.env.APPIMAGE;
|
|
}
|
|
app.relaunch(relaunchOptions);
|
|
isQuiting = true;
|
|
quit();
|
|
});
|
|
|
|
trustedIpcHandle("shutdown", () => {
|
|
isQuiting = true;
|
|
quit();
|
|
});
|
|
|
|
trustedIpcHandle("get-close-settings", () => {
|
|
return getCloseSettings();
|
|
});
|
|
|
|
trustedIpcHandle("set-close-settings", (_event, partial) => {
|
|
return updateCloseSettings(partial || {});
|
|
});
|
|
|
|
trustedIpcHandle("get-screen-security-settings", () => {
|
|
return getScreenSecuritySettingsPayload();
|
|
});
|
|
|
|
trustedIpcHandle("set-screen-security-enabled", (_event, enabled) => {
|
|
return updateScreenSecurityEnabled(enabled === true);
|
|
});
|
|
|
|
trustedIpcHandle("get-memory-usage", async () => {
|
|
return process.getProcessMemoryInfo();
|
|
});
|
|
|
|
trustedIpcHandle("get-battery-status", async () => {
|
|
let onBattery = null;
|
|
try {
|
|
if (typeof powerMonitor?.isOnBatteryPower === "function") {
|
|
onBattery = Boolean(powerMonitor.isOnBatteryPower());
|
|
}
|
|
} catch {
|
|
onBattery = null;
|
|
}
|
|
|
|
let level = null;
|
|
// Linux sysfs: common laptop BAT0/BAT1 capacity files.
|
|
if (process.platform === "linux") {
|
|
try {
|
|
const powerSupplyDir = "/sys/class/power_supply";
|
|
if (fs.existsSync(powerSupplyDir)) {
|
|
const entries = fs.readdirSync(powerSupplyDir);
|
|
for (const name of entries) {
|
|
if (!/^BAT\d+$/i.test(name) && name.toUpperCase() !== "BATTERY") {
|
|
continue;
|
|
}
|
|
const capacityPath = path.join(powerSupplyDir, name, "capacity");
|
|
if (!fs.existsSync(capacityPath)) {
|
|
continue;
|
|
}
|
|
const raw = fs.readFileSync(capacityPath, "utf8").trim();
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 100) {
|
|
level = parsed;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
level = null;
|
|
}
|
|
}
|
|
|
|
if (level == null && onBattery == null) {
|
|
return null;
|
|
}
|
|
return {
|
|
level,
|
|
charging: onBattery == null ? null : !onBattery,
|
|
on_battery: onBattery,
|
|
source: "electron",
|
|
};
|
|
});
|
|
|
|
// allow showing a file path in os file manager
|
|
trustedIpcHandle("showPathInFolder", (event, targetPath) => {
|
|
const ctx = {
|
|
app,
|
|
getDefaultStorageDir,
|
|
getDefaultReticulumConfigDir,
|
|
getUserProvidedArguments,
|
|
};
|
|
if (!isAllowedShellPath(targetPath, ctx)) {
|
|
console.warn("showPathInFolder denied (path outside allowed directories)");
|
|
return;
|
|
}
|
|
shell.showItemInFolder(targetPath);
|
|
});
|
|
|
|
trustedIpcHandle("open-path", (event, targetPath) => {
|
|
const ctx = {
|
|
app,
|
|
getDefaultStorageDir,
|
|
getDefaultReticulumConfigDir,
|
|
getUserProvidedArguments,
|
|
};
|
|
if (!isAllowedShellPath(targetPath, ctx)) {
|
|
console.warn("open-path denied (path outside allowed directories)");
|
|
return "Path is not allowed";
|
|
}
|
|
return shell.openPath(targetPath);
|
|
});
|
|
|
|
trustedIpcHandle("pick-file", async () => {
|
|
const win = getDialogParentWindow();
|
|
if (!win) {
|
|
return null;
|
|
}
|
|
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
|
|
properties: ["openFile"],
|
|
});
|
|
if (canceled || !filePaths || filePaths.length === 0) {
|
|
return null;
|
|
}
|
|
return filePaths[0];
|
|
});
|
|
|
|
trustedIpcHandle("pick-directory", async () => {
|
|
const win = getDialogParentWindow();
|
|
if (!win) {
|
|
return null;
|
|
}
|
|
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
|
|
properties: ["openDirectory"],
|
|
});
|
|
if (canceled || !filePaths || filePaths.length === 0) {
|
|
return null;
|
|
}
|
|
return filePaths[0];
|
|
});
|
|
|
|
function getChildBrowserWindowOptions() {
|
|
return {
|
|
autoHideMenuBar: true,
|
|
webPreferences: {
|
|
preload: resolvePreloadScriptPath(),
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
enableRemoteModule: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
function handleWindowOpenRequest(url) {
|
|
if (shouldOpenInElectronWindow(url)) {
|
|
return {
|
|
action: "allow",
|
|
overrideBrowserWindowOptions: getChildBrowserWindowOptions(),
|
|
};
|
|
}
|
|
|
|
const safe = normalizeExternalUrlForOpen(url);
|
|
if (safe) {
|
|
shell.openExternal(safe);
|
|
}
|
|
return {
|
|
action: "deny",
|
|
};
|
|
}
|
|
|
|
function attachWindowOpenHandler(webContents) {
|
|
webContents.setWindowOpenHandler(({ url }) => handleWindowOpenRequest(url));
|
|
}
|
|
|
|
function denyUntrustedInWindowNavigation(event, url, openExternalIfHttp) {
|
|
if (shouldAllowInWindowNavigation(url)) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
if (!openExternalIfHttp) {
|
|
return;
|
|
}
|
|
const safe = normalizeExternalUrlForOpen(url);
|
|
if (safe) {
|
|
shell.openExternal(safe);
|
|
}
|
|
}
|
|
|
|
function attachInWindowNavigationGuard(webContents) {
|
|
webContents.on("will-navigate", (event, url) => {
|
|
denyUntrustedInWindowNavigation(event, url, true);
|
|
});
|
|
webContents.on("will-redirect", (event, url) => {
|
|
denyUntrustedInWindowNavigation(event, url, true);
|
|
});
|
|
webContents.on("will-frame-navigate", (event, url, isMainFrame) => {
|
|
denyUntrustedInWindowNavigation(event, url, isMainFrame === true);
|
|
});
|
|
webContents.on("will-attach-webview", (event) => {
|
|
event.preventDefault();
|
|
});
|
|
}
|
|
|
|
app.on("web-contents-created", (_event, contents) => {
|
|
attachWindowOpenHandler(contents);
|
|
attachInWindowNavigationGuard(contents);
|
|
});
|
|
|
|
function attachDevToolsF12Shortcut(browserWindow) {
|
|
browserWindow.webContents.on("before-input-event", (event, input) => {
|
|
if (input.type !== "keyDown" || input.key !== "F12") {
|
|
return;
|
|
}
|
|
if (browserWindow.isDestroyed()) {
|
|
return;
|
|
}
|
|
browserWindow.webContents.toggleDevTools();
|
|
event.preventDefault();
|
|
});
|
|
}
|
|
|
|
function attachDefaultContextMenu(browserWindow) {
|
|
const webContents = browserWindow.webContents;
|
|
webContents.on("context-menu", (event, params) => {
|
|
const template = [];
|
|
|
|
if (params.isEditable) {
|
|
template.push(
|
|
{ role: "undo" },
|
|
{ role: "redo" },
|
|
{ type: "separator" },
|
|
{ role: "cut" },
|
|
{ role: "copy" },
|
|
{ role: "paste" },
|
|
{ role: "pasteAndMatchStyle" },
|
|
{ type: "separator" },
|
|
{ role: "selectAll" }
|
|
);
|
|
} else if (params.selectionText) {
|
|
template.push({ role: "copy" });
|
|
}
|
|
|
|
if (params.misspelledWord) {
|
|
const suggestions = params.dictionarySuggestions || [];
|
|
if (suggestions.length > 0) {
|
|
if (template.length > 0) {
|
|
template.push({ type: "separator" });
|
|
}
|
|
for (const suggestion of suggestions) {
|
|
template.push({
|
|
label: suggestion,
|
|
click: () => {
|
|
webContents.replaceMisspelling(suggestion);
|
|
},
|
|
});
|
|
}
|
|
}
|
|
if (template.length > 0) {
|
|
template.push({ type: "separator" });
|
|
}
|
|
template.push({
|
|
label: "Add to dictionary",
|
|
click: () => {
|
|
void webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord);
|
|
},
|
|
});
|
|
}
|
|
|
|
if (params.linkURL) {
|
|
if (template.length > 0) {
|
|
template.push({ type: "separator" });
|
|
}
|
|
template.push({
|
|
label: "Open link",
|
|
click: () => {
|
|
const safe = normalizeExternalUrlForOpen(params.linkURL);
|
|
if (safe) {
|
|
shell.openExternal(safe);
|
|
}
|
|
},
|
|
});
|
|
template.push({
|
|
label: "Copy link",
|
|
click: () => {
|
|
clipboard.writeText(params.linkURL);
|
|
},
|
|
});
|
|
}
|
|
|
|
if (params.mediaType === "image" || params.hasImageContents) {
|
|
if (template.length > 0) {
|
|
template.push({ type: "separator" });
|
|
}
|
|
template.push({
|
|
label: "Copy image",
|
|
click: () => {
|
|
webContents.copyImageAt(params.x, params.y);
|
|
},
|
|
});
|
|
if (params.srcURL) {
|
|
template.push({
|
|
label: "Copy image address",
|
|
click: () => {
|
|
clipboard.writeText(params.srcURL);
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
if (template.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const menu = Menu.buildFromTemplate(template);
|
|
menu.popup({ window: browserWindow });
|
|
});
|
|
}
|
|
|
|
function log(message) {
|
|
// Durable file under storage/logs/meshchatx.log. Stdout only when a TTY
|
|
// is attached (desktop AppImage launchers usually close the pipe).
|
|
mainProcessLogger.write(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);
|
|
}
|
|
|
|
// Resolves storage and Reticulum config roots the same way on every platform:
|
|
// explicit --storage-dir/--reticulum-config-dir (flag or env) first, then
|
|
// --data-dir/MESHCHAT_DATA_DIR (portable root), then a Windows portable exe
|
|
// directory, then the user home directory. See mainHelpers.resolvePortableStorageRoots.
|
|
function getPortableStorageRoots() {
|
|
return resolvePortableStorageRoots({
|
|
argv: process.argv,
|
|
env: process.env,
|
|
homeDir: app.getPath("home"),
|
|
isWindows: process.platform === "win32",
|
|
portableExecutableDir: process.env.PORTABLE_EXECUTABLE_DIR,
|
|
});
|
|
}
|
|
|
|
function getDefaultStorageDir() {
|
|
return getPortableStorageRoots().storageDir;
|
|
}
|
|
|
|
function getDefaultReticulumConfigDir() {
|
|
return getPortableStorageRoots().reticulumConfigDir;
|
|
}
|
|
|
|
function getAppIconPath() {
|
|
const iconPath = path.join(__dirname, "build", "icon.png");
|
|
const fallbackIconPath = path.join(__dirname, "assets", "images", "logo.png");
|
|
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) : "";
|
|
const paths = getBackendManager().getStartupDiagnostics().paths;
|
|
|
|
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,
|
|
logPath: paths?.backendLogPath || "",
|
|
crashReportPath: paths?.crashReportPath || "",
|
|
},
|
|
});
|
|
}
|
|
|
|
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 getCloseSettings() {
|
|
if (!closeSettings) {
|
|
closeSettings = loadCloseSettings(getDefaultStorageDir());
|
|
}
|
|
return closeSettings;
|
|
}
|
|
|
|
function updateCloseSettings(partial) {
|
|
closeSettings = saveCloseSettings(getDefaultStorageDir(), partial);
|
|
syncTrayWithSettings();
|
|
return closeSettings;
|
|
}
|
|
|
|
function getDesktopPrivacySettings() {
|
|
if (!desktopPrivacySettings) {
|
|
desktopPrivacySettings = loadDesktopPrivacySettings(getDefaultStorageDir());
|
|
}
|
|
return desktopPrivacySettings;
|
|
}
|
|
|
|
function getScreenSecuritySettingsPayload() {
|
|
const settings = getDesktopPrivacySettings();
|
|
const platform = process.platform;
|
|
return {
|
|
platform,
|
|
available: isScreenSecurityPlatformSupported(platform),
|
|
windowsDrm: platform === "win32",
|
|
enabled: settings.screenSecurityEnabled === true,
|
|
};
|
|
}
|
|
|
|
function applyScreenSecurityToWindow(browserWindow, enabled) {
|
|
if (!applyContentProtection(browserWindow, enabled === true)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
function applyScreenSecurityToAllWindows(enabled) {
|
|
applyContentProtectionToWindows(BrowserWindow.getAllWindows(), enabled === true);
|
|
}
|
|
|
|
function updateScreenSecurityEnabled(enabled) {
|
|
desktopPrivacySettings = saveDesktopPrivacySettings(getDefaultStorageDir(), {
|
|
screenSecurityEnabled: enabled === true,
|
|
});
|
|
applyScreenSecurityToAllWindows(desktopPrivacySettings.screenSecurityEnabled);
|
|
return getScreenSecuritySettingsPayload();
|
|
}
|
|
|
|
function destroyTray() {
|
|
if (tray && !tray.isDestroyed()) {
|
|
tray.destroy();
|
|
}
|
|
tray = null;
|
|
}
|
|
|
|
function createTray() {
|
|
if (tray && !tray.isDestroyed()) {
|
|
return;
|
|
}
|
|
tray = new Tray(getAppIconPath());
|
|
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();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function syncTrayWithSettings() {
|
|
const settings = getCloseSettings();
|
|
if (settings.trayEnabled) {
|
|
createTray();
|
|
} else {
|
|
destroyTray();
|
|
}
|
|
}
|
|
|
|
async function promptCloseAction() {
|
|
const settings = getCloseSettings();
|
|
const backgroundLabel = settings.trayEnabled ? "Keep running in background" : "Minimize to taskbar";
|
|
const backgroundDetail = settings.trayEnabled
|
|
? "Hide the window and keep MeshChatX in the system tray."
|
|
: "Minimize MeshChatX to the taskbar.";
|
|
const result = await dialog.showMessageBox(getDialogParentWindow() || undefined, {
|
|
type: "question",
|
|
title: "Close MeshChatX?",
|
|
message: "Close MeshChatX?",
|
|
detail: `Choose whether to quit the application or keep it running.\n\n${backgroundDetail}`,
|
|
buttons: ["Cancel", "Quit application", backgroundLabel],
|
|
defaultId: 2,
|
|
cancelId: 0,
|
|
checkboxLabel: "Remember my choice",
|
|
checkboxChecked: false,
|
|
});
|
|
if (result.response === 0) {
|
|
return null;
|
|
}
|
|
const action = result.response === 1 ? "quit" : settings.trayEnabled ? "background" : "minimize";
|
|
const remembered = rememberedCloseSettings(action, result.checkboxChecked);
|
|
if (remembered) {
|
|
updateCloseSettings(remembered);
|
|
}
|
|
return action;
|
|
}
|
|
|
|
async function handleWindowCloseRequest(event) {
|
|
if (isQuiting) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
if (!closeRequestGuard.tryEnter()) {
|
|
return;
|
|
}
|
|
try {
|
|
const settings = getCloseSettings();
|
|
let action = resolveCloseAction(settings);
|
|
if (action === "ask") {
|
|
action = await promptCloseAction();
|
|
if (!action) {
|
|
return;
|
|
}
|
|
}
|
|
if (action === "quit") {
|
|
isQuiting = true;
|
|
quit();
|
|
return;
|
|
}
|
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
return;
|
|
}
|
|
if (action === "minimize") {
|
|
mainWindow.minimize();
|
|
return;
|
|
}
|
|
mainWindow.hide();
|
|
} finally {
|
|
closeRequestGuard.leave();
|
|
}
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
app.on("browser-window-created", (event, browserWindow) => {
|
|
attachDefaultContextMenu(browserWindow);
|
|
attachDevToolsF12Shortcut(browserWindow);
|
|
const privacy = getDesktopPrivacySettings();
|
|
applyScreenSecurityToWindow(browserWindow, privacy.screenSecurityEnabled === true);
|
|
});
|
|
|
|
attachHardwareDevicePermissionHandlers(session.defaultSession, {
|
|
dialog,
|
|
getParentWindow: getDialogParentWindow,
|
|
});
|
|
|
|
// 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' 'wasm-unsafe-eval' blob:",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://*.cartocdn.com https://tiles.openfreemap.org https://*.openfreemap.org",
|
|
"font-src 'self' data: https://tiles.openfreemap.org https://*.openfreemap.org",
|
|
"connect-src 'self' http://127.0.0.1:9337 https://127.0.0.1:9337 http://localhost:9337 https://localhost:9337 ws://127.0.0.1:* wss://127.0.0.1:* ws://localhost:* wss://localhost:* blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://nominatim.openstreetmap.org https://github.com https://*.cartocdn.com https://tiles.openfreemap.org https://*.openfreemap.org",
|
|
"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 });
|
|
});
|
|
|
|
// Keep UI downloads under Downloads/MeshChatX so AppContainer ACLs and
|
|
// reveal-in-folder stay on an app-owned exchange dir, not all of Downloads.
|
|
try {
|
|
const exchangeDownloads = path.join(app.getPath("downloads"), "MeshChatX");
|
|
fs.mkdirSync(exchangeDownloads, { recursive: true });
|
|
session.defaultSession.setDownloadPath(exchangeDownloads);
|
|
log(`Download path set to ${exchangeDownloads}`);
|
|
} catch (error) {
|
|
log(`Failed to set MeshChatX download path: ${error && error.message ? error.message : error}`);
|
|
}
|
|
|
|
// Log Hardware Acceleration status (New in Electron 39)
|
|
const isHardwareAccelerationEnabled = app.isHardwareAccelerationEnabled();
|
|
log(`Hardware Acceleration Enabled: ${isHardwareAccelerationEnabled}`);
|
|
|
|
// Create system tray when enabled in desktop close settings
|
|
syncTrayWithSettings();
|
|
|
|
// get arguments passed to application, and remove the provided application path
|
|
const userProvidedArguments = getUserProvidedArguments(process.argv);
|
|
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
|
|
|
|
if (!shouldLaunchHeadless) {
|
|
const appIconPath = getAppIconPath();
|
|
// create browser window
|
|
mainWindow = new BrowserWindow({
|
|
width: 1500,
|
|
height: 800,
|
|
icon: appIconPath,
|
|
autoHideMenuBar: true,
|
|
webPreferences: {
|
|
// used to inject logging over ipc
|
|
preload: resolvePreloadScriptPath(),
|
|
// 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,
|
|
},
|
|
});
|
|
mainWindow.webContents.on("render-process-gone", (_event, details) => {
|
|
log(`Renderer process crashed: ${formatRenderProcessGoneDetails(details)}`);
|
|
});
|
|
mainWindow.webContents.on("unresponsive", () => {
|
|
log("Renderer process became unresponsive.");
|
|
});
|
|
mainWindow.webContents.on(
|
|
"did-fail-load",
|
|
async (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
|
if (!isMainFrame || !isLocalBackendUrl(validatedURL)) {
|
|
return;
|
|
}
|
|
log(`Failed to load backend URL (${errorCode}): ${errorDescription} - ${validatedURL}`);
|
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
return;
|
|
}
|
|
const currentUrl = mainWindow.webContents.getURL();
|
|
if (currentUrl.includes("loading.html")) {
|
|
return;
|
|
}
|
|
try {
|
|
await mainWindow.loadFile(path.join(__dirname, "loading.html"), {
|
|
query: { startup_error: "backend_unreachable" },
|
|
});
|
|
} catch (error) {
|
|
log(`Failed to restore loading screen after backend load failure: ${error.message}`);
|
|
}
|
|
}
|
|
);
|
|
|
|
// quit / minimize / hide-to-tray based on remembered close settings
|
|
mainWindow.on("close", (event) => {
|
|
void handleWindowCloseRequest(event);
|
|
});
|
|
|
|
// 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 executable (setup.py builds ReticulumMeshChatX)
|
|
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;
|
|
|
|
const platformFolder =
|
|
process.platform === "win32" || process.platform === "win"
|
|
? "win32"
|
|
: process.platform === "darwin"
|
|
? "darwin"
|
|
: "linux";
|
|
const archSegment = (() => {
|
|
if (process.arch === "arm64") return "arm64";
|
|
if (process.arch === "ia32") return "ia32";
|
|
if (process.arch === "arm") return "armv7l";
|
|
return "x64";
|
|
})();
|
|
const packagedExtraResourceDir = path.join(resourcesPath, `${platformFolder}-${archSegment}`, exeName);
|
|
|
|
// 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/packager extraResource: copies build/exe/<platform>-<arch> to resources/<platform>-<arch>/
|
|
packagedExtraResourceDir,
|
|
// legacy 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}`);
|
|
|
|
const manager = getBackendManager();
|
|
manager.setUserProvidedArguments(userProvidedArguments);
|
|
try {
|
|
await manager.spawnBackend(exe, integrityStatus);
|
|
} catch (e) {
|
|
log(e);
|
|
}
|
|
});
|
|
|
|
app.on("render-process-gone", (_event, webContents, details) => {
|
|
const wcId = webContents ? webContents.id : "unknown";
|
|
log(`render-process-gone for webContents ${wcId}: ${formatRenderProcessGoneDetails(details)}`);
|
|
});
|
|
|
|
// Track if quit has been initiated to prevent recursion
|
|
let quitInitiated = false;
|
|
let quitTimeoutId = null;
|
|
|
|
function quit() {
|
|
if (quitInitiated) {
|
|
return;
|
|
}
|
|
quitInitiated = true;
|
|
|
|
const exeChildProcess = getBackendManager().getChildProcess();
|
|
if (!exeChildProcess) {
|
|
app.quit();
|
|
return;
|
|
}
|
|
if (exeChildProcess.exitCode !== null || exeChildProcess.signalCode !== null) {
|
|
app.quit();
|
|
return;
|
|
}
|
|
try {
|
|
getBackendManager().killChild("SIGTERM");
|
|
} catch (e) {
|
|
log(e);
|
|
try {
|
|
getBackendManager().killChild("SIGKILL");
|
|
} catch (e2) {
|
|
log(e2);
|
|
}
|
|
app.quit();
|
|
return;
|
|
}
|
|
const timeoutMs = 5000;
|
|
quitTimeoutId = setTimeout(() => {
|
|
try {
|
|
const proc = getBackendManager().getChildProcess();
|
|
if (proc && proc.exitCode === null && proc.signalCode === null) {
|
|
getBackendManager().killChild("SIGKILL");
|
|
}
|
|
} catch (e) {
|
|
log(e);
|
|
}
|
|
app.quit();
|
|
}, timeoutMs);
|
|
exeChildProcess.once("exit", () => {
|
|
if (quitTimeoutId) {
|
|
clearTimeout(quitTimeoutId);
|
|
quitTimeoutId = null;
|
|
}
|
|
app.quit();
|
|
});
|
|
}
|
|
|
|
// Handle before-quit for additional cleanup
|
|
app.on("before-quit", () => {
|
|
if (!isQuiting) {
|
|
isQuiting = true;
|
|
}
|
|
// Ensure tray is destroyed to prevent it from keeping the app alive
|
|
destroyTray();
|
|
});
|
|
|
|
// quit electron if all windows are closed
|
|
app.on("window-all-closed", () => {
|
|
quit();
|
|
});
|