Files
ZacharyZcR 44a4534baa fix: show remote sync account identity (#1110)
* fix: show remote sync account identity

* cover getRemoteSyncUserInfo and make its null contract hold

Nothing asserted the renderer-side gate: browser builds must not reach for the
IPC bridge, and a missing bridge, an unconfigured server, an expired JWT or a
failed channel all have to degrade to no identity rather than throw.

Writing that turned up a mismatch — with no preload bridge the optional chain
resolved to undefined while the signature promises null. The only caller uses
??, so nothing is broken today, but the type was not telling the truth.

The main-process half (token expiry, /users/me, the roles fallback) stays
uncovered: remote-sync.cjs requires electron at load, so exercising it means
stubbing safeStorage and the filesystem, which is a bigger change than this PR
warrants.
2026-07-28 02:08:37 +08:00

3124 lines
85 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 fs = require("fs");
const os = require("os");
const https = require("https");
const http = require("http");
const net = require("net");
const { URL } = require("url");
const { fork, spawn } = require("child_process");
const WebSocket = require("ws");
const remoteSync = require("./remote-sync.cjs");
// 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 getServerConfigPath() {
return path.join(app.getPath("userData"), "server-config.json");
}
function getServerConfigSync() {
try {
const configPath = getServerConfigPath();
if (!fs.existsSync(configPath)) return null;
return JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch {
return null;
}
}
function getOrigin(url) {
try {
return new URL(url).origin;
} catch {
return null;
}
}
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
}
// The only remaining "connected remote server" a self-signed/invalid
// certificate could legitimately apply to is the Remote Sync server
// (also used for C2S tunnel relaying, see getC2SRelayUrl).
const config = remoteSync.getRemoteSyncConfig();
if (!config?.allowInvalidCertificate || !config?.serverUrl) return false;
return getOrigin(url) === getOrigin(config.serverUrl);
}
function getTlsVerificationOptions(url) {
return {
rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url),
};
}
function getWebSocketOptions(url, options = {}) {
return {
...options,
...(String(url).startsWith("wss:") ? getTlsVerificationOptions(url) : {}),
};
}
function httpFetch(url, options = {}) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === "https:";
const client = isHttps ? https : http;
const requestOptions = {
method: options.method || "GET",
headers: options.headers || {},
timeout: options.timeout || 10000,
...(isHttps ? getTlsVerificationOptions(url) : {}),
};
const req = client.request(url, requestOptions, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
text: () => Promise.resolve(data),
json: () => Promise.resolve(JSON.parse(data)),
});
});
});
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Request timeout"));
});
if (options.body) {
req.write(options.body);
}
req.end();
});
}
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");
}
if (process.platform === "win32") {
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;
let backendProcess = null;
let backendStartFailed = false;
let tray = null;
let isQuitting = false;
const tempFiles = new Map();
const externalEditorSessions = new Map();
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);
}
function closeExternalEditorSession(editId) {
const session = externalEditorSessions.get(editId);
if (!session) return;
if (session.timer) clearTimeout(session.timer);
try {
session.watcher.close();
} catch {
// watcher may already be closed
}
externalEditorSessions.delete(editId);
cleanupManagedTempFile(editId);
}
function notifyExternalEditorSaved(editId) {
const session = externalEditorSessions.get(editId);
if (!session || !mainWindow || mainWindow.isDestroyed()) return;
try {
const stat = fs.statSync(session.path);
if (stat.mtimeMs === session.lastMtimeMs) return;
session.lastMtimeMs = stat.mtimeMs;
const content = fs.readFileSync(session.path, "utf8");
mainWindow.webContents.send("external-editor-saved", {
editId,
content,
encoding: "utf8",
path: session.path,
});
} catch (error) {
logToFile("Failed to read external editor file:", error.message);
}
}
function openPathWithEditor(filePath, editorPath) {
if (!editorPath) {
return shell.openPath(filePath);
}
return new Promise((resolve) => {
let settled = false;
const child = spawn(editorPath, [filePath], {
detached: true,
stdio: "ignore",
});
child.once("error", (error) => {
if (settled) return;
settled = true;
resolve(error.message);
});
setTimeout(() => {
if (settled) return;
settled = true;
child.unref();
resolve("");
}, 500);
});
}
app.on(
"certificate-error",
(event, _webContents, url, error, certificate, callback) => {
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);
},
);
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 every port the backend binds (30001, 30003-30008, 30010,
// 30012...). 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
}
}
function startBackendServer() {
reapOrphanedBackendProcess();
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);
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: "30001",
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);
}
});
backendProcess.stderr.on("data", (data) => {
logToFile("[backend:stderr]", data.toString().trim());
});
backendProcess.on("exit", (code, signal) => {
logToFile(`Backend process exited with code ${code}, signal ${signal}`);
if (!resolved && code !== 0) {
backendStartFailed = true;
}
backendProcess = null;
clearBackendPidFile();
if (!resolved) {
resolved = true;
clearTimeout(readyTimeout);
resolve(false);
}
});
backendProcess.on("error", (err) => {
logToFile("Failed to start backend process:", err.message);
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...");
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();
});
}
const gotTheLock = 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 = path.join(publicRoot, "icons", "32x32.png");
}
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;
app.quit();
},
},
]);
tray.setContextMenu(contextMenu);
tray.on("click", () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
mainWindow.focus();
}
}
});
console.log("System tray created successfully");
} catch (err) {
console.error("Failed to create system tray:", err);
}
}
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,
webSecurity: false,
preload: path.join(__dirname, "preload.js"),
partition: termixSessionPartition,
allowRunningInsecureContent: true,
webviewTag: true,
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.session.webRequest.onBeforeSendHeaders(
(details, callback) => {
details.requestHeaders["X-Electron-App"] = "true";
details.requestHeaders["User-Agent"] = customUserAgent;
const rememberedJwt = getRememberedElectronAuthCookie("jwt", details.url);
if (rememberedJwt) {
setCookieHeaderValue(
details.requestHeaders,
rememberedJwt.name,
rememberedJwt.value,
);
}
callback({ requestHeaders: 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.on("close", (event) => {
if (!isQuitting && tray && !tray.isDestroyed()) {
event.preventDefault();
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" };
});
}
ipcMain.handle("get-app-version", () => {
return app.getVersion();
});
const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = "Termix-SSH";
const REPO_NAME = "Termix";
const githubCache = new Map();
const CACHE_DURATION = 30 * 60 * 1000;
async function fetchGitHubAPI(endpoint, cacheKey) {
const cached = githubCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
return {
data: cached.data,
cached: true,
cache_age: Date.now() - cached.timestamp,
};
}
try {
const response = await httpFetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "TermixElectronUpdateChecker/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout: 10000,
});
if (!response.ok) {
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
githubCache.set(cacheKey, {
data,
timestamp: Date.now(),
});
return {
data: data,
cached: false,
};
} catch (error) {
console.error("Failed to fetch from GitHub API:", error);
throw error;
}
}
ipcMain.handle("check-electron-update", async () => {
try {
const localVersion = app.getVersion();
const releaseData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
"latest_release_electron",
);
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
return {
success: false,
error: "Remote version not found",
localVersion,
};
}
const versionComparison = compareSemver(localVersion, remoteVersion);
const status =
versionComparison === null || versionComparison === 0
? "up_to_date"
: versionComparison > 0
? "beta"
: "requires_update";
const result = {
success: true,
status,
localVersion: localVersion,
remoteVersion: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url,
body: releaseData.data.body,
},
cached: releaseData.cached,
cache_age: releaseData.cache_age,
};
return result;
} catch (error) {
return {
success: false,
error: error.message,
localVersion: app.getVersion(),
};
}
});
ipcMain.handle("get-platform", () => {
return process.platform;
});
ipcMain.handle("get-embedded-server-status", () => {
return {
running:
backendProcess !== null && !backendProcess.killed && !backendStartFailed,
dataDir: isDev ? null : getBackendDataDir(),
};
});
// OIDC System Browser Authentication (RFC 8252)
ipcMain.handle(
"oidc-system-browser-auth",
async (_event, authUrl, callbackPort) => {
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");
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
`<html><body><h2>${success === "true" ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`,
);
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("OIDC authentication timed out"));
},
5 * 60 * 1000,
);
});
},
);
ipcMain.handle("get-server-config", () => {
try {
return getServerConfigSync();
} catch (error) {
console.error("Error reading server config:", error);
return null;
}
});
ipcMain.handle("save-server-config", (event, config) => {
try {
const userDataPath = app.getPath("userData");
const configPath = getServerConfigPath();
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { success: true };
} catch (error) {
console.error("Error saving server config:", error);
return { success: false, error: error.message };
}
});
// --- Remote sync (optional desktop <-> self-hosted server sync) ---
// Surfaces the pre-standalone-rework server-config.json (if a serverUrl was
// ever set in it) so the renderer can prompt upgraded installs to set up
// Remote Sync -- their hosts live on that old server and won't appear
// locally until sync is enabled. A fresh install never had this file, so
// this is naturally false for anyone who never used the old architecture.
ipcMain.handle("get-legacy-server-config", () => {
const config = getServerConfigSync();
return { serverUrl: config?.serverUrl || null };
});
ipcMain.handle("get-desktop-settings", () => {
return remoteSync.getDesktopSettings();
});
ipcMain.handle("save-desktop-settings", (_event, settings) => {
return remoteSync.saveDesktopSettings(settings);
});
ipcMain.handle("get-remote-sync-config", () => {
return remoteSync.getRemoteSyncConfig();
});
ipcMain.handle("save-remote-sync-config", (_event, config) => {
return remoteSync.saveRemoteSyncConfig(config);
});
ipcMain.handle("clear-remote-sync-config", async () => {
const result = remoteSync.clearRemoteSyncConfig();
remoteSync.clearRemoteSyncJwt();
remoteSync.getRemoteSyncEngine()?.updateStatus({
connected: false,
syncing: false,
needsReauth: false,
lastError: null,
});
return result;
});
ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
const result = remoteSync.saveRemoteSyncJwt(token);
if (result.success) {
remoteSync.getRemoteSyncEngine()?.updateStatus({
connected: true,
needsReauth: false,
lastError: null,
});
remoteSync.getRemoteSyncEngine()?.syncNow();
}
return result;
});
ipcMain.handle("get-remote-sync-jwt", () => {
return remoteSync.getRemoteSyncJwt();
});
ipcMain.handle("clear-remote-sync-jwt", () => {
return remoteSync.clearRemoteSyncJwt();
});
ipcMain.handle("get-remote-sync-status", () => {
return remoteSync.getRemoteSyncEngine()?.status || null;
});
ipcMain.handle("get-remote-sync-user-info", () => {
return remoteSync.getRemoteSyncUserInfo();
});
ipcMain.handle("remote-sync-now", async () => {
return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null;
});
ipcMain.handle("notify-local-login", (_event, token) => {
remoteSync.getRemoteSyncEngine()?.setLocalJwt(token);
return { success: true };
});
function getC2STunnelConfigPath() {
return path.join(app.getPath("userData"), "c2s-tunnels.json");
}
ipcMain.handle("get-c2s-tunnel-config", () => {
try {
const configPath = getC2STunnelConfigPath();
if (!fs.existsSync(configPath)) {
return [];
}
const configData = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(configData);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
console.error("Error reading C2S tunnel config:", error);
return [];
}
});
ipcMain.handle("save-c2s-tunnel-config", async (_event, config) => {
try {
if (!Array.isArray(config)) {
return { success: false, error: "C2S tunnel config must be an array" };
}
const autoStartListeners = new Set();
const autoStartRemoteListeners = new Set();
for (const tunnel of config) {
if (!tunnel?.autoStart) continue;
const mode = tunnel.mode || tunnel.tunnelType || "local";
if (mode === "remote") {
const sourceHostId = Number(tunnel.sourceHostId);
const sourcePort = Number(tunnel.sourcePort);
if (
!Number.isInteger(sourceHostId) ||
sourceHostId < 1 ||
!Number.isInteger(sourcePort) ||
sourcePort < 1 ||
sourcePort > 65535
) {
return {
success: false,
error: "Invalid remote client tunnel endpoint or port",
};
}
const listenerKey = `${sourceHostId}:${sourcePort}`;
if (autoStartRemoteListeners.has(listenerKey)) {
return {
success: false,
error: `Another auto-start client tunnel already uses remote ${listenerKey}`,
};
}
autoStartRemoteListeners.add(listenerKey);
continue;
}
const bindHost = tunnel.bindHost || "127.0.0.1";
const sourcePort = Number(tunnel.sourcePort);
const listenerKey = `${bindHost}:${sourcePort}`;
if (autoStartListeners.has(listenerKey)) {
return {
success: false,
error: `Another auto-start client tunnel already uses ${listenerKey}`,
};
}
autoStartListeners.add(listenerKey);
}
for (const listenerKey of autoStartListeners) {
const [bindHost, sourcePort] = listenerKey.split(":");
const result = await checkLocalPortAvailable(
bindHost,
Number(sourcePort),
);
const ownedByClientTunnel = Array.from(c2sTunnelRuntimes.values()).some(
(runtime) =>
runtime.bindHost === bindHost &&
runtime.sourcePort === Number(sourcePort),
);
if (!result.available && !ownedByClientTunnel) {
return {
success: false,
error: `Cannot auto-start client tunnel on ${listenerKey}: ${result.error || "port is already in use"}`,
};
}
}
const userDataPath = app.getPath("userData");
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
fs.writeFileSync(getC2STunnelConfigPath(), JSON.stringify(config, null, 2));
return { success: true };
} catch (error) {
console.error("Error saving C2S tunnel config:", error);
return { success: false, error: error.message };
}
});
function checkLocalPortAvailable(host, port) {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", (error) => {
resolve({ available: false, error: error.message });
});
server.once("listening", () => {
server.close(() => resolve({ available: true }));
});
server.listen({ host, port });
});
}
function checkTcpConnection(host, port) {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
resolve({ success: false, error: "Connection timed out" });
}, 5000);
socket.once("connect", () => {
clearTimeout(timer);
socket.destroy();
resolve({ success: true });
});
socket.once("error", (error) => {
clearTimeout(timer);
socket.destroy();
resolve({ success: false, error: error.message });
});
});
}
const c2sTunnelRuntimes = new Map();
const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
const C2S_WS_LOW_WATERMARK = 256 * 1024;
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
// C2S (client-to-server) tunnels relay through a connected, self-hosted
// Termix server -- the same "remote server" concept Remote Sync connects
// to, not the always-local embedded backend. There's no separate C2S
// server-URL setting in the UI; it has always shared whatever remote
// server the rest of the app was pointed at. Before the standalone-first
// rework that was server-config.json; now it's remote-sync-config.json,
// since that's the only remaining notion of "a connected remote server."
function getC2SRelayUrl() {
const config = remoteSync.getRemoteSyncConfig();
const serverUrl = config?.serverUrl;
if (!serverUrl) {
throw new Error(
"No remote Termix server connected -- enable Remote Sync first",
);
}
const base = serverUrl.replace(/\/$/, "");
const relayHttpUrl = `${base}/ssh/tunnel/c2s/stream`;
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
}
async function getC2SRelayHeaders() {
const jwt = remoteSync.getRemoteSyncJwt();
if (!jwt) return {};
return {
Authorization: `Bearer ${jwt}`,
};
}
function getC2STunnelName(tunnel, index = 0) {
if (tunnel.name) return tunnel.name;
return [
"c2s",
index,
tunnel.sourceHostId || 0,
tunnel.mode || tunnel.tunnelType || "local",
tunnel.bindHost || "127.0.0.1",
tunnel.sourcePort,
tunnel.endpointPort || 0,
].join("::");
}
function getC2STunnelStatus(tunnelName) {
return (
c2sTunnelRuntimes.get(tunnelName)?.status || {
connected: false,
status: "DISCONNECTED",
}
);
}
function getAllC2STunnelStatuses() {
const statuses = {};
for (const [tunnelName] of c2sTunnelRuntimes.entries()) {
statuses[tunnelName] = getC2STunnelStatus(tunnelName);
}
return statuses;
}
function emitC2STunnelStatuses() {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send("c2s-tunnel-statuses", getAllC2STunnelStatuses());
}
function setC2STunnelStatus(tunnelName, status) {
const runtime = c2sTunnelRuntimes.get(tunnelName);
if (runtime) {
runtime.status = status;
emitC2STunnelStatuses();
}
}
function setC2STunnelError(tunnelName, message) {
logToFile(`[c2s] ${tunnelName} failed:`, message);
setC2STunnelStatus(tunnelName, {
connected: false,
status: "ERROR",
reason: message,
});
}
function parseSocks5Target(buffer) {
if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) {
return null;
}
const addressType = buffer[3];
let offset = 4;
let host;
if (addressType === 0x01) {
if (buffer.length < offset + 4 + 2) return null;
host = Array.from(buffer.subarray(offset, offset + 4)).join(".");
offset += 4;
} else if (addressType === 0x03) {
const length = buffer[offset];
offset += 1;
if (buffer.length < offset + length + 2) return null;
host = buffer.subarray(offset, offset + length).toString("utf8");
offset += length;
} else if (addressType === 0x04) {
if (buffer.length < offset + 16 + 2) return null;
const parts = [];
for (let i = 0; i < 16; i += 2) {
parts.push(buffer.readUInt16BE(offset + i).toString(16));
}
host = parts.join(":");
offset += 16;
} else {
throw new Error("Unsupported SOCKS5 address type");
}
const port = buffer.readUInt16BE(offset);
return { host, port, bytesRead: offset + 2 };
}
async function openC2SRelay(
tunnel,
targetHost,
targetPort,
socket,
initialData,
) {
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
const relayUrl = getC2SRelayUrl();
const headers = await getC2SRelayHeaders();
logToFile(`[c2s] opening relay for ${tunnelName}`, {
relayUrl,
targetHost,
targetPort,
});
setC2STunnelStatus(tunnelName, {
connected: false,
status: "CONNECTING",
reason: `Opening relay to ${targetHost}:${targetPort}`,
});
const ws = new WebSocket(
relayUrl,
getWebSocketOptions(relayUrl, { headers }),
);
const pendingChunks = [];
let ready = false;
let closed = false;
const cleanup = () => {
if (closed) return;
closed = true;
try {
socket.destroy();
} catch {
// expected during shutdown
}
try {
ws.close();
} catch {
// expected during shutdown
}
};
const sendChunk = (chunk) => {
if (ready && ws.readyState === WebSocket.OPEN) {
ws.send(chunk);
} else {
pendingChunks.push(chunk);
}
};
socket.on("data", sendChunk);
socket.on("close", cleanup);
socket.on("error", (error) => {
setC2STunnelError(tunnelName, error.message || "Local socket error");
cleanup();
});
ws.on("close", cleanup);
ws.on("error", (error) => {
setC2STunnelError(tunnelName, error.message || "Relay connection failed");
cleanup();
});
ws.on("open", () => {
logToFile(`[c2s] relay connected for ${tunnelName}`);
ws.send(
JSON.stringify({
type: "open",
tunnelConfig: tunnel,
targetHost,
targetPort,
}),
);
});
ws.on("message", (data, isBinary) => {
if (isBinary) {
socket.write(Buffer.isBuffer(data) ? data : Buffer.from(data));
return;
}
try {
const message = JSON.parse(data.toString());
if (message.type === "ready") {
ready = true;
logToFile(`[c2s] relay ready for ${tunnelName}`);
setC2STunnelStatus(tunnelName, {
connected: true,
status: "CONNECTED",
});
if (initialData?.length) {
ws.send(initialData);
}
while (pendingChunks.length > 0) {
ws.send(pendingChunks.shift());
}
} else if (message.type === "error") {
logToFile("[c2s] relay error:", message.error);
setC2STunnelError(
tunnelName,
message.error || "Relay rejected the client tunnel",
);
cleanup();
}
} catch (error) {
logToFile("[c2s] invalid relay message:", error.message);
setC2STunnelError(tunnelName, error.message || "Invalid relay response");
cleanup();
}
});
}
async function testC2SRelay(tunnel, targetHost, targetPort) {
const relayUrl = getC2SRelayUrl();
const headers = await getC2SRelayHeaders();
const ws = new WebSocket(
relayUrl,
getWebSocketOptions(relayUrl, { headers }),
);
return new Promise((resolve) => {
let settled = false;
const settle = (result) => {
if (settled) return;
settled = true;
try {
ws.close();
} catch {
// expected during shutdown
}
resolve(result);
};
const timer = setTimeout(() => {
settle({ success: false, error: "Tunnel test timed out" });
}, 15000);
ws.on("open", () => {
ws.send(
JSON.stringify({
type: "test",
tunnelConfig: tunnel,
targetHost,
targetPort,
}),
);
});
ws.on("message", (data, isBinary) => {
if (isBinary) return;
try {
const message = JSON.parse(data.toString());
if (message.type === "ready") {
clearTimeout(timer);
settle({ success: true });
} else if (message.type === "error") {
clearTimeout(timer);
settle({
success: false,
error: message.error || "Tunnel test failed",
});
}
} catch (error) {
clearTimeout(timer);
settle({ success: false, error: error.message });
}
});
ws.on("error", (error) => {
clearTimeout(timer);
settle({ success: false, error: error.message });
});
ws.on("close", () => {
clearTimeout(timer);
settle({ success: false, error: "Tunnel test connection closed" });
});
});
}
async function testC2STunnel(tunnel, index = 0) {
const mode = tunnel.mode || tunnel.tunnelType || "local";
const testTunnel = {
...tunnel,
name: `${getC2STunnelName(tunnel, index)}::test`,
mode,
};
const bindHost = tunnel.bindHost || "127.0.0.1";
const sourcePort = Number(tunnel.sourcePort);
const endpointPort = Number(tunnel.endpointPort);
if (!tunnel.sourceHostId) {
return { success: false, error: "Endpoint SSH host is required" };
}
if (mode === "remote") {
const localTarget = await checkTcpConnection(bindHost, endpointPort);
if (!localTarget.success) {
return {
success: false,
error: `Local target ${bindHost}:${endpointPort} is not reachable: ${localTarget.error}`,
};
}
return testC2SRelay(testTunnel, undefined, undefined);
}
if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) {
return { success: false, error: "Invalid local port" };
}
const runtime = c2sTunnelRuntimes.get(getC2STunnelName(tunnel, index));
if (!runtime) {
const availability = await checkLocalPortAvailable(bindHost, sourcePort);
if (!availability.available) {
return {
success: false,
error: `Local listener ${bindHost}:${sourcePort} is not available: ${availability.error}`,
};
}
}
if (mode === "dynamic") {
return testC2SRelay(testTunnel, undefined, undefined);
}
if (!Number.isInteger(endpointPort) || endpointPort < 1) {
return { success: false, error: "Invalid remote port" };
}
return testC2SRelay(
testTunnel,
tunnel.targetHost || "127.0.0.1",
endpointPort,
);
}
function handleC2SDynamicConnection(tunnel, socket) {
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
let buffer = Buffer.alloc(0);
let stage = "greeting";
const fail = (code = 0x01, message = "SOCKS5 request failed") => {
setC2STunnelError(tunnelName, message);
if (!socket.destroyed) {
socket.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
socket.destroy();
}
};
const onData = (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
try {
if (stage === "greeting") {
if (buffer.length < 2) return;
if (buffer[0] !== 0x05) {
fail(0x01, "Invalid SOCKS5 greeting");
return;
}
const methodsLength = buffer[1];
if (buffer.length < 2 + methodsLength) return;
socket.write(Buffer.from([0x05, 0x00]));
buffer = buffer.subarray(2 + methodsLength);
stage = "connect";
}
if (stage === "connect") {
const target = parseSocks5Target(buffer);
if (!target) return;
stage = "piping";
socket.off("data", onData);
const remainder = buffer.subarray(target.bytesRead);
socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
openC2SRelay(tunnel, target.host, target.port, socket, remainder).catch(
(error) => {
logToFile("[c2s] dynamic relay failed:", error.message);
fail(0x05, error.message || "Dynamic relay failed");
},
);
}
} catch (error) {
logToFile("[c2s] SOCKS5 parse failed:", error.message);
fail(0x01, error.message || "SOCKS5 parse failed");
}
};
socket.on("data", onData);
socket.on("error", () => socket.destroy());
}
function handleC2SLocalConnection(tunnel, socket) {
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
const targetHost = tunnel.targetHost || "127.0.0.1";
const targetPort = Number(tunnel.endpointPort);
openC2SRelay(tunnel, targetHost, targetPort, socket).catch((error) => {
logToFile("[c2s] local relay failed:", error.message);
setC2STunnelError(tunnelName, error.message || "Local relay failed");
socket.destroy();
});
}
function pauseSourceForC2SWebSocket(ws, source) {
if (!source?.pause || !source?.resume) return;
if (ws.bufferedAmount <= C2S_WS_HIGH_WATERMARK) return;
source.pause();
const resumeTimer = setInterval(() => {
if (
ws.readyState !== WebSocket.OPEN ||
source.destroyed ||
ws.bufferedAmount <= C2S_WS_LOW_WATERMARK
) {
clearInterval(resumeTimer);
if (ws.readyState === WebSocket.OPEN && !source.destroyed) {
source.resume();
}
}
}, 25);
}
function sendC2SRemoteMessage(ws, message, source) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message), (error) => {
if (error && source?.destroy) {
source.destroy(error);
}
});
pauseSourceForC2SWebSocket(ws, source);
}
}
function writeC2SRemoteChunk(target, chunk, ws, closeTarget) {
if (!target || target.destroyed) return;
if (target.writableLength > C2S_STREAM_WRITE_LIMIT) {
closeTarget();
return;
}
const canContinue = target.write(chunk);
if (!canContinue && typeof ws.pause === "function") {
ws.pause();
target.once("drain", () => {
if (ws.readyState === WebSocket.OPEN && typeof ws.resume === "function") {
ws.resume();
}
});
}
}
async function startC2SRemoteTunnel(tunnel, index = 0) {
const tunnelName = getC2STunnelName(tunnel, index);
const localHost = tunnel.bindHost || "127.0.0.1";
const localPort = Number(tunnel.endpointPort);
const remotePort = Number(tunnel.sourcePort);
if (!tunnel.sourceHostId) {
return { success: false, error: "Endpoint SSH host is required" };
}
if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65535) {
return { success: false, error: "Invalid remote port" };
}
if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
return { success: false, error: "Invalid local port" };
}
const localTarget = await checkTcpConnection(localHost, localPort);
if (!localTarget.success) {
return {
success: false,
error: `Local target ${localHost}:${localPort} is not reachable: ${localTarget.error}`,
};
}
const existing = c2sTunnelRuntimes.get(tunnelName);
if (existing) {
return { success: true, tunnelName };
}
for (const runtime of c2sTunnelRuntimes.values()) {
if (
runtime.mode === "remote" &&
runtime.sourceHostId === Number(tunnel.sourceHostId) &&
runtime.sourcePort === remotePort
) {
return {
success: false,
error: `Another client remote tunnel already uses ${remotePort} on this endpoint`,
};
}
}
const relayUrl = getC2SRelayUrl();
const headers = await getC2SRelayHeaders();
const ws = new WebSocket(
relayUrl,
getWebSocketOptions(relayUrl, { headers }),
);
const sockets = new Map();
let closed = false;
const cleanup = () => {
if (closed) return;
closed = true;
for (const socket of sockets.values()) {
socket.destroy();
}
sockets.clear();
try {
ws.close();
} catch {
// expected during shutdown
}
};
c2sTunnelRuntimes.set(tunnelName, {
ws,
sockets,
mode: "remote",
sourceHostId: Number(tunnel.sourceHostId),
sourcePort: remotePort,
bindHost: localHost,
status: { connected: false, status: "CONNECTING" },
close: cleanup,
});
emitC2STunnelStatuses();
return new Promise((resolve) => {
let settled = false;
const settle = (result) => {
if (settled) return;
settled = true;
resolve(result);
};
ws.on("open", () => {
logToFile(`[c2s] opening remote tunnel ${tunnelName}`, {
relayUrl,
remotePort,
localHost,
localPort,
});
ws.send(
JSON.stringify({
type: "open",
tunnelConfig: { ...tunnel, name: tunnelName, mode: "remote" },
}),
);
});
ws.on("message", (data, isBinary) => {
if (isBinary) return;
let message;
try {
message = JSON.parse(data.toString());
} catch (error) {
setC2STunnelError(tunnelName, error.message || "Invalid relay message");
cleanup();
settle({ success: false, error: error.message });
return;
}
if (message.type === "ready") {
setC2STunnelStatus(tunnelName, {
connected: true,
status: "CONNECTED",
});
settle({ success: true, tunnelName });
return;
}
if (message.type === "error") {
const error = message.error || "Relay rejected the client tunnel";
setC2STunnelError(tunnelName, error);
cleanup();
c2sTunnelRuntimes.delete(tunnelName);
emitC2STunnelStatuses();
settle({ success: false, error });
return;
}
if (message.type === "connection" && message.streamId) {
const socket = net.createConnection(
{ host: localHost, port: localPort },
() => {
logToFile(`[c2s] remote stream ${message.streamId} connected`, {
tunnelName,
localHost,
localPort,
});
},
);
sockets.set(message.streamId, socket);
socket.on("data", (chunk) => {
sendC2SRemoteMessage(
ws,
{
type: "data",
streamId: message.streamId,
data: chunk.toString("base64"),
},
socket,
);
});
socket.on("close", () => {
sockets.delete(message.streamId);
sendC2SRemoteMessage(ws, {
type: "close",
streamId: message.streamId,
});
});
socket.on("error", (error) => {
logToFile(`[c2s] remote stream ${message.streamId} failed:`, {
tunnelName,
error: error.message,
});
sockets.delete(message.streamId);
sendC2SRemoteMessage(ws, {
type: "close",
streamId: message.streamId,
error: error.message,
});
});
return;
}
if (message.type === "data" && message.streamId && message.data) {
const socket = sockets.get(message.streamId);
writeC2SRemoteChunk(
socket,
Buffer.from(message.data, "base64"),
ws,
() => {
if (socket) {
sockets.delete(message.streamId);
socket.destroy();
}
},
);
return;
}
if (message.type === "close" && message.streamId) {
const socket = sockets.get(message.streamId);
if (socket) {
sockets.delete(message.streamId);
socket.destroy();
}
}
});
ws.on("close", () => {
cleanup();
c2sTunnelRuntimes.delete(tunnelName);
emitC2STunnelStatuses();
settle({ success: false, error: "Remote tunnel relay closed" });
});
ws.on("error", (error) => {
setC2STunnelError(tunnelName, error.message || "Relay connection failed");
cleanup();
c2sTunnelRuntimes.delete(tunnelName);
emitC2STunnelStatuses();
settle({ success: false, error: error.message });
});
});
}
async function startC2STunnel(tunnel, index = 0) {
const mode = tunnel.mode || tunnel.tunnelType || "local";
const tunnelName = getC2STunnelName(tunnel, index);
const bindHost = tunnel.bindHost || "127.0.0.1";
const sourcePort = Number(tunnel.sourcePort);
logToFile(`[c2s] starting tunnel ${tunnelName}`, {
mode,
bindHost,
sourcePort,
sourceHostId: tunnel.sourceHostId,
endpointPort: tunnel.endpointPort,
});
if (mode === "remote") {
return startC2SRemoteTunnel(tunnel, index);
}
if (!tunnel.sourceHostId) {
return { success: false, error: "Endpoint SSH host is required" };
}
if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) {
return { success: false, error: "Invalid local port" };
}
const existing = c2sTunnelRuntimes.get(tunnelName);
if (existing) {
return { success: true, tunnelName };
}
for (const runtime of c2sTunnelRuntimes.values()) {
if (
runtime.mode !== "remote" &&
runtime.bindHost === bindHost &&
runtime.sourcePort === sourcePort
) {
return {
success: false,
error: `Another client tunnel already uses ${bindHost}:${sourcePort}`,
};
}
}
const availability = await checkLocalPortAvailable(bindHost, sourcePort);
if (!availability.available) {
return {
success: false,
error: availability.error || "Port is already in use",
};
}
const sockets = new Set();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
if (mode === "dynamic") {
handleC2SDynamicConnection({ ...tunnel, name: tunnelName, mode }, socket);
} else {
handleC2SLocalConnection({ ...tunnel, name: tunnelName, mode }, socket);
}
});
c2sTunnelRuntimes.set(tunnelName, {
server,
sockets,
bindHost,
sourcePort,
status: { connected: false, status: "CONNECTING" },
});
return new Promise((resolve) => {
server.once("error", (error) => {
c2sTunnelRuntimes.delete(tunnelName);
logToFile(`[c2s] failed to listen for ${tunnelName}:`, error.message);
emitC2STunnelStatuses();
resolve({ success: false, error: error.message });
});
server.listen({ host: bindHost, port: sourcePort }, () => {
logToFile(
`[c2s] listening for ${tunnelName} on ${bindHost}:${sourcePort}`,
);
setC2STunnelStatus(tunnelName, {
connected: true,
status: "CONNECTED",
});
resolve({ success: true, tunnelName });
});
});
}
async function stopC2STunnel(tunnelName) {
const runtime = c2sTunnelRuntimes.get(tunnelName);
if (!runtime) {
return { success: true };
}
setC2STunnelStatus(tunnelName, {
connected: false,
status: "DISCONNECTING",
});
return new Promise((resolve) => {
if (typeof runtime.close === "function") {
runtime.close();
c2sTunnelRuntimes.delete(tunnelName);
emitC2STunnelStatuses();
resolve({ success: true });
return;
}
for (const socket of runtime.sockets || []) {
socket.destroy();
}
runtime.server?.close(() => {
c2sTunnelRuntimes.delete(tunnelName);
emitC2STunnelStatuses();
resolve({ success: true });
});
});
}
function stopAllC2STunnels() {
for (const [tunnelName, runtime] of c2sTunnelRuntimes.entries()) {
try {
if (typeof runtime.close === "function") {
runtime.close();
} else {
for (const socket of runtime.sockets || []) {
socket.destroy();
}
runtime.server?.close();
}
} catch (error) {
logToFile(`[c2s] failed to stop tunnel ${tunnelName}:`, error.message);
}
c2sTunnelRuntimes.delete(tunnelName);
}
emitC2STunnelStatuses();
}
async function startC2SAutoStartTunnels() {
const configPath = getC2STunnelConfigPath();
if (!fs.existsSync(configPath)) {
return { success: true, started: 0, errors: [] };
}
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
const tunnels = Array.isArray(config) ? config : [];
const errors = [];
let started = 0;
for (let index = 0; index < tunnels.length; index += 1) {
const tunnel = tunnels[index];
if (!tunnel?.autoStart) continue;
const result = await startC2STunnel(tunnel, index);
if (result.success) {
started += 1;
} else {
errors.push(result.error || "Failed to start client tunnel");
}
}
return { success: errors.length === 0, started, errors };
}
ipcMain.handle("check-local-port-available", async (_event, host, port) => {
const sourcePort = Number(port);
if (
!host ||
!Number.isInteger(sourcePort) ||
sourcePort < 1 ||
sourcePort > 65535
) {
return { available: false, error: "Invalid local bind address or port" };
}
return checkLocalPortAvailable(host, sourcePort);
});
ipcMain.handle("start-c2s-tunnel", async (_event, tunnel, index) => {
try {
return await startC2STunnel(tunnel, Number(index) || 0);
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("test-c2s-tunnel", async (_event, tunnel, index) => {
try {
return await testC2STunnel(tunnel, Number(index) || 0);
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("stop-c2s-tunnel", async (_event, tunnelName) => {
try {
return await stopC2STunnel(tunnelName);
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("get-c2s-tunnel-statuses", () => {
return getAllC2STunnelStatuses();
});
ipcMain.handle("start-c2s-autostart-tunnels", async () => {
try {
return await startC2SAutoStartTunnels();
} catch (error) {
return { success: false, started: 0, errors: [error.message] };
}
});
ipcMain.handle("get-c2s-tunnel-preset-default-name", () => {
const now = new Date();
const date = now.toISOString().slice(0, 10);
const platform =
process.platform === "darwin"
? "macOS"
: process.platform === "win32"
? "Windows"
: "Linux";
const release = os.release();
const computerName = os.hostname();
return `[${date}] ${computerName} (${platform} ${release})`;
});
ipcMain.handle("get-setting", (event, key) => {
try {
const userDataPath = app.getPath("userData");
const settingsPath = path.join(userDataPath, "settings.json");
if (!fs.existsSync(settingsPath)) {
return null;
}
const settingsData = fs.readFileSync(settingsPath, "utf8");
const settings = JSON.parse(settingsData);
return settings[key] !== undefined ? settings[key] : null;
} catch (error) {
console.error("Error reading setting:", error);
return null;
}
});
ipcMain.handle("set-setting", (event, key, value) => {
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("get-session-cookie", async (_event, name, targetUrl) => {
try {
const ses = mainWindow?.webContents?.session;
if (!ses) return null;
const cookies = await ses.cookies.get({
name,
...(targetUrl ? { url: targetUrl } : {}),
});
const cookie = cookies.find((candidate) =>
cookieMatchesUrl(candidate, targetUrl),
);
return (
cookie?.value ||
getRememberedElectronAuthCookie(name, targetUrl)?.value ||
null
);
} catch (error) {
console.error("Failed to get session cookie:", error);
return getRememberedElectronAuthCookie(name, targetUrl)?.value || null;
}
});
function cookieMatchesUrl(cookie, targetUrl) {
if (!targetUrl) return true;
try {
const targetHost = new URL(targetUrl).hostname;
const cookieDomain = (cookie.domain || "").replace(/^\./, "");
return (
cookieDomain === targetHost ||
targetHost.endsWith(`.${cookieDomain}`) ||
(!cookieDomain && targetHost === "localhost")
);
} catch {
return true;
}
}
ipcMain.handle(
"wait-session-cookie",
async (_event, name, targetUrl, previousValue, timeoutMs = 5000) => {
const ses = mainWindow?.webContents?.session;
if (!ses) return { success: false, error: "No Electron session" };
const existingCookies = await ses.cookies.get({
name,
...(targetUrl ? { url: targetUrl } : {}),
});
const existingCookie = existingCookies.find((cookie) =>
cookieMatchesUrl(cookie, targetUrl),
);
if (existingCookie?.value && existingCookie.value !== previousValue) {
return { success: true, value: existingCookie.value };
}
const rememberedCookie = getRememberedElectronAuthCookie(name, targetUrl);
if (rememberedCookie?.value && rememberedCookie.value !== previousValue) {
return { success: true, value: rememberedCookie.value };
}
return new Promise((resolve) => {
const timeout = setTimeout(() => {
ses.cookies.off("changed", onCookieChanged);
resolve({ success: false, error: "Timed out waiting for cookie" });
}, timeoutMs);
function onCookieChanged(_event, cookie, _cause, removed) {
if (
removed ||
cookie.name !== name ||
!cookie.value ||
cookie.value === previousValue ||
!cookieMatchesUrl(cookie, targetUrl)
) {
return;
}
clearTimeout(timeout);
ses.cookies.off("changed", onCookieChanged);
resolve({ success: true, value: cookie.value });
}
ses.cookies.on("changed", onCookieChanged);
});
},
);
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) => {
return dialog.showOpenDialog(mainWindow, options || {});
});
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 };
}
});
ipcMain.handle("open-external-editor", async (_event, fileData) => {
try {
const result = createManagedTempFile(
fileData?.fileName,
fileData?.content,
fileData?.encoding,
);
const editId = result.tempId;
const stat = fs.statSync(result.path);
const watcher = fs.watch(result.path, { persistent: false }, () => {
const session = externalEditorSessions.get(editId);
if (!session) return;
if (session.timer) clearTimeout(session.timer);
session.timer = setTimeout(() => notifyExternalEditorSaved(editId), 500);
});
externalEditorSessions.set(editId, {
path: result.path,
watcher,
timer: null,
lastMtimeMs: stat.mtimeMs,
});
const editorPath =
typeof fileData?.editorPath === "string" && fileData.editorPath.trim()
? fileData.editorPath.trim()
: null;
const openError = await openPathWithEditor(result.path, editorPath);
if (openError) {
closeExternalEditorSession(editId);
return { success: false, error: openError };
}
return { success: true, editId, path: result.path };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("close-external-editor", (_event, editId) => {
try {
closeExternalEditorSession(editId);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
try {
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
const healthUrl = `${normalizedServerUrl}/health`;
// This is a best-effort reachability probe, not a hard gate: a reverse
// proxy doing SSO in front of the real server (Pangolin, Authelia,
// Cloudflare Access, etc.) intercepts this unauthenticated request
// before it ever reaches Termix's own /health route, and returns its
// own login page (HTML, or a redirect) instead of {"status":"ok"}.
// That's a legitimate, working setup -- the login iframe shown right
// after this check is what actually proves the server is real, by
// completing an authenticated round-trip. So any response at all here
// (any status code, any body) means "something is there, let the user
// proceed"; only a network-level failure (nothing answered at all)
// blocks continuing.
try {
const response = await httpFetch(healthUrl, {
method: "GET",
timeout: 10000,
});
const data = await response.text();
const looksLikeHtml =
data.includes("<html") ||
data.includes("<!DOCTYPE") ||
data.includes("<head>") ||
data.includes("<body>");
if (response.ok && !looksLikeHtml) {
try {
const healthData = JSON.parse(data);
if (
healthData &&
(healthData.status === "ok" ||
healthData.status === "healthy" ||
healthData.healthy === true ||
healthData.database === "connected")
) {
return {
success: true,
status: response.status,
testedUrl: healthUrl,
};
}
} catch (parseError) {
console.log("Health endpoint did not return valid JSON");
}
}
// Reachable, but not a recognized Termix health response -- likely a
// proxy/SSO login page in front of the real server. Let the user
// proceed; the login step next will fail clearly if this really
// isn't a Termix server.
return {
success: true,
status: response.status,
testedUrl: healthUrl,
warning: looksLikeHtml
? "Could not confirm this is a Termix server (the response looked like an HTML page, which can happen behind a login-protected reverse proxy). You can continue, and the next step will fail clearly if this isn't actually a Termix server."
: "Server responded, but not with the expected health check format. Continuing anyway.",
};
} catch (urlError) {
console.error("Health check failed:", urlError);
return {
success: false,
error:
"Server is not responding. Please ensure the server is running and accessible.",
};
}
} 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) {
const result = await startBackendServer();
logToFile("startBackendServer result:", result);
} else {
logToFile(
"Skipping embedded backend (isDev=true) - expecting separate dev:backend process",
);
}
createTray();
createWindow();
remoteSync.initRemoteSync(() => mainWindow);
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 editId of externalEditorSessions.keys()) {
closeExternalEditorSession(editId);
}
for (const tempId of tempFiles.keys()) {
cleanupManagedTempFile(tempId);
}
stopAllC2STunnels();
stopBackendServer();
});
process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", error);
});
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});