Files
Termix/electron/main.cjs
T

1968 lines
57 KiB
JavaScript

const {
app,
BrowserWindow,
shell,
ipcMain,
dialog,
Menu,
session,
safeStorage,
Tray,
clipboard,
nativeImage,
} = require("electron");
const path = require("path");
const { getUnpackedAppRoot } = require("./backend-paths.cjs");
const { classifyBackendFailure } = require("./backend-failure.cjs");
const fs = require("fs");
const http = require("http");
const net = require("net");
const tls = require("tls");
const zlib = require("zlib");
const crypto = require("crypto");
const { URL, pathToFileURL } = require("url");
const { fork } = require("child_process");
const linkedServer = require("./linked-server.cjs");
const desktopSettings = require("./desktop-settings.cjs");
const autoUpdates = require("./auto-updater.cjs");
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs");
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
// Saved by the file manager after the user picks an editor in a file dialog.
const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath";
const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs");
// The main process's Node.js networking (the global `fetch`) only
// trusts Node's bundled Mozilla CA list by default, not the OS/system trust
// store. Chromium (the renderer, i.e. the web app and the login iframe) uses
// the OS trust store instead, so a certificate that's valid in-browser --
// e.g. one issued by a reverse proxy's internal/corporate CA, or a system
// CA installed via Keychain/certmgr -- can still fail main-process requests
// with UNABLE_TO_VERIFY_LEAF_SIGNATURE. Merge the system store in so remote
// sync and the connection health check see the same trust as the browser.
try {
if (typeof tls.setDefaultCACertificates === "function") {
tls.setDefaultCACertificates([
...tls.getCACertificates("default"),
...tls.getCACertificates("system"),
]);
}
} catch (error) {
console.error("Failed to merge system CA certificates:", error);
}
// Portable mode: if a `.portable` marker exists next to the executable,
// store all data in a `data` folder beside the exe instead of %APPDATA%.
(function setupPortableDataDir() {
const exeDir = path.dirname(process.execPath);
const markerPath = path.join(exeDir, ".portable");
const envOverride = process.env.TERMIX_DATA_DIR;
let portableDataDir = null;
if (envOverride) {
portableDataDir = envOverride;
} else if (app.isPackaged && fs.existsSync(markerPath)) {
portableDataDir = path.join(exeDir, "data");
}
if (portableDataDir) {
try {
if (!fs.existsSync(portableDataDir)) {
fs.mkdirSync(portableDataDir, { recursive: true });
}
app.setPath("userData", portableDataDir);
} catch {
// Fall back to default userData if we can't create the directory.
}
}
})();
const logFile = path.join(app.getPath("userData"), "termix-main.log");
const electronAuthCookiesPath = path.join(
app.getPath("userData"),
"electron-auth-cookies.json",
);
const electronAuthCookies = new Map();
function logToFile(...args) {
const timestamp = new Date().toISOString();
const msg = args
.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
.join(" ");
const line = `[${timestamp}] ${msg}\n`;
try {
fs.appendFileSync(logFile, line);
} catch {
// ignore
}
console.log(...args);
}
function getCookieOrigin(url) {
try {
const parsedUrl = new URL(url);
const protocol =
parsedUrl.protocol === "ws:"
? "http:"
: parsedUrl.protocol === "wss:"
? "https:"
: parsedUrl.protocol;
return `${protocol}//${parsedUrl.host}`;
} catch {
return null;
}
}
function parseCookieTarget(url) {
try {
const parsedUrl = new URL(url);
if (parsedUrl.protocol === "ws:") {
parsedUrl.protocol = "http:";
} else if (parsedUrl.protocol === "wss:") {
parsedUrl.protocol = "https:";
}
return parsedUrl;
} catch {
return null;
}
}
function getElectronAuthCookieKey(name, origin) {
return origin ? `${origin}|${name}` : null;
}
function getSafeStorageAvailable() {
try {
return safeStorage.isEncryptionAvailable();
} catch {
return false;
}
}
function encodeElectronAuthCookieValue(value) {
return {
encrypted: true,
value: safeStorage.encryptString(value).toString("base64"),
};
}
function decodeElectronAuthCookieValue(record) {
if (!record.encrypted || !getSafeStorageAvailable()) {
return null;
}
try {
return safeStorage.decryptString(Buffer.from(record.value, "base64"));
} catch (error) {
logToFile(
"Failed to decrypt persisted Electron auth cookie:",
error.message,
);
return null;
}
}
function isElectronAuthCookieExpired(cookie) {
return Number.isFinite(cookie.expiresAt) && cookie.expiresAt <= Date.now();
}
function saveElectronAuthCookiesToDisk() {
try {
if (!getSafeStorageAvailable()) {
if (fs.existsSync(electronAuthCookiesPath)) {
fs.rmSync(electronAuthCookiesPath, { force: true });
}
return;
}
const records = [];
for (const [key, cookie] of electronAuthCookies.entries()) {
if (isElectronAuthCookieExpired(cookie)) {
electronAuthCookies.delete(key);
continue;
}
records.push({
key,
name: cookie.name,
origin: cookie.origin,
path: cookie.path,
expiresAt: cookie.expiresAt,
...encodeElectronAuthCookieValue(cookie.value),
});
}
fs.writeFileSync(
electronAuthCookiesPath,
JSON.stringify({ version: 1, records }, null, 2),
);
} catch (error) {
logToFile("Failed to persist Electron auth cookies:", error.message);
}
}
function loadElectronAuthCookiesFromDisk() {
electronAuthCookies.clear();
try {
if (!getSafeStorageAvailable()) {
if (fs.existsSync(electronAuthCookiesPath)) {
fs.rmSync(electronAuthCookiesPath, { force: true });
}
return;
}
if (!fs.existsSync(electronAuthCookiesPath)) {
return;
}
const data = JSON.parse(fs.readFileSync(electronAuthCookiesPath, "utf8"));
const records = Array.isArray(data.records) ? data.records : [];
for (const record of records) {
if (
!record ||
typeof record.key !== "string" ||
typeof record.name !== "string" ||
typeof record.origin !== "string"
) {
continue;
}
const value = decodeElectronAuthCookieValue(record);
if (!value) {
continue;
}
const cookie = {
name: record.name,
value,
origin: record.origin,
path: typeof record.path === "string" ? record.path : "/",
expiresAt: Number.isFinite(record.expiresAt) ? record.expiresAt : null,
};
if (!isElectronAuthCookieExpired(cookie)) {
electronAuthCookies.set(record.key, cookie);
}
}
saveElectronAuthCookiesToDisk();
} catch (error) {
logToFile("Failed to load persisted Electron auth cookies:", error.message);
}
}
function clearPersistedElectronAuthCookies() {
electronAuthCookies.clear();
try {
if (fs.existsSync(electronAuthCookiesPath)) {
fs.rmSync(electronAuthCookiesPath, { force: true });
}
} catch (error) {
logToFile(
"Failed to clear persisted Electron auth cookies:",
error.message,
);
}
}
function parseSetCookieHeader(header) {
const [cookiePair, ...attributes] = String(header || "").split(";");
const separatorIndex = cookiePair.indexOf("=");
if (separatorIndex <= 0) return null;
const parsed = {
name: cookiePair.slice(0, separatorIndex).trim(),
value: cookiePair.slice(separatorIndex + 1).trim(),
path: "/",
maxAge: null,
expires: null,
};
for (const attribute of attributes) {
const [rawName, ...rawValueParts] = attribute.trim().split("=");
const attrName = rawName.toLowerCase();
const attrValue = rawValueParts.join("=");
if (attrName === "path" && attrValue) {
parsed.path = attrValue;
} else if (attrName === "max-age" && attrValue) {
const maxAge = Number(attrValue);
parsed.maxAge = Number.isFinite(maxAge) ? maxAge : null;
} else if (attrName === "expires" && attrValue) {
const expires = Date.parse(attrValue);
parsed.expires = Number.isFinite(expires) ? expires : null;
}
}
return parsed;
}
function rememberElectronAuthCookieFromHeader(url, header) {
const origin = getCookieOrigin(url);
if (!origin) return;
const cookie = parseSetCookieHeader(header);
if (!cookie || cookie.name !== "jwt") return;
const key = getElectronAuthCookieKey(cookie.name, origin);
if (!key) return;
const expired =
cookie.maxAge === 0 ||
(cookie.expires !== null && cookie.expires <= Date.now());
if (expired || !cookie.value) {
electronAuthCookies.delete(key);
saveElectronAuthCookiesToDisk();
return;
}
const expiresAt =
cookie.maxAge !== null ? Date.now() + cookie.maxAge * 1000 : cookie.expires;
electronAuthCookies.set(key, {
name: cookie.name,
value: cookie.value,
origin,
path: cookie.path,
expiresAt,
});
saveElectronAuthCookiesToDisk();
}
function getRememberedElectronAuthCookie(name, targetUrl) {
const target = parseCookieTarget(targetUrl);
if (!target) return null;
const exactKey = getElectronAuthCookieKey(name, target.origin);
const exactCookie = exactKey ? electronAuthCookies.get(exactKey) : null;
if (exactCookie && !isElectronAuthCookieExpired(exactCookie)) {
return exactCookie;
}
if (target.protocol !== "https:") {
return null;
}
const httpOrigin = `http://${target.host}`;
const httpKey = getElectronAuthCookieKey(name, httpOrigin);
const httpCookie = httpKey ? electronAuthCookies.get(httpKey) : null;
return httpCookie && !isElectronAuthCookieExpired(httpCookie)
? httpCookie
: null;
}
function getHeaderName(headers, name) {
const lowerName = name.toLowerCase();
return Object.keys(headers || {}).find(
(key) => key.toLowerCase() === lowerName,
);
}
function setCookieHeaderValue(requestHeaders, name, value) {
const headerName = getHeaderName(requestHeaders, "Cookie") || "Cookie";
const existing = requestHeaders[headerName];
const existingValue = Array.isArray(existing)
? existing.join("; ")
: existing;
const nextCookie = `${name}=${value}`;
const otherCookies = String(existingValue || "")
.split(";")
.map((cookie) => cookie.trim())
.filter((cookie) => cookie && !cookie.startsWith(`${name}=`));
requestHeaders[headerName] =
otherCookies.length > 0
? `${otherCookies.join("; ")}; ${nextCookie}`
: nextCookie;
}
function parseSemver(version) {
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
}
function compareSemver(a, b) {
const parsedA = parseSemver(a);
const parsedB = parseSemver(b);
if (!parsedA || !parsedB) return null;
for (let i = 0; i < 3; i += 1) {
if (parsedA[i] > parsedB[i]) return 1;
if (parsedA[i] < parsedB[i]) return -1;
}
return 0;
}
const INSECURE_MODE_VALUES = new Set(["true", "1", "yes"]);
function isInsecureModeEnabled() {
return INSECURE_MODE_VALUES.has(
String(process.env.ENABLE_INSECURE_MODE || "")
.trim()
.toLowerCase(),
);
}
function isPrivateNetworkHost(hostname) {
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true;
}
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
);
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number);
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
);
}
if (hostname.startsWith("fc") || hostname.startsWith("fd")) {
return true;
}
return false;
}
function isInvalidCertificateAllowedForUrl(url) {
if (isInsecureModeEnabled()) return true;
try {
const { hostname } = new URL(url);
if (isPrivateNetworkHost(hostname)) return true;
} catch {
// fall through
}
// Otherwise only the server this desktop is linked to, when its link
// allows a self-signed certificate.
const linked = linkedServer.getLinkedServer();
if (!linked?.allowInvalidCertificate) return false;
return linkedServer.isLinkedOrigin(url);
}
function getTlsVerificationOptions(url) {
return {
rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url),
};
}
if (process.platform === "linux") {
app.commandLine.appendSwitch("--ozone-platform-hint=auto");
app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder");
// Fix click-coordinate misalignment with fractional display scaling on KDE/Wayland.
// Chromium's hit-testing uses unscaled coords while the compositor scales visually,
// so forcing scale factor 1 keeps them in sync. See: https://github.com/brave/brave-browser/issues/50028
app.commandLine.appendSwitch("--force-device-scale-factor", "1");
const passwordStore = selectLinuxPasswordStore(app.commandLine, process.env);
if (passwordStore) {
logToFile(
`[safeStorage] Selected the ${passwordStore} password store for this desktop.`,
);
}
}
if (process.platform === "win32" || process.env.ELECTRON_DISABLE_GPU === "1") {
app.disableHardwareAcceleration();
}
if (isInsecureModeEnabled()) {
logToFile(
"[security] ENABLE_INSECURE_MODE is enabled; TLS certificate validation is disabled.",
);
app.commandLine.appendSwitch("--ignore-certificate-errors");
app.commandLine.appendSwitch("--ignore-ssl-errors");
app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list");
}
app.commandLine.appendSwitch("--enable-features=NetworkService");
let mainWindow = null;
const { createIsolatedWindows } = require("./isolated-window.cjs");
const isolatedWindows = createIsolatedWindows({
BrowserWindow,
session,
getMainWindow: () => mainWindow,
});
// Requests from the embedded backend over the fork IPC channel (the other
// direction from the "shutdown" message main already sends it). A plugin's
// ctx.desktop.openIsolatedWindow is the one caller today.
const BACKEND_REQUEST_HANDLERS = {
"open-isolated-window": (payload) => isolatedWindows.open(payload),
// Plugins open OS clients themselves now (desktop:local), so none is wired here.
"launch-external-client": (payload) => ({
success: false,
error: `No external client for ${payload?.protocol ?? "this protocol"}`,
}),
// Generic shell actions for a plugin's desktop:local file work.
"shell-trash-item": async (payload) => {
await shell.trashItem(String(payload?.path ?? ""));
return { success: true };
},
"shell-show-item": (payload) => {
shell.showItemInFolder(String(payload?.path ?? ""));
return { success: true };
},
"shell-open-path": async (payload) => {
const error = await shell.openPath(String(payload?.path ?? ""));
return error ? { success: false, error } : { success: true };
},
"sync-proxy-config": (payload) => {
linkedServer.setLinkedServer(payload);
return { success: true };
},
};
function isDevRunner() {
return (
process.env.TERMIX_DEV_RUNNER === "true" &&
typeof process.send === "function"
);
}
async function handleBackendRequest(msg) {
if (!msg || msg.type !== "backend-request") return;
const { id, channel, payload } = msg;
const handler = BACKEND_REQUEST_HANDLERS[channel];
// Under npm run dev:electron the runner owns the backend and relays these messages.
const target =
backendProcess && !backendProcess.killed
? backendProcess
: isDevRunner()
? process
: null;
const reply = (response) => {
if (!target) return;
try {
target.send({ type: "backend-response", id, ...response });
} catch (error) {
logToFile("Failed to reply to backend request:", error.message);
}
};
if (!handler) {
reply({ ok: false, error: `Unknown backend request channel: ${channel}` });
return;
}
try {
const result = await handler(payload);
reply({ ok: true, result });
} catch (error) {
reply({ ok: false, error: error.message || String(error) });
}
}
let backendProcess = null;
let backendStartFailed = false;
// Why the embedded backend died, once it has. Null while it is healthy
// or still starting. The renderer polls this to tell "still booting"
// (keep waiting) apart from "never coming back" (show the reason).
let backendFailure = null;
// Set while we are deliberately tearing the backend down on quit, so a
// SIGKILL we sent ourselves is not reported to the user as a crash.
let backendStopRequested = false;
// Tail of the embedded backend's stderr, kept only so a failed start can be
// classified after the fact.
const BACKEND_STDERR_TAIL_LIMIT = 8192;
let backendStderrTail = "";
let tray = null;
let isQuitting = false;
const tempFiles = new Map();
// Paths the user picked in an open dialog this session. Only these may be
// saved as the external editor, so the renderer cannot name an executable.
const dialogChosenPaths = new Set();
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
const windowsAppUserModelId = "com.karmaa.termix";
const electronCacheBuildPath = path.join(
app.getPath("userData"),
"client-cache-build.json",
);
const termixSessionPartition = "persist:termix";
function getTempRoot() {
const tempRoot = path.join(app.getPath("temp"), "termix");
fs.mkdirSync(tempRoot, { recursive: true });
return tempRoot;
}
function sanitizeFileName(fileName) {
const baseName = path.basename(String(fileName || "file"));
return baseName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") || "file";
}
function decodeFileContent(content, encoding) {
if (encoding === "base64") {
return Buffer.from(String(content || ""), "base64");
}
return Buffer.from(String(content || ""), "utf8");
}
function createManagedTempFile(fileName, content, encoding = "utf8") {
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`));
const filePath = path.join(tempDir, sanitizeFileName(fileName));
fs.writeFileSync(filePath, decodeFileContent(content, encoding));
tempFiles.set(tempId, { path: filePath, dir: tempDir });
return { tempId, path: filePath };
}
function cleanupManagedTempFile(tempId) {
const temp = tempFiles.get(tempId);
if (!temp) return;
try {
fs.rmSync(temp.dir, { recursive: true, force: true });
} catch (error) {
logToFile("Failed to clean up temporary file:", error.message);
}
tempFiles.delete(tempId);
}
// Origins the renderer has explicitly marked as allowed to present an invalid
// certificate, for web endpoints whose ignoreCert is set. Session scoped and
// never persisted.
//
// Deliberately NOT wired into isInvalidCertificateAllowedForUrl: that function
// also governs this process's own outbound TLS via getTlsVerificationOptions,
// and widening it would grant more privilege than the feature needs.
//
// Entries expire rather than living until the app restarts (days, on a
// desktop app). Main has no host-database access, so it cannot verify a
// renderer's claim that an origin really is a configured web endpoint -- an
// unbounded allowance would make that an indefinite window rather than a
// momentary one. The renderer re-registers before each load, which refreshes
// the expiry, so a live endpoint keeps working while a stale entry lapses.
const WEB_ENDPOINT_CERTIFICATE_ALLOWLIST_TTL_MS = 5 * 60 * 1000;
const webEndpointCertificateAllowlist = new Map();
function isWebEndpointCertificateAllowed(url) {
try {
const parsed = new URL(url);
// The allowance exists solely to suppress a TLS certificate error, so a
// non-https origin in the map is meaningless at best.
if (parsed.protocol !== "https:") return false;
const expiresAt = webEndpointCertificateAllowlist.get(parsed.origin);
if (expiresAt === undefined) return false;
if (Date.now() >= expiresAt) {
webEndpointCertificateAllowlist.delete(parsed.origin);
return false;
}
return true;
} catch {
return false;
}
}
ipcMain.handle("allow-invalid-certificate-for-origin", (_event, origin) => {
try {
// Store the PARSED origin, never the caller's string, so a path or a
// wildcard cannot widen the allowance.
const parsed = new URL(String(origin));
if (parsed.protocol !== "https:") {
return { success: false };
}
webEndpointCertificateAllowlist.set(
parsed.origin,
Date.now() + WEB_ENDPOINT_CERTIFICATE_ALLOWLIST_TTL_MS,
);
return { success: true };
} catch {
return { success: false };
}
});
app.on(
"certificate-error",
(event, _webContents, url, error, certificate, callback) => {
if (
isolatedWindows.handleCertificateError(event, _webContents, url, callback)
)
return;
if (isWebEndpointCertificateAllowed(url)) {
event.preventDefault();
logToFile("Allowed invalid certificate for configured web endpoint", {
url,
error,
issuer: certificate?.issuerName,
subject: certificate?.subjectName,
});
callback(true);
return;
}
if (isInvalidCertificateAllowedForUrl(url)) {
event.preventDefault();
logToFile("Allowed invalid certificate for configured server", {
url,
error,
issuer: certificate?.issuerName,
subject: certificate?.subjectName,
});
callback(true);
return;
}
callback(false);
},
);
// A reverse proxy in front of the linked server may ask for basic auth; the
// link's saved username and password answer it. Anything else gets the
// default behaviour.
app.on("login", (event, _webContents, details, authInfo, callback) => {
const credentials = linkedServer.answerLogin(authInfo, details?.url);
if (!credentials) return;
event.preventDefault();
callback(credentials.username, credentials.password || "");
});
function getElectronBuildTimestamp() {
try {
const buildInfo = require("./build-info.cjs");
if (Number.isInteger(buildInfo.buildTimestamp)) {
return buildInfo.buildTimestamp;
}
} catch {
// Development runs may not have generated build metadata yet.
}
return 0;
}
async function clearElectronClientCacheIfBuildChanged() {
const buildTimestamp = getElectronBuildTimestamp();
let cacheTimestamp = 0;
try {
if (fs.existsSync(electronCacheBuildPath)) {
const data = JSON.parse(fs.readFileSync(electronCacheBuildPath, "utf8"));
cacheTimestamp = Number.isInteger(data.buildTimestamp)
? data.buildTimestamp
: 0;
}
} catch (error) {
logToFile(
"Failed to read Electron client cache build info:",
error.message,
);
}
if (cacheTimestamp === buildTimestamp) {
return;
}
const clearStep = async (label, action) => {
try {
await action();
} catch (error) {
logToFile(`Failed to clear Electron ${label}:`, error.message);
}
};
try {
const defaultSession = session.defaultSession;
await clearStep("HTTP cache", () => defaultSession.clearCache());
await clearStep("code cache", () =>
defaultSession.clearCodeCaches({ urls: [] }),
);
await clearStep("auth cache", () => defaultSession.clearAuthCache());
await clearStep("storage data", () =>
defaultSession.clearStorageData({
storages: [
"appcache",
"cookies",
"filesystem",
"shadercache",
"websql",
"serviceworkers",
"cachestorage",
],
}),
);
fs.writeFileSync(
electronCacheBuildPath,
JSON.stringify(
{
buildTimestamp,
appVersion: app.getVersion(),
updatedAt: new Date().toISOString(),
},
null,
2,
),
);
logToFile("Electron client cache cleared for build change", {
from: cacheTimestamp,
to: buildTimestamp,
appVersion: app.getVersion(),
});
} catch (error) {
logToFile("Failed to clear Electron client cache:", error.message);
}
}
function getCookieRemovalUrl(cookie) {
const scheme = cookie.secure ? "https" : "http";
const domain = cookie.domain?.startsWith(".")
? cookie.domain.slice(1)
: cookie.domain || "localhost";
return `${scheme}://${domain}${cookie.path || "/"}`;
}
async function clearElectronJwtCookiesAtStartup() {
loadElectronAuthCookiesFromDisk();
const targetSessions = new Set([
session.defaultSession,
session.fromPartition(termixSessionPartition),
]);
for (const targetSession of targetSessions) {
try {
const cookies = await targetSession.cookies.get({ name: "jwt" });
await Promise.all(
cookies.map((cookie) =>
targetSession.cookies.remove(
getCookieRemovalUrl(cookie),
cookie.name,
),
),
);
if (cookies.length > 0) {
logToFile("Cleared Electron JWT cookies from cookie store", {
count: cookies.length,
});
}
} catch (error) {
logToFile("Failed to clear Electron JWT cookies:", error.message);
}
}
}
function getBackendPaths() {
if (isDev) {
const backendDir = path.join(appRoot, "dist", "backend", "backend");
return {
entryPath: path.join(backendDir, "starter.js"),
backendCwd: backendDir,
};
}
// fork() does not go through Electron's asar redirector, use the unpacked path.
// On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR
// app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
const unpackedRoot = getUnpackedAppRoot(appRoot);
const backendDir = path.join(unpackedRoot, "dist", "backend", "backend");
return {
entryPath: path.join(backendDir, "starter.js"),
backendCwd: backendDir,
};
}
function getBackendDataDir() {
const userDataPath = app.getPath("userData");
const dataDir = path.join(userDataPath, "server-data");
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
return dataDir;
}
function getBackendPidFilePath() {
return path.join(app.getPath("userData"), "backend.pid");
}
// If the app was previously killed abnormally (crash, force-quit, Task
// Manager) rather than through the normal quit flow, will-quit never fires
// and stopBackendServer() never runs -- the forked backend child is a
// genuinely separate OS process on Windows/mac/Linux, so it keeps running
// and holding the port the backend binds (30001). Every subsequent
// launch's own backend then fails outright
// with EADDRINUSE and the app is stuck until something manually kills the
// orphan. Reap any such leftover process, identified by PID file, before
// spawning a new one.
function reapOrphanedBackendProcess() {
const pidFilePath = getBackendPidFilePath();
let recordedPid;
try {
recordedPid = parseInt(fs.readFileSync(pidFilePath, "utf8").trim(), 10);
} catch {
return;
}
if (!Number.isInteger(recordedPid) || recordedPid <= 0) return;
try {
// Signal 0 does not kill the process -- it only checks whether a
// process with this PID exists and is signalable, throwing ESRCH if
// not. This avoids killing an unrelated process that happens to have
// reused the same PID since the last run.
process.kill(recordedPid, 0);
} catch {
// No live process at that PID; nothing to reap.
try {
fs.unlinkSync(pidFilePath);
} catch {
// already absent
}
return;
}
logToFile(
`Found orphaned backend process from a previous session (pid ${recordedPid}), terminating it before starting a new one`,
);
try {
process.kill(recordedPid, "SIGKILL");
} catch {
// already gone
}
try {
fs.unlinkSync(pidFilePath);
} catch {
// already absent
}
}
const DEFAULT_BACKEND_PORT = 30001;
let backendPort = DEFAULT_BACKEND_PORT;
function canListenOn(port) {
return new Promise((resolve) => {
const probe = net.createServer();
probe.once("error", () => resolve(null));
probe.listen(port, "127.0.0.1", () => {
const { port: bound } = probe.address();
probe.close(() => resolve(bound));
});
});
}
// 30001 when it is free, otherwise any free port. Only the desktop app talks
// to this backend, so the number itself does not matter.
async function findBackendPort() {
const preferred = await canListenOn(DEFAULT_BACKEND_PORT);
if (preferred) return preferred;
const fallback = await canListenOn(0);
if (fallback) {
logToFile(
`Port ${DEFAULT_BACKEND_PORT} is in use, starting the backend on ${fallback}`,
);
return fallback;
}
return DEFAULT_BACKEND_PORT;
}
function startBackendServer() {
reapOrphanedBackendProcess();
backendFailure = null;
backendStopRequested = false;
backendStderrTail = "";
return new Promise((resolve) => {
const { entryPath, backendCwd } = getBackendPaths();
logToFile("isDev:", isDev, "appRoot:", appRoot);
logToFile("app.isPackaged:", app.isPackaged);
logToFile("process.env.NODE_ENV:", process.env.NODE_ENV);
if (!fs.existsSync(entryPath)) {
logToFile("Backend entry not found:", entryPath);
backendFailure = { reason: "crashed", port: null };
resolve(false);
return;
}
const dataDir = getBackendDataDir();
logToFile("Starting embedded backend server...");
logToFile("Backend entry:", entryPath);
logToFile("Data directory:", dataDir);
logToFile("Backend cwd:", backendCwd);
logToFile("Checking paths...");
logToFile(" entryPath exists:", fs.existsSync(entryPath));
logToFile(" dataDir exists:", fs.existsSync(dataDir));
logToFile(" backendCwd exists:", fs.existsSync(backendCwd));
backendProcess = fork(entryPath, [], {
cwd:
fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory()
? backendCwd
: dataDir,
env: {
...process.env,
DATA_DIR: dataDir,
NODE_ENV: "production",
ELECTRON_EMBEDDED: "true",
PORT: String(backendPort),
EMBEDDED_BACKEND_PORT: String(backendPort),
ELECTRON_USER_DATA: app.getPath("userData"),
VERSION: app.getVersion(),
},
stdio: ["pipe", "pipe", "pipe", "ipc"],
});
logToFile("Backend process spawned, pid:", backendProcess.pid);
try {
fs.writeFileSync(getBackendPidFilePath(), String(backendProcess.pid));
} catch {
// Non-fatal: only means a future crash won't self-heal via reap.
}
let resolved = false;
const readyTimeout = setTimeout(() => {
if (!resolved) {
resolved = true;
logToFile("Backend ready timeout (15s), proceeding anyway...");
resolve(true);
}
}, 15000);
backendProcess.stdout.on("data", (data) => {
const msg = data.toString().trim();
logToFile("[backend]", msg);
if (!resolved && msg.includes("started successfully")) {
resolved = true;
clearTimeout(readyTimeout);
logToFile("Backend ready signal received");
resolve(true);
}
});
// The reason for a failed start is only ever visible in what the child
// wrote to stderr before dying, so keep a bounded tail of it. Bounded
// because a backend that stays up for days can otherwise log without
// limit into a buffer nothing ever drains.
backendProcess.stderr.on("data", (data) => {
const chunk = data.toString();
backendStderrTail = (backendStderrTail + chunk).slice(
-BACKEND_STDERR_TAIL_LIMIT,
);
logToFile("[backend:stderr]", chunk.trim());
});
backendProcess.on("message", (msg) => {
if (!resolved && msg?.type === "backend-ready") {
resolved = true;
clearTimeout(readyTimeout);
logToFile("Backend ready signal received via IPC");
resolve(true);
}
void handleBackendRequest(msg);
});
backendProcess.on("exit", (code, signal) => {
logToFile(`Backend process exited with code ${code}, signal ${signal}`);
if (!resolved && code !== 0) {
backendStartFailed = true;
}
// Classified whether or not the ready promise already settled: the
// 15s ready timeout resolves optimistically, so a backend that dies
// at second 20 still has to be reported rather than silently dropped.
// backendStopRequested is the only evidence that an exit was asked
// for, so it is the sole guard here -- past it, every exit means the
// backend is gone and the renderer must stop waiting for it.
if (!backendStopRequested) {
backendFailure = classifyBackendFailure(backendStderrTail);
logToFile("Backend failure classified as:", backendFailure.reason);
}
backendProcess = null;
clearBackendPidFile();
if (!resolved) {
resolved = true;
clearTimeout(readyTimeout);
resolve(false);
}
});
backendProcess.on("error", (err) => {
logToFile("Failed to start backend process:", err.message);
backendFailure = { reason: "crashed", port: null };
backendProcess = null;
if (!resolved) {
resolved = true;
clearTimeout(readyTimeout);
resolve(false);
}
});
});
}
function clearBackendPidFile() {
try {
fs.unlinkSync(getBackendPidFilePath());
} catch {
// already absent
}
}
function stopBackendServer() {
if (!backendProcess) return;
console.log("Stopping embedded backend server...");
backendStopRequested = true;
try {
backendProcess.send({ type: "shutdown" });
} catch {
// IPC channel may already be closed
}
const forceKillTimeout = setTimeout(() => {
if (backendProcess) {
console.log("Force killing backend process...");
backendProcess.kill("SIGKILL");
backendProcess = null;
}
}, 5000);
backendProcess.on("exit", () => {
clearTimeout(forceKillTimeout);
backendProcess = null;
clearBackendPidFile();
});
}
// App Sandbox denies the singleton socket bind, which reads as a held lock and
// quits the app on every launch. macOS reactivates bundled apps anyway.
const gotTheLock = process.mas || app.requestSingleInstanceLock();
if (!gotTheLock) {
console.log("Another instance is already running, quitting...");
app.quit();
process.exit(0);
} else {
app.on("second-instance", (event, commandLine, workingDirectory) => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
mainWindow.show();
}
});
}
function createTray() {
try {
const { nativeImage } = require("electron");
// Native APIs (Tray, nativeImage) can't load files from inside app.asar,
// use the unpacked path so the OS sees a real file.
const publicRoot = isDev
? path.join(appRoot, "public")
: path.join(
appRoot.replace(
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
"app.asar.unpacked",
),
"public",
);
let trayIcon;
if (process.platform === "darwin") {
const iconPath = path.join(publicRoot, "icons", "16x16.png");
trayIcon = nativeImage.createFromPath(iconPath);
trayIcon.setTemplateImage(true);
} else if (process.platform === "win32") {
trayIcon = path.join(publicRoot, "icon.ico");
} else {
trayIcon = loadLinuxTrayIcon(nativeImage, publicRoot);
if (!trayIcon) {
logToFile("Tray icon not found, skipping the system tray");
return;
}
if (/gnome/i.test(process.env.XDG_CURRENT_DESKTOP || "")) {
logToFile(
"GNOME shows tray icons only with the AppIndicator extension installed",
);
}
}
tray = new Tray(trayIcon);
tray.setToolTip("Termix");
const contextMenu = Menu.buildFromTemplate([
{
label: "Show Window",
click: () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
},
},
{
label: "Quit",
click: () => {
isQuitting = true;
quitApp(app, mainWindow);
},
},
]);
tray.setContextMenu(contextMenu);
tray.on("click", () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
mainWindow.focus();
}
}
});
logToFile("System tray created");
} catch (err) {
tray = null;
logToFile("Failed to create system tray:", err?.message || String(err));
}
}
function loadLinuxTrayIcon(nativeImage, publicRoot) {
const roots = [
publicRoot,
path.join(process.resourcesPath || "", "app.asar.unpacked", "public"),
];
for (const root of roots) {
for (const size of ["32x32", "24x24", "48x48"]) {
const image = nativeImage.createFromPath(
path.join(root, "icons", `${size}.png`),
);
if (!image.isEmpty()) return image;
}
}
return null;
}
function createWindow() {
const appVersion = app.getVersion();
const electronVersion = process.versions.electron;
const platform =
process.platform === "win32"
? "Windows"
: process.platform === "darwin"
? "macOS"
: "Linux";
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
title: "Termix",
icon: path.join(
appRoot,
"public",
process.platform === "win32" ? "icon.ico" : "icon.png",
),
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
preload: path.join(__dirname, "preload.js"),
additionalArguments: [`--termix-backend-port=${backendPort}`],
partition: termixSessionPartition,
allowRunningInsecureContent: false,
webviewTag: false,
offscreen: false,
},
show: true,
});
mainWindow.webContents.session.setPermissionRequestHandler(
(webContents, permission, callback) => {
if (
permission === "clipboard-read" ||
permission === "clipboard-write" ||
permission === "clipboard-sanitized-write"
) {
callback(true);
return;
}
callback(false);
},
);
if (process.platform !== "darwin") {
mainWindow.setMenuBarVisibility(false);
}
const customUserAgent = `Termix-Desktop/${appVersion} (${platform}; Electron/${electronVersion})`;
mainWindow.webContents.setUserAgent(customUserAgent);
mainWindow.webContents.on("before-input-event", (event, input) => {
if (process.platform !== "win32" || !isCloseActiveTabInput(input)) return;
event.preventDefault();
mainWindow.webContents.send("close-active-tab");
});
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
(details, callback) => {
details.requestHeaders["X-Electron-App"] = "true";
details.requestHeaders["User-Agent"] = customUserAgent;
// YouTube refuses embeds without a Referer, which file:// never sends.
if (
details.url.startsWith("https://www.youtube-nocookie.com/embed/") &&
!details.requestHeaders.Referer
) {
details.requestHeaders.Referer = "https://termix.site/";
}
const rememberedJwt = getRememberedElectronAuthCookie("jwt", details.url);
if (rememberedJwt) {
setCookieHeaderValue(
details.requestHeaders,
rememberedJwt.name,
rememberedJwt.value,
);
}
callback({
requestHeaders: linkedServer.applyLinkHeaders(
details.url,
details.requestHeaders,
),
});
},
);
if (isDev) {
mainWindow.loadURL("http://localhost:5173");
mainWindow.webContents.openDevTools();
} else {
const indexPath = path.join(appRoot, "dist", "index.html");
mainWindow.loadFile(indexPath).catch((err) => {
console.error("Failed to load file:", err);
});
}
mainWindow.webContents.session.webRequest.onHeadersReceived(
(details, callback) => {
const headers = details.responseHeaders;
if (headers) {
delete headers["x-frame-options"];
delete headers["X-Frame-Options"];
if (headers["content-security-policy"]) {
headers["content-security-policy"] = headers[
"content-security-policy"
]
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
.filter((value) => value.trim().length > 0);
if (headers["content-security-policy"].length === 0) {
delete headers["content-security-policy"];
}
}
if (headers["Content-Security-Policy"]) {
headers["Content-Security-Policy"] = headers[
"Content-Security-Policy"
]
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
.filter((value) => value.trim().length > 0);
if (headers["Content-Security-Policy"].length === 0) {
delete headers["Content-Security-Policy"];
}
}
const setCookieHeaderName = getHeaderName(headers, "Set-Cookie");
if (setCookieHeaderName) {
const setCookieHeaders = Array.isArray(headers[setCookieHeaderName])
? headers[setCookieHeaderName]
: [headers[setCookieHeaderName]];
setCookieHeaders.forEach((cookie) => {
rememberElectronAuthCookieFromHeader(details.url, cookie);
});
headers[setCookieHeaderName] = setCookieHeaders.map((cookie) => {
let modified = cookie.replace(
/;\s*SameSite=Strict/gi,
"; SameSite=None",
);
modified = modified.replace(
/;\s*SameSite=Lax/gi,
"; SameSite=None",
);
if (!modified.includes("SameSite=")) {
modified += "; SameSite=None";
}
if (
!modified.includes("Secure") &&
details.url.startsWith("https")
) {
modified += "; Secure";
}
return modified;
});
}
}
callback({ responseHeaders: headers });
},
);
mainWindow.once("ready-to-show", () => {
mainWindow.show();
mainWindow.focus();
});
setTimeout(() => {
if (mainWindow && !mainWindow.isVisible()) {
mainWindow.show();
}
}, 3000);
mainWindow.webContents.on(
"did-fail-load",
(event, errorCode, errorDescription, validatedURL) => {
console.error(
"Failed to load:",
errorCode,
errorDescription,
validatedURL,
);
},
);
mainWindow.webContents.on("did-finish-load", () => {
console.log("Frontend loaded successfully");
});
mainWindow.webContents.on("will-prevent-unload", (event) => {
// Explicit Quit must not be vetoed by the renderer's connection guard.
// In this Electron event, preventDefault allows the unload to proceed.
if (isQuitting) event.preventDefault();
});
mainWindow.on("close", (event) => {
if (!isQuitting && tray && !tray.isDestroyed()) {
event.preventDefault();
// Stock GNOME has no tray to bring a hidden window back from.
if (
process.platform === "linux" &&
/gnome/i.test(process.env.XDG_CURRENT_DESKTOP || "")
) {
mainWindow.minimize();
} else {
mainWindow.hide();
}
}
});
mainWindow.on("closed", () => {
mainWindow = null;
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
try {
const parsed = new URL(url);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
shell.openExternal(url);
}
} catch {
// invalid URL, ignore
}
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (event, url) => {
const allowedUrl = isDev
? url.startsWith("http://localhost:5173/")
: url === pathToFileURL(path.join(appRoot, "dist", "index.html")).href;
if (!allowedUrl) event.preventDefault();
});
}
ipcMain.handle("get-app-version", () => {
return app.getVersion();
});
ipcMain.handle("get-platform", () => {
return process.platform;
});
ipcMain.handle("get-embedded-server-status", () => {
return {
running:
backendProcess !== null && !backendProcess.killed && !backendStartFailed,
// A backend that is merely slow to boot reports no failure, so the
// renderer keeps waiting for it. Only a classified failure means the
// process is gone for good and waiting is pointless.
failure: backendFailure,
dataDir: isDev ? null : getBackendDataDir(),
};
});
// External login through the system browser (RFC 8252)
ipcMain.handle(
"external-browser-login",
async (_event, authUrl, callbackPort) => {
if (!isExternalLoginUrl(authUrl)) {
return { success: false, error: "Invalid authentication URL" };
}
if (!isCallbackPort(callbackPort)) {
return { success: false, error: "Invalid callback port" };
}
const http = require("http");
return new Promise((resolve) => {
let timeout;
let settled = false;
const server = http.createServer((req, res) => {
const url = new URL(req.url || "/", `http://localhost:${callbackPort}`);
if (url.pathname === "/oidc-callback") {
const success = url.searchParams.get("success");
const error = url.searchParams.get("error");
const token = url.searchParams.get("token");
const secondFactor = url.searchParams.get("second_factor") === "1";
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
`<html><body><h2>${success === "true" || secondFactor ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`,
);
if (secondFactor) {
finish({
success: false,
secondFactor: true,
tempToken: url.searchParams.get("temp_token"),
factors: url.searchParams.get("second_factors"),
});
} else if (success === "true") {
finish({ success: true, token });
} else {
finish({
success: false,
error: error || "Authentication failed",
});
}
return;
}
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not found");
});
const finish = (result) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
try {
server.close();
} catch {
// Server may not have started yet.
}
resolve(result);
};
const fail = (error) => {
finish({
success: false,
error: error instanceof Error ? error.message : String(error),
});
};
server.once("error", fail);
server.listen(callbackPort, "localhost", async () => {
try {
await shell.openExternal(authUrl);
} catch (error) {
fail(error);
}
});
// Timeout after 5 minutes
timeout = setTimeout(
() => {
fail(new Error("External login timed out"));
},
5 * 60 * 1000,
);
});
},
);
// Desktop-only settings, such as whether hosts connect from this device or
// through the linked server by default.
ipcMain.handle("get-desktop-settings", () => {
return desktopSettings.getDesktopSettings();
});
ipcMain.handle("save-desktop-settings", (_event, settings) => {
return desktopSettings.saveDesktopSettings(settings);
});
let appUpdater = null;
function startAppUpdater() {
const kind = autoUpdates.getInstallKind({
platform: process.platform,
arch: process.arch,
isPackaged: app.isPackaged,
mas: !!process.mas,
env: process.env,
execPath: process.execPath,
resourcesPath: process.resourcesPath,
exists: (target) => fs.existsSync(target),
readFile: (target) => fs.readFileSync(target, "utf8"),
});
logToFile("Updater install kind:", kind);
appUpdater = autoUpdates.createUpdater({
currentVersion: app.getVersion(),
platform: process.platform,
arch: process.arch,
kind,
getMode: () => desktopSettings.getDesktopSettings().updateMode,
saveMode: (mode) =>
desktopSettings.saveDesktopSettings({ updateMode: mode }),
fetchReleases: async () => {
const { net } = require("electron");
const response = await net.fetch(autoUpdates.RELEASES_API, {
headers: { Accept: "application/vnd.github+json" },
});
if (!response.ok) {
throw new Error(`GitHub releases returned ${response.status}`);
}
return response.json();
},
loadAutoUpdater: () => require("electron-updater").autoUpdater,
send: (state) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("desktop-update-state", state);
}
},
log: logToFile,
beforeInstall: () => {
isQuitting = true;
},
});
appUpdater.start();
}
ipcMain.handle(
"desktop-update-get-state",
() => appUpdater?.getState() ?? null,
);
ipcMain.handle("desktop-update-check", () => appUpdater?.check() ?? null);
ipcMain.handle("desktop-update-download", () => appUpdater?.download() ?? null);
ipcMain.handle("desktop-update-install", () => appUpdater?.install() ?? false);
ipcMain.handle(
"desktop-update-set-mode",
(_event, mode) => appUpdater?.setMode(mode) ?? null,
);
// The server an older version of the app was pointed at, offered again when
// linking so upgraded installs do not have to look it up.
ipcMain.handle("get-previous-server-url", () => {
return { serverUrl: desktopSettings.getPreviousServerUrl() };
});
function readSetting(key) {
const settingsPath = path.join(app.getPath("userData"), "settings.json");
if (!fs.existsSync(settingsPath)) return null;
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
return settings[key] !== undefined ? settings[key] : null;
}
ipcMain.handle("get-setting", (event, key) => {
try {
return readSetting(key);
} catch (error) {
console.error("Error reading setting:", error);
return null;
}
});
ipcMain.handle("set-setting", (event, key, value) => {
if (typeof key !== "string") {
return { success: false, error: "Invalid setting key" };
}
if (
key === EXTERNAL_EDITOR_SETTING &&
value &&
!dialogChosenPaths.has(value)
) {
return { success: false, error: "Choose the editor in the file dialog" };
}
try {
const userDataPath = app.getPath("userData");
const settingsPath = path.join(userDataPath, "settings.json");
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
let settings = {};
if (fs.existsSync(settingsPath)) {
const settingsData = fs.readFileSync(settingsPath, "utf8");
settings = JSON.parse(settingsData);
}
settings[key] = value;
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
return { success: true };
} catch (error) {
console.error("Error saving setting:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle("clear-session-cookies", async () => {
try {
clearPersistedElectronAuthCookies();
const ses = mainWindow?.webContents?.session;
if (ses) {
const cookies = await ses.cookies.get({});
for (const cookie of cookies) {
await ses.cookies.remove(getCookieRemovalUrl(cookie), cookie.name);
}
}
} catch (error) {
console.error("Failed to clear session cookies:", error);
}
});
ipcMain.handle("clipboard-write-text", (_event, text) => {
clipboard.writeText(typeof text === "string" ? text : String(text ?? ""));
return true;
});
ipcMain.handle("clipboard-read-text", () => clipboard.readText());
ipcMain.handle("show-save-dialog", async (_event, options) => {
return dialog.showSaveDialog(mainWindow, options || {});
});
ipcMain.handle("show-open-dialog", async (_event, options) => {
const result = await dialog.showOpenDialog(mainWindow, options || {});
for (const filePath of result.filePaths) dialogChosenPaths.add(filePath);
return result;
});
ipcMain.handle("create-temp-file", async (_event, fileData) => {
try {
const result = createManagedTempFile(
fileData?.fileName,
fileData?.content,
fileData?.encoding,
);
return { success: true, ...result };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("create-temp-folder", async (_event, folderData) => {
try {
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`));
const folderPath = path.join(
tempDir,
sanitizeFileName(folderData?.folderName || "files"),
);
fs.mkdirSync(folderPath, { recursive: true });
for (const file of folderData?.files || []) {
const relativePath = String(file.relativePath || "")
.split(/[\\/]+/)
.map(sanitizeFileName)
.filter(Boolean)
.join(path.sep);
if (!relativePath) continue;
const targetPath = path.join(folderPath, relativePath);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(
targetPath,
decodeFileContent(file.content, file.encoding),
);
}
tempFiles.set(tempId, { path: folderPath, dir: tempDir });
return { success: true, tempId, path: folderPath };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("start-drag-to-desktop", (event, dragData) => {
try {
const temp = tempFiles.get(dragData?.tempId);
if (!temp) return { success: false, error: "Temporary file not found" };
event.sender.startDrag({
file: temp.path,
icon: nativeImage.createEmpty(),
});
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("cleanup-temp-file", (_event, tempId) => {
try {
cleanupManagedTempFile(tempId);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
function createMenu() {
if (process.platform === "darwin") {
const template = [
{
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
},
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
],
},
{
label: "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
label: "Window",
submenu: [
{ role: "minimize" },
{ role: "zoom" },
{ type: "separator" },
{ role: "front" },
{ type: "separator" },
{ role: "window" },
],
},
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
}
app.whenReady().then(async () => {
logToFile("=== App ready ===");
logToFile(
"isDev:",
isDev,
"platform:",
process.platform,
"arch:",
process.arch,
);
if (process.platform === "win32") {
app.setAppUserModelId(windowsAppUserModelId);
}
createMenu();
await clearElectronClientCacheIfBuildChanged();
await clearElectronJwtCookiesAtStartup();
if (!isDev) {
backendPort = await findBackendPort();
const result = await startBackendServer();
logToFile("startBackendServer result:", result);
} else {
logToFile(
"Skipping embedded backend (isDev=true) - expecting the dev runner's backend",
);
if (isDevRunner()) {
process.on("message", (msg) => void handleBackendRequest(msg));
}
}
createTray();
createWindow();
startAppUpdater();
desktopSettings.removeOldSyncFiles();
logToFile("=== Startup complete ===");
});
app.on("window-all-closed", () => {
if (!tray || tray.isDestroyed()) {
app.quit();
}
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
} else if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
});
app.on("before-quit", () => {
isQuitting = true;
});
app.on("will-quit", () => {
console.log("App will quit...");
for (const tempId of tempFiles.keys()) {
cleanupManagedTempFile(tempId);
}
stopBackendServer();
});
process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", error);
});
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});