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 autoUpdates = require("./auto-updater.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, setLocalBackendPort, } = require("./local-files.cjs"); const { EXTERNAL_EDITOR_SETTING, isLaunchableFileName, } = require("./external-editor.cjs"); const { isExternalLoginUrl, isCallbackPort } = require("./external-login.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 isPrivateNetworkHost(hostname) { if ( hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" ) { return true; } const ipv4Match = hostname.match( /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/, ); if (ipv4Match) { const [, a, b] = ipv4Match.map(Number); return ( a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) ); } if (hostname.startsWith("fc") || hostname.startsWith("fd")) { return true; } return false; } function isInvalidCertificateAllowedForUrl(url) { if (isInsecureModeEnabled()) return true; try { const { hostname } = new URL(url); if (isPrivateNetworkHost(hostname)) return true; } catch { // fall through } // Otherwise only the server this desktop is linked to, when its link // allows a self-signed certificate. const linked = linkedServer.getLinkedServer(); if (!linked?.allowInvalidCertificate) return false; return linkedServer.isLinkedOrigin(url); } function getTlsVerificationOptions(url) { return { rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url), }; } 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(); // Paths the user picked in an open dialog this session. Only these may be // saved as the external editor, so the renderer cannot name an executable. const dialogChosenPaths = new Set(); const isDev = process.env.NODE_ENV === "development" || !app.isPackaged; const appRoot = isDev ? process.cwd() : path.join(__dirname, ".."); const windowsAppUserModelId = "com.karmaa.termix"; const electronCacheBuildPath = path.join( app.getPath("userData"), "client-cache-build.json", ); const termixSessionPartition = "persist:termix"; function getTempRoot() { const tempRoot = path.join(app.getPath("temp"), "termix"); fs.mkdirSync(tempRoot, { recursive: true }); return tempRoot; } function sanitizeFileName(fileName) { const baseName = path.basename(String(fileName || "file")); return baseName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") || "file"; } function decodeFileContent(content, encoding) { if (encoding === "base64") { return Buffer.from(String(content || ""), "base64"); } return Buffer.from(String(content || ""), "utf8"); } function createManagedTempFile(fileName, content, encoding = "utf8") { const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`)); const filePath = path.join(tempDir, sanitizeFileName(fileName)); fs.writeFileSync(filePath, decodeFileContent(content, encoding)); tempFiles.set(tempId, { path: filePath, dir: tempDir }); return { tempId, path: filePath }; } function cleanupManagedTempFile(tempId) { const temp = tempFiles.get(tempId); if (!temp) return; try { fs.rmSync(temp.dir, { recursive: true, force: true }); } catch (error) { logToFile("Failed to clean up temporary file:", error.message); } tempFiles.delete(tempId); } 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 } } const DEFAULT_BACKEND_PORT = 30001; let backendPort = DEFAULT_BACKEND_PORT; function canListenOn(port) { return new Promise((resolve) => { const probe = net.createServer(); probe.once("error", () => resolve(null)); probe.listen(port, "127.0.0.1", () => { const { port: bound } = probe.address(); probe.close(() => resolve(bound)); }); }); } // 30001 when it is free, otherwise any free port. Only the desktop app talks // to this backend, so the number itself does not matter. async function findBackendPort() { const preferred = await canListenOn(DEFAULT_BACKEND_PORT); if (preferred) return preferred; const fallback = await canListenOn(0); if (fallback) { logToFile( `Port ${DEFAULT_BACKEND_PORT} is in use, starting the backend on ${fallback}`, ); return fallback; } return DEFAULT_BACKEND_PORT; } function startBackendServer() { reapOrphanedBackendProcess(); backendFailure = null; backendStopRequested = false; backendStderrTail = ""; return new Promise((resolve) => { const { entryPath, backendCwd } = getBackendPaths(); logToFile("isDev:", isDev, "appRoot:", appRoot); logToFile("app.isPackaged:", app.isPackaged); logToFile("process.env.NODE_ENV:", process.env.NODE_ENV); if (!fs.existsSync(entryPath)) { logToFile("Backend entry not found:", entryPath); backendFailure = { reason: "crashed", port: null }; resolve(false); return; } const dataDir = getBackendDataDir(); logToFile("Starting embedded backend server..."); logToFile("Backend entry:", entryPath); logToFile("Data directory:", dataDir); logToFile("Backend cwd:", backendCwd); logToFile("Checking paths..."); logToFile(" entryPath exists:", fs.existsSync(entryPath)); logToFile(" dataDir exists:", fs.existsSync(dataDir)); logToFile(" backendCwd exists:", fs.existsSync(backendCwd)); backendProcess = fork(entryPath, [], { cwd: fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory() ? backendCwd : dataDir, env: { ...process.env, DATA_DIR: dataDir, NODE_ENV: "production", ELECTRON_EMBEDDED: "true", PORT: String(backendPort), EMBEDDED_BACKEND_PORT: String(backendPort), VERSION: app.getVersion(), }, stdio: ["pipe", "pipe", "pipe", "ipc"], }); logToFile("Backend process spawned, pid:", backendProcess.pid); try { fs.writeFileSync(getBackendPidFilePath(), String(backendProcess.pid)); } catch { // Non-fatal: only means a future crash won't self-heal via reap. } let resolved = false; const readyTimeout = setTimeout(() => { if (!resolved) { resolved = true; logToFile("Backend ready timeout (15s), proceeding anyway..."); resolve(true); } }, 15000); backendProcess.stdout.on("data", (data) => { const msg = data.toString().trim(); logToFile("[backend]", msg); if (!resolved && msg.includes("started successfully")) { resolved = true; clearTimeout(readyTimeout); logToFile("Backend ready signal received"); resolve(true); } }); // The reason for a failed start is only ever visible in what the child // wrote to stderr before dying, so keep a bounded tail of it. Bounded // because a backend that stays up for days can otherwise log without // limit into a buffer nothing ever drains. backendProcess.stderr.on("data", (data) => { const chunk = data.toString(); backendStderrTail = (backendStderrTail + chunk).slice( -BACKEND_STDERR_TAIL_LIMIT, ); logToFile("[backend:stderr]", chunk.trim()); }); backendProcess.on("message", (msg) => { if (!resolved && msg?.type === "backend-ready") { resolved = true; clearTimeout(readyTimeout); logToFile("Backend ready signal received via IPC"); resolve(true); } void handleBackendRequest(msg); }); backendProcess.on("exit", (code, signal) => { logToFile(`Backend process exited with code ${code}, signal ${signal}`); if (!resolved && code !== 0) { backendStartFailed = true; } // Classified whether or not the ready promise already settled: the // 15s ready timeout resolves optimistically, so a backend that dies // at second 20 still has to be reported rather than silently dropped. // backendStopRequested is the only evidence that an exit was asked // for, so it is the sole guard here -- past it, every exit means the // backend is gone and the renderer must stop waiting for it. if (!backendStopRequested) { backendFailure = classifyBackendFailure(backendStderrTail); logToFile("Backend failure classified as:", backendFailure.reason); } backendProcess = null; clearBackendPidFile(); if (!resolved) { resolved = true; clearTimeout(readyTimeout); resolve(false); } }); backendProcess.on("error", (err) => { logToFile("Failed to start backend process:", err.message); backendFailure = { reason: "crashed", port: null }; backendProcess = null; if (!resolved) { resolved = true; clearTimeout(readyTimeout); resolve(false); } }); }); } function clearBackendPidFile() { try { fs.unlinkSync(getBackendPidFilePath()); } catch { // already absent } } function stopBackendServer() { if (!backendProcess) return; console.log("Stopping embedded backend server..."); backendStopRequested = true; try { backendProcess.send({ type: "shutdown" }); } catch { // IPC channel may already be closed } const forceKillTimeout = setTimeout(() => { if (backendProcess) { console.log("Force killing backend process..."); backendProcess.kill("SIGKILL"); backendProcess = null; } }, 5000); backendProcess.on("exit", () => { clearTimeout(forceKillTimeout); backendProcess = null; clearBackendPidFile(); }); } // App Sandbox denies the singleton socket bind, which reads as a held lock and // quits the app on every launch. macOS reactivates bundled apps anyway. const gotTheLock = process.mas || app.requestSingleInstanceLock(); if (!gotTheLock) { console.log("Another instance is already running, quitting..."); app.quit(); process.exit(0); } else { app.on("second-instance", (event, commandLine, workingDirectory) => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); mainWindow.show(); } }); } function createTray() { try { const { nativeImage } = require("electron"); // Native APIs (Tray, nativeImage) can't load files from inside app.asar, // use the unpacked path so the OS sees a real file. const publicRoot = isDev ? path.join(appRoot, "public") : path.join( appRoot.replace( /app(-[a-z0-9]+)?\.asar(?!\.unpacked)/, "app.asar.unpacked", ), "public", ); let trayIcon; if (process.platform === "darwin") { const iconPath = path.join(publicRoot, "icons", "16x16.png"); trayIcon = nativeImage.createFromPath(iconPath); trayIcon.setTemplateImage(true); } else if (process.platform === "win32") { trayIcon = path.join(publicRoot, "icon.ico"); } else { trayIcon = loadLinuxTrayIcon(nativeImage, publicRoot); if (!trayIcon) { logToFile("Tray icon not found, skipping the system tray"); return; } if (/gnome/i.test(process.env.XDG_CURRENT_DESKTOP || "")) { logToFile( "GNOME shows tray icons only with the AppIndicator extension installed", ); } } tray = new Tray(trayIcon); tray.setToolTip("Termix"); const contextMenu = Menu.buildFromTemplate([ { label: "Show Window", click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } }, }, { label: "Quit", click: () => { isQuitting = true; quitApp(app, mainWindow); }, }, ]); tray.setContextMenu(contextMenu); tray.on("click", () => { if (mainWindow) { if (mainWindow.isVisible()) { mainWindow.hide(); } else { mainWindow.show(); mainWindow.focus(); } } }); logToFile("System tray created"); } catch (err) { tray = null; logToFile("Failed to create system tray:", err?.message || String(err)); } } function loadLinuxTrayIcon(nativeImage, publicRoot) { const roots = [ publicRoot, path.join(process.resourcesPath || "", "app.asar.unpacked", "public"), ]; for (const root of roots) { for (const size of ["32x32", "24x24", "48x48"]) { const image = nativeImage.createFromPath( path.join(root, "icons", `${size}.png`), ); if (!image.isEmpty()) return image; } } return null; } function createWindow() { const appVersion = app.getVersion(); const electronVersion = process.versions.electron; const platform = process.platform === "win32" ? "Windows" : process.platform === "darwin" ? "macOS" : "Linux"; mainWindow = new BrowserWindow({ width: 1200, height: 800, minWidth: 800, minHeight: 600, title: "Termix", icon: path.join( appRoot, "public", process.platform === "win32" ? "icon.ico" : "icon.png", ), webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, preload: path.join(__dirname, "preload.js"), additionalArguments: [`--termix-backend-port=${backendPort}`], partition: termixSessionPartition, allowRunningInsecureContent: false, webviewTag: false, offscreen: false, }, show: true, }); mainWindow.webContents.session.setPermissionRequestHandler( (webContents, permission, callback) => { if ( permission === "clipboard-read" || permission === "clipboard-write" || permission === "clipboard-sanitized-write" ) { callback(true); return; } callback(false); }, ); if (process.platform !== "darwin") { mainWindow.setMenuBarVisibility(false); } const customUserAgent = `Termix-Desktop/${appVersion} (${platform}; Electron/${electronVersion})`; mainWindow.webContents.setUserAgent(customUserAgent); mainWindow.webContents.on("before-input-event", (event, input) => { if (process.platform !== "win32" || !isCloseActiveTabInput(input)) return; event.preventDefault(); mainWindow.webContents.send("close-active-tab"); }); mainWindow.webContents.session.webRequest.onBeforeSendHeaders( (details, callback) => { details.requestHeaders["X-Electron-App"] = "true"; details.requestHeaders["User-Agent"] = customUserAgent; // YouTube refuses embeds without a Referer, which file:// never sends. if ( details.url.startsWith("https://www.youtube-nocookie.com/embed/") && !details.requestHeaders.Referer ) { details.requestHeaders.Referer = "https://termix.site/"; } const rememberedJwt = getRememberedElectronAuthCookie("jwt", details.url); if (rememberedJwt) { setCookieHeaderValue( details.requestHeaders, rememberedJwt.name, rememberedJwt.value, ); } callback({ requestHeaders: linkedServer.applyLinkHeaders( details.url, details.requestHeaders, ), }); }, ); if (isDev) { mainWindow.loadURL("http://localhost:5173"); mainWindow.webContents.openDevTools(); } else { const indexPath = path.join(appRoot, "dist", "index.html"); mainWindow.loadFile(indexPath).catch((err) => { console.error("Failed to load file:", err); }); } mainWindow.webContents.session.webRequest.onHeadersReceived( (details, callback) => { const headers = details.responseHeaders; if (headers) { delete headers["x-frame-options"]; delete headers["X-Frame-Options"]; if (headers["content-security-policy"]) { headers["content-security-policy"] = headers[ "content-security-policy" ] .map((value) => value.replace(/frame-ancestors[^;]*/gi, "")) .filter((value) => value.trim().length > 0); if (headers["content-security-policy"].length === 0) { delete headers["content-security-policy"]; } } if (headers["Content-Security-Policy"]) { headers["Content-Security-Policy"] = headers[ "Content-Security-Policy" ] .map((value) => value.replace(/frame-ancestors[^;]*/gi, "")) .filter((value) => value.trim().length > 0); if (headers["Content-Security-Policy"].length === 0) { delete headers["Content-Security-Policy"]; } } const setCookieHeaderName = getHeaderName(headers, "Set-Cookie"); if (setCookieHeaderName) { const setCookieHeaders = Array.isArray(headers[setCookieHeaderName]) ? headers[setCookieHeaderName] : [headers[setCookieHeaderName]]; setCookieHeaders.forEach((cookie) => { rememberElectronAuthCookieFromHeader(details.url, cookie); }); headers[setCookieHeaderName] = setCookieHeaders.map((cookie) => { let modified = cookie.replace( /;\s*SameSite=Strict/gi, "; SameSite=None", ); modified = modified.replace( /;\s*SameSite=Lax/gi, "; SameSite=None", ); if (!modified.includes("SameSite=")) { modified += "; SameSite=None"; } if ( !modified.includes("Secure") && details.url.startsWith("https") ) { modified += "; Secure"; } return modified; }); } } callback({ responseHeaders: headers }); }, ); mainWindow.once("ready-to-show", () => { mainWindow.show(); mainWindow.focus(); }); setTimeout(() => { if (mainWindow && !mainWindow.isVisible()) { mainWindow.show(); } }, 3000); mainWindow.webContents.on( "did-fail-load", (event, errorCode, errorDescription, validatedURL) => { console.error( "Failed to load:", errorCode, errorDescription, validatedURL, ); }, ); mainWindow.webContents.on("did-finish-load", () => { console.log("Frontend loaded successfully"); }); mainWindow.webContents.on("will-prevent-unload", (event) => { // Explicit Quit must not be vetoed by the renderer's connection guard. // In this Electron event, preventDefault allows the unload to proceed. if (isQuitting) event.preventDefault(); }); mainWindow.on("close", (event) => { if (!isQuitting && tray && !tray.isDestroyed()) { event.preventDefault(); // Stock GNOME has no tray to bring a hidden window back from. if ( process.platform === "linux" && /gnome/i.test(process.env.XDG_CURRENT_DESKTOP || "") ) { mainWindow.minimize(); } else { mainWindow.hide(); } } }); mainWindow.on("closed", () => { mainWindow = null; }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { try { const parsed = new URL(url); if (parsed.protocol === "http:" || parsed.protocol === "https:") { shell.openExternal(url); } } catch { // invalid URL, ignore } return { action: "deny" }; }); mainWindow.webContents.on("will-navigate", (event, url) => { const allowedUrl = isDev ? url.startsWith("http://localhost:5173/") : url === pathToFileURL(path.join(appRoot, "dist", "index.html")).href; if (!allowedUrl) event.preventDefault(); }); } ipcMain.handle("get-app-version", () => { return app.getVersion(); }); ipcMain.handle("get-platform", () => { return process.platform; }); ipcMain.handle("get-embedded-server-status", () => { return { running: backendProcess !== null && !backendProcess.killed && !backendStartFailed, // A backend that is merely slow to boot reports no failure, so the // renderer keeps waiting for it. Only a classified failure means the // process is gone for good and waiting is pointless. failure: backendFailure, dataDir: isDev ? null : getBackendDataDir(), }; }); // External login through the system browser (RFC 8252) ipcMain.handle( "external-browser-login", async (_event, authUrl, callbackPort) => { if (!isExternalLoginUrl(authUrl)) { return { success: false, error: "Invalid authentication URL" }; } if (!isCallbackPort(callbackPort)) { return { success: false, error: "Invalid callback port" }; } const http = require("http"); return new Promise((resolve) => { let timeout; let settled = false; const server = http.createServer((req, res) => { const url = new URL(req.url || "/", `http://localhost:${callbackPort}`); if (url.pathname === "/oidc-callback") { const success = url.searchParams.get("success"); const error = url.searchParams.get("error"); const token = url.searchParams.get("token"); const secondFactor = url.searchParams.get("second_factor") === "1"; res.writeHead(200, { "Content-Type": "text/html" }); res.end( `
You can close this tab and return to Termix.
`, ); if (secondFactor) { finish({ success: false, secondFactor: true, tempToken: url.searchParams.get("temp_token"), factors: url.searchParams.get("second_factors"), }); } else if (success === "true") { finish({ success: true, token }); } else { finish({ success: false, error: error || "Authentication failed", }); } return; } res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not found"); }); const finish = (result) => { if (settled) return; settled = true; if (timeout) clearTimeout(timeout); try { server.close(); } catch { // Server may not have started yet. } resolve(result); }; const fail = (error) => { finish({ success: false, error: error instanceof Error ? error.message : String(error), }); }; server.once("error", fail); server.listen(callbackPort, "localhost", async () => { try { await shell.openExternal(authUrl); } catch (error) { fail(error); } }); // Timeout after 5 minutes timeout = setTimeout( () => { fail(new Error("External login timed out")); }, 5 * 60 * 1000, ); }); }, ); // Desktop-only settings, such as whether hosts connect from this device or // through the linked server by default. ipcMain.handle("get-desktop-settings", () => { return desktopSettings.getDesktopSettings(); }); ipcMain.handle("save-desktop-settings", (_event, settings) => { return desktopSettings.saveDesktopSettings(settings); }); let appUpdater = null; function startAppUpdater() { const kind = autoUpdates.getInstallKind({ platform: process.platform, arch: process.arch, isPackaged: app.isPackaged, mas: !!process.mas, env: process.env, execPath: process.execPath, resourcesPath: process.resourcesPath, exists: (target) => fs.existsSync(target), readFile: (target) => fs.readFileSync(target, "utf8"), }); logToFile("Updater install kind:", kind); appUpdater = autoUpdates.createUpdater({ currentVersion: app.getVersion(), platform: process.platform, arch: process.arch, kind, getMode: () => desktopSettings.getDesktopSettings().updateMode, saveMode: (mode) => desktopSettings.saveDesktopSettings({ updateMode: mode }), fetchReleases: async () => { const { net } = require("electron"); const response = await net.fetch(autoUpdates.RELEASES_API, { headers: { Accept: "application/vnd.github+json" }, }); if (!response.ok) { throw new Error(`GitHub releases returned ${response.status}`); } return response.json(); }, loadAutoUpdater: () => require("electron-updater").autoUpdater, send: (state) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send("desktop-update-state", state); } }, log: logToFile, beforeInstall: () => { isQuitting = true; }, }); appUpdater.start(); } ipcMain.handle( "desktop-update-get-state", () => appUpdater?.getState() ?? null, ); ipcMain.handle("desktop-update-check", () => appUpdater?.check() ?? null); ipcMain.handle("desktop-update-download", () => appUpdater?.download() ?? null); ipcMain.handle("desktop-update-install", () => appUpdater?.install() ?? false); ipcMain.handle( "desktop-update-set-mode", (_event, mode) => appUpdater?.setMode(mode) ?? null, ); // The server an older version of the app was pointed at, offered again when // linking so upgraded installs do not have to look it up. ipcMain.handle("get-previous-server-url", () => { return { serverUrl: desktopSettings.getPreviousServerUrl() }; }); function 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; // A proxy in front of the server may drop an idle WebSocket, which stopped // the tunnel. Pings count as traffic. function keepC2SSocketAlive(ws) { const timer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) ws.ping(); }, 25_000); timer.unref?.(); ws.once("close", () => clearInterval(timer)); } // 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)); keepC2SSocketAlive(ws); 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)); keepC2SSocketAlive(ws); if (runtime) runtime.authSocket = ws; return new Promise((resolve) => { let settled = false; // The relay sends its reason and then closes; keep the reason. let relayError = null; 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); relayError = normalizeC2SErrorMessage( message.error, "Tunnel test failed", ); 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, relayError || "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)); keepC2SSocketAlive(ws); 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})`; }); function readSetting(key) { const settingsPath = path.join(app.getPath("userData"), "settings.json"); if (!fs.existsSync(settingsPath)) return null; const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); return settings[key] !== undefined ? settings[key] : null; } ipcMain.handle("get-setting", (event, key) => { try { return readSetting(key); } catch (error) { console.error("Error reading setting:", error); return null; } }); ipcMain.handle("set-setting", (event, key, value) => { if (typeof key !== "string") { return { success: false, error: "Invalid setting key" }; } if ( key === EXTERNAL_EDITOR_SETTING && value && !dialogChosenPaths.has(value) ) { return { success: false, error: "Choose the editor in the file dialog" }; } try { const userDataPath = app.getPath("userData"); const settingsPath = path.join(userDataPath, "settings.json"); if (!fs.existsSync(userDataPath)) { fs.mkdirSync(userDataPath, { recursive: true }); } let settings = {}; if (fs.existsSync(settingsPath)) { const settingsData = fs.readFileSync(settingsPath, "utf8"); settings = JSON.parse(settingsData); } settings[key] = value; fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); return { success: true }; } catch (error) { console.error("Error saving setting:", error); return { success: false, error: error.message }; } }); ipcMain.handle("clear-session-cookies", async () => { try { clearPersistedElectronAuthCookies(); const ses = mainWindow?.webContents?.session; if (ses) { const cookies = await ses.cookies.get({}); for (const cookie of cookies) { await ses.cookies.remove(getCookieRemovalUrl(cookie), cookie.name); } } } catch (error) { console.error("Failed to clear session cookies:", error); } }); ipcMain.handle("clipboard-write-text", (_event, text) => { clipboard.writeText(typeof text === "string" ? text : String(text ?? "")); return true; }); ipcMain.handle("clipboard-read-text", () => clipboard.readText()); ipcMain.handle("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) => { const result = await dialog.showOpenDialog(mainWindow, options || {}); for (const filePath of result.filePaths) dialogChosenPaths.add(filePath); return result; }); 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 { // The editor comes from the saved setting, never from the caller. const editorPath = readSetting(EXTERNAL_EDITOR_SETTING) || null; if (!editorPath && isLaunchableFileName(fileData?.fileName)) { return { success: false, error: "Choose an external editor to open this file type", }; } 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 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) { backendPort = await findBackendPort(); setLocalBackendPort(backendPort); const result = await startBackendServer(); logToFile("startBackendServer result:", result); } else { logToFile( "Skipping embedded backend (isDev=true) - expecting the dev runner's backend", ); if (isDevRunner()) { process.on("message", (msg) => void handleBackendRequest(msg)); } } createTray(); createWindow(); startAppUpdater(); desktopSettings.removeOldSyncFiles(); logToFile("=== Startup complete ==="); }); app.on("window-all-closed", () => { if (!tray || tray.isDestroyed()) { app.quit(); } }); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } else if (mainWindow) { mainWindow.show(); mainWindow.focus(); } }); app.on("before-quit", () => { isQuitting = true; }); app.on("will-quit", () => { console.log("App will quit..."); for (const 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); });