Files

3849 lines
109 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 { renameLocalPath } = require("./rename-local-path.cjs");
const os = require("os");
const https = require("https");
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, spawn } = require("child_process");
const pty = require("node-pty");
const WebSocket = require("ws");
const linkedServer = require("./linked-server.cjs");
const desktopSettings = require("./desktop-settings.cjs");
const { launchNativeRdp } = require("./native-rdp.cjs");
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs");
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
const { registerLocalFileHandlers } = require("./local-files.cjs");
const localTerminalSessions = new Map();
function ownedLocalTerminal(event, sessionId) {
if (typeof sessionId !== "string" || !/^[a-f0-9-]{36}$/.test(sessionId)) {
return null;
}
const session = localTerminalSessions.get(sessionId);
return session?.ownerId === event.sender.id ? session : null;
}
function closeLocalTerminalsFor(ownerId) {
for (const [sessionId, session] of localTerminalSessions) {
if (session.ownerId !== ownerId) continue;
session.process.kill();
localTerminalSessions.delete(sessionId);
}
}
// The main process's Node.js networking (the `https`/`http` modules used by
// httpFetch below and 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 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
}
// 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 getOrigin(url) === linked.origin;
}
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
? options.allowInvalidCertificate
? { rejectUnauthorized: false }
: getTlsVerificationOptions(url)
: {}),
};
const req = client.request(url, requestOptions, (res) => {
const chunks = [];
// Reverse proxies (nginx and friends) commonly gzip/deflate/br-compress
// responses regardless of client Accept-Encoding. Unlike browser fetch,
// Node's http/https modules never auto-decompress, so an unhandled
// content-encoding here silently turns the body into garbage bytes.
let stream = res;
const maxResponseBytes = options.maxResponseBytes || 10 * 1024 * 1024;
let responseBytes = 0;
let settled = false;
const fail = (error) => {
if (settled) return;
settled = true;
stream.destroy();
req.destroy();
reject(error);
};
const encoding = (res.headers["content-encoding"] || "")
.toLowerCase()
.trim();
try {
if (encoding === "gzip" || encoding === "x-gzip") {
stream = res.pipe(zlib.createGunzip());
} else if (encoding === "br") {
stream = res.pipe(zlib.createBrotliDecompress());
} else if (encoding === "deflate") {
stream = res.pipe(zlib.createInflate());
}
} catch (decompressError) {
reject(decompressError);
return;
}
stream.on("data", (chunk) => {
responseBytes += chunk.length;
if (responseBytes > maxResponseBytes) {
fail(new Error(`Response exceeds ${maxResponseBytes} bytes`));
return;
}
chunks.push(chunk);
});
stream.on("end", () => {
if (settled) return;
settled = true;
const data = Buffer.concat(chunks).toString("utf8");
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
text: () => Promise.resolve(data),
json: () => Promise.resolve(JSON.parse(data)),
});
});
stream.on("error", fail);
});
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");
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),
// The OS client for a protocol. Only RDP has one wired up.
"launch-external-client": (payload) =>
payload?.protocol === "rdp"
? launchNativeRdp(payload)
: {
success: false,
error: `No external client for ${payload?.protocol ?? "this protocol"}`,
},
"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();
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);
});
}
// 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
}
}
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: "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);
}
});
// 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"),
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) => {
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);
});
// 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 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);
const remoteAddress = getC2SRemoteAddress(tunnel);
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}:${remoteAddress}:${sourcePort}`;
if (autoStartRemoteListeners.has(listenerKey)) {
return {
success: false,
error: `Another auto-start client tunnel already uses remote ${listenerKey}`,
};
}
autoStartRemoteListeners.add(listenerKey);
continue;
}
const bindHost = getC2SLocalAddress(tunnel);
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 sourcePortSeparator = listenerKey.lastIndexOf(":");
const bindHost = listenerKey.slice(0, sourcePortSeparator);
const sourcePort = listenerKey.slice(sourcePortSeparator + 1);
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 });
});
});
}
function getC2SAddress(value, fallback) {
const address = typeof value === "string" ? value.trim() : "";
return address || fallback;
}
function getC2SLocalAddress(tunnel) {
return getC2SAddress(tunnel?.localAddress || tunnel?.bindHost, "127.0.0.1");
}
function getC2SRemoteAddress(tunnel) {
return getC2SAddress(
tunnel?.remoteAddress || tunnel?.targetHost,
"127.0.0.1",
);
}
function normalizeC2STunnelAddresses(tunnel) {
const localAddress = getC2SLocalAddress(tunnel);
const remoteAddress = getC2SRemoteAddress(tunnel);
return {
...tunnel,
localAddress,
remoteAddress,
bindHost: localAddress,
targetHost: remoteAddress,
};
}
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 the server this desktop is
// linked to, not the embedded backend.
function getC2SRemoteBaseUrl() {
const serverUrl = linkedServer.getLinkedServer()?.serverUrl;
if (!serverUrl) {
throw new Error(
"This device is not linked to a Termix server. Link it in Sync first.",
);
}
return serverUrl.replace(/\/$/, "");
}
// The socket path on the linked server that relays C2S streams. The plugin
// that owns the relay tells us where it is, so nothing here names it.
let c2sRelayPath = null;
const C2S_RELAY_PATH_PATTERN =
/^\/plugin-ws\/[a-z0-9][a-z0-9-]*\/[A-Za-z0-9/_-]+$/;
ipcMain.handle("set-c2s-relay-path", (_event, relayPath) => {
if (
typeof relayPath !== "string" ||
!C2S_RELAY_PATH_PATTERN.test(relayPath)
) {
return { success: false, error: "Invalid relay path" };
}
c2sRelayPath = relayPath;
return { success: true };
});
function getC2SRelayUrl() {
if (!c2sRelayPath) {
throw new Error("Client tunnels are not available yet. Try again shortly.");
}
const relayHttpUrl = `${getC2SRemoteBaseUrl()}${c2sRelayPath}`;
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
}
const C2S_REMOTE_SESSION_EXPIRED_ERROR =
"This device was signed out of its server. Sign in again from Sync and try again.";
function normalizeC2SAuthToken(authToken) {
return typeof authToken === "string" ? authToken.trim() : "";
}
function isC2SAuthError(message) {
const value = String(message || "")
.trim()
.toLowerCase();
return (
value === "authentication required" ||
value === "missing authentication token" ||
value === "invalid token" ||
value === "session expired" ||
value === "session not found" ||
// The plugin relay refuses an unauthenticated upgrade before any message.
value === "unexpected server response: 401"
);
}
function normalizeC2SErrorMessage(message, fallback = "Client tunnel failed") {
const value = message || fallback;
return isC2SAuthError(value) ? C2S_REMOTE_SESSION_EXPIRED_ERROR : value;
}
function createC2SFailure(message, fallback) {
const error = normalizeC2SErrorMessage(message, fallback);
return {
success: false,
error,
...(error === C2S_REMOTE_SESSION_EXPIRED_ERROR
? { code: "REMOTE_SESSION_EXPIRED" }
: {}),
};
}
async function fetchC2SRemoteJson(pathname) {
if (!linkedServer.getLinkedServer()?.token) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
}
const url = `${getC2SRemoteBaseUrl()}${pathname}`;
const response = await httpFetch(url, {
headers: linkedServer.linkedRequestHeaders(),
});
if (response.status === 401 || response.status === 403) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
}
if (!response.ok) {
throw new Error(`Request failed (${response.status}): ${url}`);
}
return response.json();
}
async function resolveC2SRemoteSourceHost(tunnel) {
const normalized = normalizeC2STunnelAddresses(tunnel);
const sourceHostSyncId =
typeof normalized.sourceHostSyncId === "string"
? normalized.sourceHostSyncId.trim()
: "";
if (!sourceHostSyncId) return normalized;
let remoteHost = null;
try {
remoteHost = await fetchC2SRemoteJson(
`/sync/v2/hosts/${encodeURIComponent(sourceHostSyncId)}`,
);
} catch (error) {
if (error.message === C2S_REMOTE_SESSION_EXPIRED_ERROR) throw error;
}
const remoteHostId = Number(remoteHost?.id);
if (!Number.isInteger(remoteHostId) || remoteHostId < 1) {
throw new Error(
"The intermediate host is not on the server yet. Sync, then try again.",
);
}
return {
...normalized,
sourceHostId: remoteHostId,
sourceHostSyncId,
sourceHostName: remoteHost.name || normalized.sourceHostName,
endpointHost: normalized.endpointHost || remoteHost.name,
};
}
async function getC2SRelayHeaders(relayUrl) {
if (linkedServer.getLinkedServer()?.token) {
return linkedServer.linkedRequestHeaders();
}
const headers = { "X-Electron-App": "true" };
if (!mainWindow?.webContents?.session) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
}
const cookieUrl = relayUrl
.replace(/^ws:/, "http:")
.replace(/^wss:/, "https:");
const cookies = await mainWindow.webContents.session.cookies.get({
url: cookieUrl,
name: "jwt",
});
const jwt = cookies[0]?.value;
if (!jwt) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
}
headers.Cookie = `jwt=${encodeURIComponent(jwt)}`;
return headers;
}
const attachC2SAuth = require("./c2s-auth.cjs").createC2SAuthBridge(
ipcMain,
() => mainWindow,
);
function getC2STunnelName(tunnel, index = 0) {
if (tunnel.name) return tunnel.name;
const localAddress = getC2SLocalAddress(tunnel);
const remoteAddress = getC2SRemoteAddress(tunnel);
return [
"c2s",
index,
tunnel.sourceHostId || 0,
tunnel.mode || tunnel.tunnelType || "local",
localAddress,
remoteAddress,
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,
authToken,
) {
const tunnelConfig = normalizeC2STunnelAddresses(tunnel);
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
const relayUrl = getC2SRelayUrl();
const headers = await getC2SRelayHeaders(relayUrl, authToken);
logToFile(`[c2s] opening relay for ${tunnelName}`, {
relayUrl,
targetHost,
targetPort,
});
const ws = new WebSocket(
relayUrl,
getWebSocketOptions(relayUrl, { headers }),
);
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
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);
// One connection failing must not stop the tunnel: the listener refuses
// new connections while the tunnel is not connected, and a browser opens
// many at once.
socket.on("error", (error) => {
logToFile(`[c2s] ${tunnelName} local socket error:`, error.message);
cleanup();
});
ws.on("close", cleanup);
ws.on("error", (error) => {
logToFile(`[c2s] ${tunnelName} relay error:`, error.message);
cleanup();
});
ws.on("open", () => {
logToFile(`[c2s] relay connected for ${tunnelName}`);
ws.send(
JSON.stringify({
type: "open",
tunnelConfig,
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}`);
if (initialData?.length) {
ws.send(initialData);
}
while (pendingChunks.length > 0) {
ws.send(pendingChunks.shift());
}
} else if (message.type === "error") {
const relayError = normalizeC2SErrorMessage(
message.error,
"Relay rejected the client tunnel",
);
logToFile("[c2s] relay error:", relayError);
if (relayError === C2S_REMOTE_SESSION_EXPIRED_ERROR) {
setC2STunnelError(tunnelName, relayError);
}
cleanup();
}
} catch (error) {
logToFile("[c2s] invalid relay message:", error.message);
cleanup();
}
});
}
async function testC2SRelay(
tunnel,
targetHost,
targetPort,
authToken,
runtime,
) {
const tunnelConfig = normalizeC2STunnelAddresses(tunnel);
const relayUrl = getC2SRelayUrl();
const headers = await getC2SRelayHeaders(relayUrl, authToken);
if (runtime && c2sTunnelRuntimes.get(tunnel.name) !== runtime) {
return { success: false, error: "Tunnel stopped" };
}
const ws = new WebSocket(
relayUrl,
getWebSocketOptions(relayUrl, { headers }),
);
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
if (runtime) runtime.authSocket = ws;
return new Promise((resolve) => {
let settled = false;
const settle = (result) => {
if (settled) return;
settled = true;
try {
if (!runtime || !result.success) ws.close();
} catch {
// expected during shutdown
}
resolve(result);
};
const timer = setTimeout(() => {
settle({ success: false, error: "Tunnel test timed out" });
}, 180_000);
ws.on("open", () => {
ws.send(
JSON.stringify({
type: "test",
keepAlive: !!runtime,
tunnelConfig,
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(createC2SFailure(message.error, "Tunnel test failed"));
}
} catch (error) {
clearTimeout(timer);
settle(createC2SFailure(error.message, "Tunnel test failed"));
}
});
ws.on("error", (error) => {
clearTimeout(timer);
settle(createC2SFailure(error.message, "Tunnel test failed"));
});
ws.on("close", () => {
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
setC2STunnelError(tunnel.name, "Endpoint SSH connection closed");
}
clearTimeout(timer);
settle({ success: false, error: "Tunnel test connection closed" });
});
});
}
async function testC2STunnel(tunnel, index = 0, authToken) {
const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel);
const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local";
const testTunnel = {
...normalizeC2STunnelAddresses(resolvedTunnel),
name: `${getC2STunnelName(resolvedTunnel, index)}::test`,
mode,
};
const localAddress = getC2SLocalAddress(testTunnel);
const remoteAddress = getC2SRemoteAddress(testTunnel);
const sourcePort = Number(resolvedTunnel.sourcePort);
const endpointPort = Number(resolvedTunnel.endpointPort);
if (!resolvedTunnel.sourceHostId) {
return { success: false, error: "Endpoint SSH host is required" };
}
if (mode === "remote") {
const localTarget = await checkTcpConnection(localAddress, endpointPort);
if (!localTarget.success) {
return {
success: false,
error: `Local target ${localAddress}:${endpointPort} is not reachable: ${localTarget.error}`,
};
}
return testC2SRelay(testTunnel, undefined, undefined, authToken);
}
if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) {
return { success: false, error: "Invalid local port" };
}
const runtime = c2sTunnelRuntimes.get(
getC2STunnelName(resolvedTunnel, index),
);
if (!runtime) {
const availability = await checkLocalPortAvailable(
localAddress,
sourcePort,
);
if (!availability.available) {
return {
success: false,
error: `Local listener ${localAddress}:${sourcePort} is not available: ${availability.error}`,
};
}
}
if (mode === "dynamic") {
return testC2SRelay(testTunnel, undefined, undefined, authToken);
}
if (!Number.isInteger(endpointPort) || endpointPort < 1) {
return { success: false, error: "Invalid remote port" };
}
return testC2SRelay(testTunnel, remoteAddress, endpointPort, authToken);
}
function handleC2SDynamicConnection(tunnel, socket, authToken) {
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,
authToken,
).catch((error) => {
const message = normalizeC2SErrorMessage(
error.message,
"Dynamic relay failed",
);
logToFile("[c2s] dynamic relay failed:", message);
fail(0x05, message);
});
}
} 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, authToken) {
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
const targetHost = getC2SRemoteAddress(tunnel);
const targetPort = Number(tunnel.endpointPort);
openC2SRelay(
tunnel,
targetHost,
targetPort,
socket,
undefined,
authToken,
).catch((error) => {
const message = normalizeC2SErrorMessage(
error.message,
"Local relay failed",
);
logToFile("[c2s] local relay failed:", message);
if (message === C2S_REMOTE_SESSION_EXPIRED_ERROR) {
setC2STunnelError(tunnelName, message);
}
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, authToken) {
const tunnelName = getC2STunnelName(tunnel, index);
const tunnelConfig = normalizeC2STunnelAddresses(tunnel);
const localHost = getC2SLocalAddress(tunnelConfig);
const remoteHost = getC2SRemoteAddress(tunnelConfig);
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.remoteAddress === remoteHost &&
runtime.sourcePort === remotePort
) {
return {
success: false,
error: `Another client remote tunnel already uses ${remoteHost}:${remotePort} on this endpoint`,
};
}
}
const relayUrl = getC2SRelayUrl();
const headers = await getC2SRelayHeaders(relayUrl, authToken);
const ws = new WebSocket(
relayUrl,
getWebSocketOptions(relayUrl, { headers }),
);
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
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,
remoteAddress: remoteHost,
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,
remoteHost,
remotePort,
localHost,
localPort,
});
ws.send(
JSON.stringify({
type: "open",
tunnelConfig: { ...tunnelConfig, name: tunnelName, mode: "remote" },
}),
);
});
ws.on("message", (data, isBinary) => {
if (isBinary) return;
let message;
try {
message = JSON.parse(data.toString());
} catch (error) {
const message = normalizeC2SErrorMessage(
error.message,
"Invalid relay message",
);
setC2STunnelError(tunnelName, message);
cleanup();
settle(createC2SFailure(message, "Invalid relay message"));
return;
}
if (message.type === "ready") {
setC2STunnelStatus(tunnelName, {
connected: true,
status: "CONNECTED",
});
settle({ success: true, tunnelName });
return;
}
if (message.type === "error") {
const error = normalizeC2SErrorMessage(
message.error,
"Relay rejected the client tunnel",
);
setC2STunnelError(tunnelName, error);
cleanup();
c2sTunnelRuntimes.delete(tunnelName);
emitC2STunnelStatuses();
settle(createC2SFailure(error, "Relay rejected the client tunnel"));
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) => {
const message = normalizeC2SErrorMessage(
error.message,
"Relay connection failed",
);
setC2STunnelError(tunnelName, message);
cleanup();
c2sTunnelRuntimes.delete(tunnelName);
emitC2STunnelStatuses();
settle(createC2SFailure(message, "Relay connection failed"));
});
});
}
async function startC2STunnel(tunnel, index = 0, authToken) {
const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel);
const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local";
const tunnelName = getC2STunnelName(resolvedTunnel, index);
const tunnelConfig = {
...normalizeC2STunnelAddresses(resolvedTunnel),
sessionId: crypto.randomUUID(),
};
const bindHost = getC2SLocalAddress(tunnelConfig);
const sourcePort = Number(resolvedTunnel.sourcePort);
logToFile(`[c2s] starting tunnel ${tunnelName}`, {
mode,
bindHost,
remoteAddress: getC2SRemoteAddress(tunnelConfig),
sourcePort,
sourceHostId: resolvedTunnel.sourceHostId,
sourceHostSyncId: resolvedTunnel.sourceHostSyncId,
endpointPort: resolvedTunnel.endpointPort,
});
if (mode === "remote") {
return startC2SRemoteTunnel(resolvedTunnel, index, authToken);
}
if (!resolvedTunnel.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) => {
if (!c2sTunnelRuntimes.get(tunnelName)?.status.connected) {
socket.destroy();
return;
}
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
if (mode === "dynamic") {
handleC2SDynamicConnection(
{ ...tunnelConfig, name: tunnelName, mode },
socket,
authToken,
);
} else {
handleC2SLocalConnection(
{ ...tunnelConfig, name: tunnelName, mode },
socket,
authToken,
);
}
});
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: false,
status: "CONNECTING",
reason: "Verifying endpoint SSH connection",
});
const runtime = c2sTunnelRuntimes.get(tunnelName);
const verifyTunnel = testC2SRelay(
{ ...tunnelConfig, name: tunnelName, mode },
mode === "dynamic" ? undefined : getC2SRemoteAddress(tunnelConfig),
mode === "dynamic" ? undefined : Number(tunnelConfig.endpointPort),
authToken,
runtime,
);
verifyTunnel
.then((result) => {
if (c2sTunnelRuntimes.get(tunnelName) !== runtime) return;
if (result.success) {
setC2STunnelStatus(tunnelName, {
connected: true,
status: "CONNECTED",
});
} else {
setC2STunnelError(
tunnelName,
result.error || "Endpoint SSH connection failed",
);
}
})
.catch((error) => {
if (c2sTunnelRuntimes.get(tunnelName) === runtime) {
setC2STunnelError(
tunnelName,
error.message || "Endpoint SSH connection failed",
);
}
});
resolve({ success: true, tunnelName });
});
});
}
async function stopC2STunnel(tunnelName) {
const runtime = c2sTunnelRuntimes.get(tunnelName);
if (!runtime) {
return { success: true };
}
setC2STunnelStatus(tunnelName, {
connected: false,
status: "DISCONNECTING",
});
runtime.authSocket?.close();
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 {
runtime.authSocket?.close();
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, authToken) => {
try {
return await startC2STunnel(
tunnel,
Number(index) || 0,
normalizeC2SAuthToken(authToken),
);
} catch (error) {
return createC2SFailure(error.message, "Failed to start client tunnel");
}
});
ipcMain.handle("test-c2s-tunnel", async (_event, tunnel, index, authToken) => {
try {
return await testC2STunnel(
tunnel,
Number(index) || 0,
normalizeC2SAuthToken(authToken),
);
} catch (error) {
return createC2SFailure(error.message, "Failed to test client tunnel");
}
});
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("local-terminal-start", (event, dimensions = {}) => {
const cols = Math.min(500, Math.max(2, Number(dimensions.cols) || 80));
const rows = Math.min(300, Math.max(1, Number(dimensions.rows) || 24));
const sessionId = crypto.randomUUID();
const shellConfig = resolveLocalShell(process.platform, dimensions.shell);
const spawnShell = () =>
pty.spawn(shellConfig.file, shellConfig.args, {
name: "xterm-256color",
cols,
rows,
cwd: os.homedir(),
env: {
...process.env,
TERM: "xterm-256color",
COLORTERM: "truecolor",
},
});
let child;
try {
child = spawnShell();
} catch (error) {
const ptyDir = path.dirname(require.resolve("node-pty/package.json"));
if (
!String(error?.message).includes("posix_spawnp") ||
repairSpawnHelpers(ptyDir) === 0
) {
throw error;
}
logToFile("[local-terminal] restored the spawn-helper execute bit");
child = spawnShell();
}
const ownerId = event.sender.id;
const session = { ownerId, process: child, ready: false, buffered: "" };
localTerminalSessions.set(sessionId, session);
child.onData((data) => {
if (!session.ready) {
session.buffered = (session.buffered + data).slice(-1024 * 1024);
return;
}
if (!event.sender.isDestroyed()) {
event.sender.send(`local-terminal:data:${sessionId}`, data);
}
});
child.onExit(({ exitCode }) => {
localTerminalSessions.delete(sessionId);
if (!event.sender.isDestroyed()) {
event.sender.send(`local-terminal:exit:${sessionId}`, exitCode);
}
});
event.sender.once("destroyed", () => closeLocalTerminalsFor(ownerId));
return { sessionId, shell: shellConfig.file };
});
ipcMain.handle("local-terminal-ready", (event, sessionId) => {
const session = ownedLocalTerminal(event, sessionId);
if (!session) return false;
session.ready = true;
if (session.buffered && !event.sender.isDestroyed()) {
event.sender.send(`local-terminal:data:${sessionId}`, session.buffered);
session.buffered = "";
}
return true;
});
ipcMain.handle("local-terminal-write", (event, sessionId, data) => {
const session = ownedLocalTerminal(event, sessionId);
if (!session || typeof data !== "string" || data.length > 64 * 1024) {
return false;
}
session.process.write(data);
return true;
});
ipcMain.handle("local-terminal-resize", (event, sessionId, cols, rows) => {
const session = ownedLocalTerminal(event, sessionId);
const width = Number(cols);
const height = Number(rows);
if (
!session ||
!Number.isInteger(width) ||
!Number.isInteger(height) ||
width < 2 ||
width > 500 ||
height < 1 ||
height > 300
) {
return false;
}
session.process.resize(width, height);
return true;
});
ipcMain.handle("local-terminal-close", (event, sessionId) => {
const session = ownedLocalTerminal(event, sessionId);
if (!session) return false;
localTerminalSessions.delete(sessionId);
session.process.kill();
return true;
});
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 || {});
});
function toLocalFileType(stat) {
if (stat.isDirectory()) return "directory";
if (stat.isFile()) return "file";
if (stat.isSymbolicLink()) return "link";
return "other";
}
function getLocalEntry(entryPath, name = path.basename(entryPath)) {
const stat = fs.lstatSync(entryPath);
return {
name,
path: entryPath,
type: toLocalFileType(stat),
size: stat.isFile() ? stat.size : 0,
created: stat.birthtime.toISOString(),
modified: stat.mtime.toISOString(),
modifiedTimestamp: stat.mtimeMs,
hidden: name.startsWith("."),
permissions: (stat.mode & 0o777).toString(8).padStart(3, "0"),
owner: String(stat.uid),
group: String(stat.gid),
};
}
function normalizeRelativeLocalPath(relativePath) {
return relativePath.split(path.sep).join("/");
}
function validateLocalName(name) {
if (typeof name !== "string" || !name.trim()) {
throw new Error("Missing name");
}
const trimmed = name.trim();
if (
trimmed.includes("/") ||
trimmed.includes("\\") ||
trimmed === "." ||
trimmed === ".."
) {
throw new Error("Name cannot contain path separators");
}
return trimmed;
}
function ensureLocalDirectory(dirPath) {
if (typeof dirPath !== "string" || !dirPath) {
throw new Error("Missing directory path");
}
const stat = fs.statSync(dirPath);
if (!stat.isDirectory()) {
throw new Error("Path is not a directory");
}
}
function collectLocalFilesFromPath(rootPath, rootName, files, limit) {
if (files.length >= limit) return;
const stat = fs.lstatSync(rootPath);
if (stat.isSymbolicLink()) return;
if (stat.isFile()) {
files.push({
path: rootPath,
name: path.basename(rootPath),
relativePath: normalizeRelativeLocalPath(rootName),
size: stat.size,
created: stat.birthtime.toISOString(),
modified: stat.mtime.toISOString(),
});
return;
}
if (!stat.isDirectory()) return;
for (const childName of fs.readdirSync(rootPath)) {
if (files.length >= limit) return;
const childPath = path.join(rootPath, childName);
collectLocalFilesFromPath(
childPath,
path.join(rootName, childName),
files,
limit,
);
}
}
ipcMain.handle("get-local-home-directory", () => os.homedir());
ipcMain.handle("list-local-directory", (_event, dirPath) => {
try {
const requestedPath =
typeof dirPath === "string" && dirPath.trim() ? dirPath : os.homedir();
const stat = fs.statSync(requestedPath);
if (!stat.isDirectory()) {
return {
success: false,
path: requestedPath,
entries: [],
error: "Path is not a directory",
};
}
const entries = fs
.readdirSync(requestedPath, { withFileTypes: true })
.map((entry) => {
try {
return getLocalEntry(
path.join(requestedPath, entry.name),
entry.name,
);
} catch {
return null;
}
})
.filter(Boolean)
.sort((a, b) => {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
return a.name.localeCompare(b.name);
});
return {
success: true,
path: requestedPath,
parent: path.dirname(requestedPath),
entries,
};
} catch (error) {
return {
success: false,
path: typeof dirPath === "string" ? dirPath : "",
entries: [],
error: error instanceof Error ? error.message : "Failed to list path",
};
}
});
ipcMain.handle("stat-local-paths", (_event, paths) => {
const pathList = Array.isArray(paths) ? paths : [];
return pathList.map((entryPath) => {
try {
return { success: true, ...getLocalEntry(entryPath) };
} catch (error) {
return {
success: false,
path: entryPath,
error: error instanceof Error ? error.message : "Failed to stat path",
};
}
});
});
ipcMain.handle("collect-local-files", (_event, paths) => {
try {
const pathList = Array.isArray(paths) ? paths : [];
const files = [];
const limit = 10000;
for (const entryPath of pathList) {
if (typeof entryPath !== "string" || !entryPath) continue;
collectLocalFilesFromPath(
entryPath,
path.basename(entryPath),
files,
limit + 1,
);
if (files.length > limit) break;
}
return {
success: true,
files: files.slice(0, limit),
truncated: files.length > limit,
};
} catch (error) {
return {
success: false,
files: [],
error: error instanceof Error ? error.message : "Failed to collect files",
};
}
});
ipcMain.handle("create-local-folder", (_event, parentPath, folderName) => {
try {
ensureLocalDirectory(parentPath);
const safeName = validateLocalName(folderName);
const targetPath = path.join(parentPath, safeName);
fs.mkdirSync(targetPath);
return { success: true, ...getLocalEntry(targetPath, safeName) };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to create folder",
};
}
});
ipcMain.handle("rename-local-path", (_event, entryPath, newName) => {
try {
if (typeof entryPath !== "string" || !entryPath) {
throw new Error("Missing path");
}
const safeName = validateLocalName(newName);
const targetPath = path.join(path.dirname(entryPath), safeName);
renameLocalPath(entryPath, targetPath);
return { success: true, ...getLocalEntry(targetPath, safeName) };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to rename path",
};
}
});
ipcMain.handle("trash-local-path", async (_event, entryPath) => {
try {
if (typeof entryPath !== "string" || !entryPath) {
throw new Error("Missing path");
}
await shell.trashItem(entryPath);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to delete path",
};
}
});
ipcMain.handle("chmod-local-path", (_event, entryPath, permissions) => {
try {
if (typeof entryPath !== "string" || !entryPath) {
throw new Error("Missing path");
}
if (typeof permissions !== "string" || !/^[0-7]{3,4}$/.test(permissions)) {
throw new Error("Permissions must be an octal value");
}
fs.chmodSync(entryPath, parseInt(permissions, 8));
return { success: true, ...getLocalEntry(entryPath) };
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : "Failed to update permissions",
};
}
});
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 };
}
});
// Local disk browsing + streamed local<->remote transfers for the file
// manager's dual-pane mode (see electron/local-files.cjs).
registerLocalFileHandlers({ ipcMain, shell });
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 the dev runner's backend",
);
if (isDevRunner()) {
process.on("message", (msg) => void handleBackendRequest(msg));
}
}
createTray();
createWindow();
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 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);
});