mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-23 12:01:06 +00:00
1968 lines
57 KiB
JavaScript
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);
|
|
});
|