const { app, BrowserWindow, shell, ipcMain, dialog, Menu, session, safeStorage, Tray, } = require("electron"); const path = require("path"); const fs = require("fs"); const os = require("os"); const https = require("https"); const http = require("http"); const net = require("net"); const { URL } = require("url"); const { fork } = require("child_process"); const WebSocket = require("ws"); 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 getTlsVerificationOptions() { return { rejectUnauthorized: !isInsecureModeEnabled(), }; } function getWebSocketOptions(url, options = {}) { return { ...options, ...(String(url).startsWith("wss:") ? getTlsVerificationOptions() : {}), }; } function httpFetch(url, options = {}) { return new Promise((resolve, reject) => { const urlObj = new URL(url); const isHttps = urlObj.protocol === "https:"; const client = isHttps ? https : http; const requestOptions = { method: options.method || "GET", headers: options.headers || {}, timeout: options.timeout || 10000, ...(isHttps ? getTlsVerificationOptions() : {}), }; const req = client.request(url, requestOptions, (res) => { let data = ""; res.on("data", (chunk) => (data += chunk)); res.on("end", () => { resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, text: () => Promise.resolve(data), json: () => Promise.resolve(JSON.parse(data)), }); }); }); req.on("error", reject); req.on("timeout", () => { req.destroy(); reject(new Error("Request timeout")); }); if (options.body) { req.write(options.body); } req.end(); }); } if (process.platform === "linux") { app.commandLine.appendSwitch("--ozone-platform-hint=auto"); app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder"); } if (isInsecureModeEnabled()) { logToFile( "[security] ENABLE_INSECURE_MODE is enabled; TLS certificate validation is disabled.", ); app.commandLine.appendSwitch("--ignore-certificate-errors"); app.commandLine.appendSwitch("--ignore-ssl-errors"); app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list"); } app.commandLine.appendSwitch("--enable-features=NetworkService"); let mainWindow = null; let backendProcess = null; let tray = null; let isQuitting = false; const isDev = process.env.NODE_ENV === "development" || !app.isPackaged; const appRoot = isDev ? process.cwd() : path.join(__dirname, ".."); const electronCacheBuildPath = path.join( app.getPath("userData"), "client-cache-build.json", ); const termixSessionPartition = "persist:termix"; 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 getBackendEntryPath() { if (isDev) { return path.join(appRoot, "dist", "backend", "backend", "starter.js"); } return path.join(appRoot, "dist", "backend", "backend", "starter.js"); } 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 startBackendServer() { return new Promise((resolve) => { const entryPath = getBackendEntryPath(); logToFile("isDev:", isDev, "appRoot:", appRoot); logToFile("app.isPackaged:", app.isPackaged); logToFile("process.env.NODE_ENV:", process.env.NODE_ENV); if (!fs.existsSync(entryPath)) { logToFile("Backend entry not found:", entryPath); resolve(false); return; } const dataDir = getBackendDataDir(); logToFile("Starting embedded backend server..."); logToFile("Backend entry:", entryPath); logToFile("Data directory:", dataDir); logToFile("Backend cwd:", appRoot); logToFile("Checking paths..."); logToFile(" entryPath exists:", fs.existsSync(entryPath)); logToFile(" dataDir exists:", fs.existsSync(dataDir)); logToFile(" appRoot exists:", fs.existsSync(appRoot)); const distPath = path.join(appRoot, "dist"); if (fs.existsSync(distPath)) { logToFile(" dist directory contents:", fs.readdirSync(distPath)); const backendPath = path.join(distPath, "backend"); if (fs.existsSync(backendPath)) { logToFile(" dist/backend contents:", fs.readdirSync(backendPath)); } } backendProcess = fork(entryPath, [], { cwd: appRoot, env: { ...process.env, DATA_DIR: dataDir, NODE_ENV: "production", ELECTRON_EMBEDDED: "true", PORT: "30001", }, stdio: ["pipe", "pipe", "pipe", "ipc"], }); logToFile("Backend process spawned, pid:", backendProcess.pid); let resolved = false; const readyTimeout = setTimeout(() => { if (!resolved) { resolved = true; logToFile("Backend ready timeout (15s), proceeding anyway..."); resolve(true); } }, 15000); backendProcess.stdout.on("data", (data) => { const msg = data.toString().trim(); logToFile("[backend]", msg); if (!resolved && msg.includes("started successfully")) { resolved = true; clearTimeout(readyTimeout); logToFile("Backend ready signal received"); resolve(true); } }); backendProcess.stderr.on("data", (data) => { logToFile("[backend:stderr]", data.toString().trim()); }); backendProcess.on("exit", (code, signal) => { logToFile(`Backend process exited with code ${code}, signal ${signal}`); backendProcess = null; if (!resolved) { resolved = true; clearTimeout(readyTimeout); resolve(false); } }); backendProcess.on("error", (err) => { logToFile("Failed to start backend process:", err.message); backendProcess = null; if (!resolved) { resolved = true; clearTimeout(readyTimeout); resolve(false); } }); }); } function stopBackendServer() { if (!backendProcess) return; console.log("Stopping embedded backend server..."); try { backendProcess.send({ type: "shutdown" }); } catch { // IPC channel may already be closed } const forceKillTimeout = setTimeout(() => { if (backendProcess) { console.log("Force killing backend process..."); backendProcess.kill("SIGKILL"); backendProcess = null; } }, 5000); backendProcess.on("exit", () => { clearTimeout(forceKillTimeout); backendProcess = null; }); } const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { console.log("Another instance is already running, quitting..."); app.quit(); process.exit(0); } else { app.on("second-instance", (event, commandLine, workingDirectory) => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); mainWindow.show(); } }); } function createTray() { try { const { nativeImage } = require("electron"); let trayIcon; if (process.platform === "darwin") { const iconPath = path.join(appRoot, "public", "icons", "16x16.png"); trayIcon = nativeImage.createFromPath(iconPath); trayIcon.setTemplateImage(true); } else if (process.platform === "win32") { trayIcon = path.join(appRoot, "public", "icon.ico"); } else { trayIcon = path.join(appRoot, "public", "icons", "32x32.png"); } tray = new Tray(trayIcon); tray.setToolTip("Termix"); const contextMenu = Menu.buildFromTemplate([ { label: "Show Window", click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } }, }, { label: "Quit", click: () => { isQuitting = true; app.quit(); }, }, ]); tray.setContextMenu(contextMenu); tray.on("click", () => { if (mainWindow) { if (mainWindow.isVisible()) { mainWindow.hide(); } else { mainWindow.show(); mainWindow.focus(); } } }); console.log("System tray created successfully"); } catch (err) { console.error("Failed to create system tray:", err); } } function createWindow() { const appVersion = app.getVersion(); const electronVersion = process.versions.electron; const platform = process.platform === "win32" ? "Windows" : process.platform === "darwin" ? "macOS" : "Linux"; mainWindow = new BrowserWindow({ width: 1200, height: 800, minWidth: 800, minHeight: 600, title: "Termix", icon: path.join(appRoot, "public", "icon.png"), webPreferences: { nodeIntegration: false, contextIsolation: true, webSecurity: false, preload: path.join(__dirname, "preload.js"), partition: termixSessionPartition, allowRunningInsecureContent: true, webviewTag: true, offscreen: false, }, show: true, }); mainWindow.webContents.session.setPermissionRequestHandler( (webContents, permission, callback) => { if ( permission === "clipboard-read" || permission === "clipboard-write" || permission === "clipboard-sanitized-write" ) { callback(true); return; } callback(false); }, ); if (process.platform !== "darwin") { mainWindow.setMenuBarVisibility(false); } const customUserAgent = `Termix-Desktop/${appVersion} (${platform}; Electron/${electronVersion})`; mainWindow.webContents.setUserAgent(customUserAgent); mainWindow.webContents.session.webRequest.onBeforeSendHeaders( (details, callback) => { details.requestHeaders["X-Electron-App"] = "true"; details.requestHeaders["User-Agent"] = customUserAgent; const rememberedJwt = getRememberedElectronAuthCookie("jwt", details.url); if (rememberedJwt) { setCookieHeaderValue( details.requestHeaders, rememberedJwt.name, rememberedJwt.value, ); } callback({ requestHeaders: details.requestHeaders }); }, ); if (isDev) { mainWindow.loadURL("http://localhost:5173"); mainWindow.webContents.openDevTools(); } else { const indexPath = path.join(appRoot, "dist", "index.html"); mainWindow.loadFile(indexPath).catch((err) => { console.error("Failed to load file:", err); }); } mainWindow.webContents.session.webRequest.onHeadersReceived( (details, callback) => { const headers = details.responseHeaders; if (headers) { delete headers["x-frame-options"]; delete headers["X-Frame-Options"]; if (headers["content-security-policy"]) { headers["content-security-policy"] = headers[ "content-security-policy" ] .map((value) => value.replace(/frame-ancestors[^;]*/gi, "")) .filter((value) => value.trim().length > 0); if (headers["content-security-policy"].length === 0) { delete headers["content-security-policy"]; } } if (headers["Content-Security-Policy"]) { headers["Content-Security-Policy"] = headers[ "Content-Security-Policy" ] .map((value) => value.replace(/frame-ancestors[^;]*/gi, "")) .filter((value) => value.trim().length > 0); if (headers["Content-Security-Policy"].length === 0) { delete headers["Content-Security-Policy"]; } } const setCookieHeaderName = getHeaderName(headers, "Set-Cookie"); if (setCookieHeaderName) { const setCookieHeaders = Array.isArray(headers[setCookieHeaderName]) ? headers[setCookieHeaderName] : [headers[setCookieHeaderName]]; setCookieHeaders.forEach((cookie) => { rememberElectronAuthCookieFromHeader(details.url, cookie); }); headers[setCookieHeaderName] = setCookieHeaders.map((cookie) => { let modified = cookie.replace( /;\s*SameSite=Strict/gi, "; SameSite=None", ); modified = modified.replace( /;\s*SameSite=Lax/gi, "; SameSite=None", ); if (!modified.includes("SameSite=")) { modified += "; SameSite=None"; } if ( !modified.includes("Secure") && details.url.startsWith("https") ) { modified += "; Secure"; } return modified; }); } } callback({ responseHeaders: headers }); }, ); mainWindow.once("ready-to-show", () => { mainWindow.show(); }); setTimeout(() => { if (mainWindow && !mainWindow.isVisible()) { mainWindow.show(); } }, 3000); mainWindow.webContents.on( "did-fail-load", (event, errorCode, errorDescription, validatedURL) => { console.error( "Failed to load:", errorCode, errorDescription, validatedURL, ); }, ); mainWindow.webContents.on("did-finish-load", () => { console.log("Frontend loaded successfully"); }); mainWindow.on("close", (event) => { if (!isQuitting && tray && !tray.isDestroyed()) { event.preventDefault(); mainWindow.hide(); } }); mainWindow.on("closed", () => { mainWindow = null; }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: "deny" }; }); } ipcMain.handle("get-app-version", () => { return app.getVersion(); }); const GITHUB_API_BASE = "https://api.github.com"; const REPO_OWNER = "Termix-SSH"; const REPO_NAME = "Termix"; const githubCache = new Map(); const CACHE_DURATION = 30 * 60 * 1000; async function fetchGitHubAPI(endpoint, cacheKey) { const cached = githubCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { return { data: cached.data, cached: true, cache_age: Date.now() - cached.timestamp, }; } try { const response = await httpFetch(`${GITHUB_API_BASE}${endpoint}`, { headers: { Accept: "application/vnd.github+json", "User-Agent": "TermixElectronUpdateChecker/1.0", "X-GitHub-Api-Version": "2022-11-28", }, timeout: 10000, }); if (!response.ok) { throw new Error( `GitHub API error: ${response.status} ${response.statusText}`, ); } const data = await response.json(); githubCache.set(cacheKey, { data, timestamp: Date.now(), }); return { data: data, cached: false, }; } catch (error) { console.error("Failed to fetch from GitHub API:", error); throw error; } } ipcMain.handle("check-electron-update", async () => { try { const localVersion = app.getVersion(); const releaseData = await fetchGitHubAPI( `/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`, "latest_release_electron", ); const rawTag = releaseData.data.tag_name || releaseData.data.name || ""; const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/); const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null; if (!remoteVersion) { return { success: false, error: "Remote version not found", localVersion, }; } const versionComparison = compareSemver(localVersion, remoteVersion); const status = versionComparison === null || versionComparison === 0 ? "up_to_date" : versionComparison > 0 ? "beta" : "requires_update"; const result = { success: true, status, localVersion: localVersion, remoteVersion: remoteVersion, latest_release: { tag_name: releaseData.data.tag_name, name: releaseData.data.name, published_at: releaseData.data.published_at, html_url: releaseData.data.html_url, body: releaseData.data.body, }, cached: releaseData.cached, cache_age: releaseData.cache_age, }; return result; } catch (error) { return { success: false, error: error.message, localVersion: app.getVersion(), }; } }); ipcMain.handle("get-platform", () => { return process.platform; }); ipcMain.handle("get-embedded-server-status", () => { return { running: backendProcess !== null && !backendProcess.killed, embedded: !isDev, dataDir: isDev ? null : getBackendDataDir(), }; }); ipcMain.handle("get-server-config", () => { try { const userDataPath = app.getPath("userData"); const configPath = path.join(userDataPath, "server-config.json"); if (fs.existsSync(configPath)) { const configData = fs.readFileSync(configPath, "utf8"); return JSON.parse(configData); } return null; } catch (error) { console.error("Error reading server config:", error); return null; } }); ipcMain.handle("save-server-config", (event, config) => { try { const userDataPath = app.getPath("userData"); const configPath = path.join(userDataPath, "server-config.json"); if (!fs.existsSync(userDataPath)) { fs.mkdirSync(userDataPath, { recursive: true }); } fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); return { success: true }; } catch (error) { console.error("Error saving server config:", error); return { success: false, error: error.message }; } }); function getC2STunnelConfigPath() { return path.join(app.getPath("userData"), "c2s-tunnels.json"); } ipcMain.handle("get-c2s-tunnel-config", () => { try { const configPath = getC2STunnelConfigPath(); if (!fs.existsSync(configPath)) { return []; } const configData = fs.readFileSync(configPath, "utf8"); const parsed = JSON.parse(configData); return Array.isArray(parsed) ? parsed : []; } catch (error) { console.error("Error reading C2S tunnel config:", error); return []; } }); ipcMain.handle("save-c2s-tunnel-config", async (_event, config) => { try { if (!Array.isArray(config)) { return { success: false, error: "C2S tunnel config must be an array" }; } const autoStartListeners = new Set(); const autoStartRemoteListeners = new Set(); for (const tunnel of config) { if (!tunnel?.autoStart) continue; const mode = tunnel.mode || tunnel.tunnelType || "local"; if (mode === "remote") { const sourceHostId = Number(tunnel.sourceHostId); const sourcePort = Number(tunnel.sourcePort); if ( !Number.isInteger(sourceHostId) || sourceHostId < 1 || !Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535 ) { return { success: false, error: "Invalid remote client tunnel endpoint or port", }; } const listenerKey = `${sourceHostId}:${sourcePort}`; if (autoStartRemoteListeners.has(listenerKey)) { return { success: false, error: `Another auto-start client tunnel already uses remote ${listenerKey}`, }; } autoStartRemoteListeners.add(listenerKey); continue; } const bindHost = tunnel.bindHost || "127.0.0.1"; const sourcePort = Number(tunnel.sourcePort); const listenerKey = `${bindHost}:${sourcePort}`; if (autoStartListeners.has(listenerKey)) { return { success: false, error: `Another auto-start client tunnel already uses ${listenerKey}`, }; } autoStartListeners.add(listenerKey); } for (const listenerKey of autoStartListeners) { const [bindHost, sourcePort] = listenerKey.split(":"); const result = await checkLocalPortAvailable( bindHost, Number(sourcePort), ); const ownedByClientTunnel = Array.from(c2sTunnelRuntimes.values()).some( (runtime) => runtime.bindHost === bindHost && runtime.sourcePort === Number(sourcePort), ); if (!result.available && !ownedByClientTunnel) { return { success: false, error: `Cannot auto-start client tunnel on ${listenerKey}: ${result.error || "port is already in use"}`, }; } } const userDataPath = app.getPath("userData"); if (!fs.existsSync(userDataPath)) { fs.mkdirSync(userDataPath, { recursive: true }); } fs.writeFileSync(getC2STunnelConfigPath(), JSON.stringify(config, null, 2)); return { success: true }; } catch (error) { console.error("Error saving C2S tunnel config:", error); return { success: false, error: error.message }; } }); function checkLocalPortAvailable(host, port) { return new Promise((resolve) => { const server = net.createServer(); server.once("error", (error) => { resolve({ available: false, error: error.message }); }); server.once("listening", () => { server.close(() => resolve({ available: true })); }); server.listen({ host, port }); }); } function checkTcpConnection(host, port) { return new Promise((resolve) => { const socket = net.createConnection({ host, port }); const timer = setTimeout(() => { socket.destroy(); resolve({ success: false, error: "Connection timed out" }); }, 5000); socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve({ success: true }); }); socket.once("error", (error) => { clearTimeout(timer); socket.destroy(); resolve({ success: false, error: error.message }); }); }); } const c2sTunnelRuntimes = new Map(); const C2S_WS_HIGH_WATERMARK = 1024 * 1024; const C2S_WS_LOW_WATERMARK = 256 * 1024; const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024; function getServerConfigSync() { try { const configPath = path.join(app.getPath("userData"), "server-config.json"); if (!fs.existsSync(configPath)) return null; return JSON.parse(fs.readFileSync(configPath, "utf8")); } catch { return null; } } function getC2SRelayUrl() { const config = getServerConfigSync(); const serverUrl = config?.serverUrl || (!isDev ? "http://127.0.0.1:30003" : null); if (!serverUrl) { throw new Error("No Termix server configured"); } const base = serverUrl.replace(/\/$/, ""); const relayHttpUrl = base.endsWith(":30003") ? `${base}/ssh/tunnel/c2s/stream` : `${base}/ssh/tunnel/c2s/stream`; return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:"); } async function getC2SRelayHeaders(relayUrl) { if (!mainWindow?.webContents?.session) return {}; 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) return {}; return { Cookie: `jwt=${encodeURIComponent(jwt)}`, }; } function getC2STunnelName(tunnel, index = 0) { if (tunnel.name) return tunnel.name; return [ "c2s", index, tunnel.sourceHostId || 0, tunnel.mode || tunnel.tunnelType || "local", tunnel.bindHost || "127.0.0.1", tunnel.sourcePort, tunnel.endpointPort || 0, ].join("::"); } function getC2STunnelStatus(tunnelName) { return ( c2sTunnelRuntimes.get(tunnelName)?.status || { connected: false, status: "DISCONNECTED", } ); } function getAllC2STunnelStatuses() { const statuses = {}; for (const [tunnelName] of c2sTunnelRuntimes.entries()) { statuses[tunnelName] = getC2STunnelStatus(tunnelName); } return statuses; } function emitC2STunnelStatuses() { if (!mainWindow || mainWindow.isDestroyed()) return; mainWindow.webContents.send("c2s-tunnel-statuses", getAllC2STunnelStatuses()); } function setC2STunnelStatus(tunnelName, status) { const runtime = c2sTunnelRuntimes.get(tunnelName); if (runtime) { runtime.status = status; emitC2STunnelStatuses(); } } function setC2STunnelError(tunnelName, message) { logToFile(`[c2s] ${tunnelName} failed:`, message); setC2STunnelStatus(tunnelName, { connected: false, status: "ERROR", reason: message, }); } function parseSocks5Target(buffer) { if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) { return null; } const addressType = buffer[3]; let offset = 4; let host; if (addressType === 0x01) { if (buffer.length < offset + 4 + 2) return null; host = Array.from(buffer.subarray(offset, offset + 4)).join("."); offset += 4; } else if (addressType === 0x03) { const length = buffer[offset]; offset += 1; if (buffer.length < offset + length + 2) return null; host = buffer.subarray(offset, offset + length).toString("utf8"); offset += length; } else if (addressType === 0x04) { if (buffer.length < offset + 16 + 2) return null; const parts = []; for (let i = 0; i < 16; i += 2) { parts.push(buffer.readUInt16BE(offset + i).toString(16)); } host = parts.join(":"); offset += 16; } else { throw new Error("Unsupported SOCKS5 address type"); } const port = buffer.readUInt16BE(offset); return { host, port, bytesRead: offset + 2 }; } async function openC2SRelay( tunnel, targetHost, targetPort, socket, initialData, ) { const tunnelName = tunnel.name || getC2STunnelName(tunnel); const relayUrl = getC2SRelayUrl(); const headers = await getC2SRelayHeaders(relayUrl); logToFile(`[c2s] opening relay for ${tunnelName}`, { relayUrl, targetHost, targetPort, }); setC2STunnelStatus(tunnelName, { connected: false, status: "CONNECTING", reason: `Opening relay to ${targetHost}:${targetPort}`, }); const ws = new WebSocket( relayUrl, getWebSocketOptions(relayUrl, { headers }), ); const pendingChunks = []; let ready = false; let closed = false; const cleanup = () => { if (closed) return; closed = true; try { socket.destroy(); } catch { // expected during shutdown } try { ws.close(); } catch { // expected during shutdown } }; const sendChunk = (chunk) => { if (ready && ws.readyState === WebSocket.OPEN) { ws.send(chunk); } else { pendingChunks.push(chunk); } }; socket.on("data", sendChunk); socket.on("close", cleanup); socket.on("error", (error) => { setC2STunnelError(tunnelName, error.message || "Local socket error"); cleanup(); }); ws.on("close", cleanup); ws.on("error", (error) => { setC2STunnelError(tunnelName, error.message || "Relay connection failed"); cleanup(); }); ws.on("open", () => { logToFile(`[c2s] relay connected for ${tunnelName}`); ws.send( JSON.stringify({ type: "open", tunnelConfig: tunnel, targetHost, targetPort, }), ); }); ws.on("message", (data, isBinary) => { if (isBinary) { socket.write(Buffer.isBuffer(data) ? data : Buffer.from(data)); return; } try { const message = JSON.parse(data.toString()); if (message.type === "ready") { ready = true; logToFile(`[c2s] relay ready for ${tunnelName}`); setC2STunnelStatus(tunnelName, { connected: true, status: "CONNECTED", }); if (initialData?.length) { ws.send(initialData); } while (pendingChunks.length > 0) { ws.send(pendingChunks.shift()); } } else if (message.type === "error") { logToFile("[c2s] relay error:", message.error); setC2STunnelError( tunnelName, message.error || "Relay rejected the client tunnel", ); cleanup(); } } catch (error) { logToFile("[c2s] invalid relay message:", error.message); setC2STunnelError(tunnelName, error.message || "Invalid relay response"); cleanup(); } }); } async function testC2SRelay(tunnel, targetHost, targetPort) { const relayUrl = getC2SRelayUrl(); const headers = await getC2SRelayHeaders(relayUrl); const ws = new WebSocket( relayUrl, getWebSocketOptions(relayUrl, { headers }), ); return new Promise((resolve) => { let settled = false; const settle = (result) => { if (settled) return; settled = true; try { ws.close(); } catch { // expected during shutdown } resolve(result); }; const timer = setTimeout(() => { settle({ success: false, error: "Tunnel test timed out" }); }, 15000); ws.on("open", () => { ws.send( JSON.stringify({ type: "test", tunnelConfig: tunnel, targetHost, targetPort, }), ); }); ws.on("message", (data, isBinary) => { if (isBinary) return; try { const message = JSON.parse(data.toString()); if (message.type === "ready") { clearTimeout(timer); settle({ success: true }); } else if (message.type === "error") { clearTimeout(timer); settle({ success: false, error: message.error || "Tunnel test failed", }); } } catch (error) { clearTimeout(timer); settle({ success: false, error: error.message }); } }); ws.on("error", (error) => { clearTimeout(timer); settle({ success: false, error: error.message }); }); ws.on("close", () => { clearTimeout(timer); settle({ success: false, error: "Tunnel test connection closed" }); }); }); } async function testC2STunnel(tunnel, index = 0) { const mode = tunnel.mode || tunnel.tunnelType || "local"; const testTunnel = { ...tunnel, name: `${getC2STunnelName(tunnel, index)}::test`, mode, }; const bindHost = tunnel.bindHost || "127.0.0.1"; const sourcePort = Number(tunnel.sourcePort); const endpointPort = Number(tunnel.endpointPort); if (!tunnel.sourceHostId) { return { success: false, error: "Endpoint SSH host is required" }; } if (mode === "remote") { const localTarget = await checkTcpConnection(bindHost, endpointPort); if (!localTarget.success) { return { success: false, error: `Local target ${bindHost}:${endpointPort} is not reachable: ${localTarget.error}`, }; } return testC2SRelay(testTunnel, undefined, undefined); } if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) { return { success: false, error: "Invalid local port" }; } const runtime = c2sTunnelRuntimes.get(getC2STunnelName(tunnel, index)); if (!runtime) { const availability = await checkLocalPortAvailable(bindHost, sourcePort); if (!availability.available) { return { success: false, error: `Local listener ${bindHost}:${sourcePort} is not available: ${availability.error}`, }; } } if (mode === "dynamic") { return testC2SRelay(testTunnel, undefined, undefined); } if (!Number.isInteger(endpointPort) || endpointPort < 1) { return { success: false, error: "Invalid remote port" }; } return testC2SRelay( testTunnel, tunnel.targetHost || "127.0.0.1", endpointPort, ); } function handleC2SDynamicConnection(tunnel, socket) { const tunnelName = tunnel.name || getC2STunnelName(tunnel); let buffer = Buffer.alloc(0); let stage = "greeting"; const fail = (code = 0x01, message = "SOCKS5 request failed") => { setC2STunnelError(tunnelName, message); if (!socket.destroyed) { socket.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); socket.destroy(); } }; const onData = (chunk) => { buffer = Buffer.concat([buffer, chunk]); try { if (stage === "greeting") { if (buffer.length < 2) return; if (buffer[0] !== 0x05) { fail(0x01, "Invalid SOCKS5 greeting"); return; } const methodsLength = buffer[1]; if (buffer.length < 2 + methodsLength) return; socket.write(Buffer.from([0x05, 0x00])); buffer = buffer.subarray(2 + methodsLength); stage = "connect"; } if (stage === "connect") { const target = parseSocks5Target(buffer); if (!target) return; stage = "piping"; socket.off("data", onData); const remainder = buffer.subarray(target.bytesRead); socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); openC2SRelay(tunnel, target.host, target.port, socket, remainder).catch( (error) => { logToFile("[c2s] dynamic relay failed:", error.message); fail(0x05, error.message || "Dynamic relay failed"); }, ); } } catch (error) { logToFile("[c2s] SOCKS5 parse failed:", error.message); fail(0x01, error.message || "SOCKS5 parse failed"); } }; socket.on("data", onData); socket.on("error", () => socket.destroy()); } function handleC2SLocalConnection(tunnel, socket) { const tunnelName = tunnel.name || getC2STunnelName(tunnel); const targetHost = tunnel.targetHost || "127.0.0.1"; const targetPort = Number(tunnel.endpointPort); openC2SRelay(tunnel, targetHost, targetPort, socket).catch((error) => { logToFile("[c2s] local relay failed:", error.message); setC2STunnelError(tunnelName, error.message || "Local relay failed"); socket.destroy(); }); } function pauseSourceForC2SWebSocket(ws, source) { if (!source?.pause || !source?.resume) return; if (ws.bufferedAmount <= C2S_WS_HIGH_WATERMARK) return; source.pause(); const resumeTimer = setInterval(() => { if ( ws.readyState !== WebSocket.OPEN || source.destroyed || ws.bufferedAmount <= C2S_WS_LOW_WATERMARK ) { clearInterval(resumeTimer); if (ws.readyState === WebSocket.OPEN && !source.destroyed) { source.resume(); } } }, 25); } function sendC2SRemoteMessage(ws, message, source) { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(message), (error) => { if (error && source?.destroy) { source.destroy(error); } }); pauseSourceForC2SWebSocket(ws, source); } } function writeC2SRemoteChunk(target, chunk, ws, closeTarget) { if (!target || target.destroyed) return; if (target.writableLength > C2S_STREAM_WRITE_LIMIT) { closeTarget(); return; } const canContinue = target.write(chunk); if (!canContinue && typeof ws.pause === "function") { ws.pause(); target.once("drain", () => { if (ws.readyState === WebSocket.OPEN && typeof ws.resume === "function") { ws.resume(); } }); } } async function startC2SRemoteTunnel(tunnel, index = 0) { const tunnelName = getC2STunnelName(tunnel, index); const localHost = tunnel.bindHost || "127.0.0.1"; const localPort = Number(tunnel.endpointPort); const remotePort = Number(tunnel.sourcePort); if (!tunnel.sourceHostId) { return { success: false, error: "Endpoint SSH host is required" }; } if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65535) { return { success: false, error: "Invalid remote port" }; } if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) { return { success: false, error: "Invalid local port" }; } const localTarget = await checkTcpConnection(localHost, localPort); if (!localTarget.success) { return { success: false, error: `Local target ${localHost}:${localPort} is not reachable: ${localTarget.error}`, }; } const existing = c2sTunnelRuntimes.get(tunnelName); if (existing) { return { success: true, tunnelName }; } for (const runtime of c2sTunnelRuntimes.values()) { if ( runtime.mode === "remote" && runtime.sourceHostId === Number(tunnel.sourceHostId) && runtime.sourcePort === remotePort ) { return { success: false, error: `Another client remote tunnel already uses ${remotePort} on this endpoint`, }; } } const relayUrl = getC2SRelayUrl(); const headers = await getC2SRelayHeaders(relayUrl); const ws = new WebSocket( relayUrl, getWebSocketOptions(relayUrl, { headers }), ); const sockets = new Map(); let closed = false; const cleanup = () => { if (closed) return; closed = true; for (const socket of sockets.values()) { socket.destroy(); } sockets.clear(); try { ws.close(); } catch { // expected during shutdown } }; c2sTunnelRuntimes.set(tunnelName, { ws, sockets, mode: "remote", sourceHostId: Number(tunnel.sourceHostId), sourcePort: remotePort, bindHost: localHost, status: { connected: false, status: "CONNECTING" }, close: cleanup, }); emitC2STunnelStatuses(); return new Promise((resolve) => { let settled = false; const settle = (result) => { if (settled) return; settled = true; resolve(result); }; ws.on("open", () => { logToFile(`[c2s] opening remote tunnel ${tunnelName}`, { relayUrl, remotePort, localHost, localPort, }); ws.send( JSON.stringify({ type: "open", tunnelConfig: { ...tunnel, name: tunnelName, mode: "remote" }, }), ); }); ws.on("message", (data, isBinary) => { if (isBinary) return; let message; try { message = JSON.parse(data.toString()); } catch (error) { setC2STunnelError(tunnelName, error.message || "Invalid relay message"); cleanup(); settle({ success: false, error: error.message }); return; } if (message.type === "ready") { setC2STunnelStatus(tunnelName, { connected: true, status: "CONNECTED", }); settle({ success: true, tunnelName }); return; } if (message.type === "error") { const error = message.error || "Relay rejected the client tunnel"; setC2STunnelError(tunnelName, error); cleanup(); c2sTunnelRuntimes.delete(tunnelName); emitC2STunnelStatuses(); settle({ success: false, error }); return; } if (message.type === "connection" && message.streamId) { const socket = net.createConnection( { host: localHost, port: localPort }, () => { logToFile(`[c2s] remote stream ${message.streamId} connected`, { tunnelName, localHost, localPort, }); }, ); sockets.set(message.streamId, socket); socket.on("data", (chunk) => { sendC2SRemoteMessage( ws, { type: "data", streamId: message.streamId, data: chunk.toString("base64"), }, socket, ); }); socket.on("close", () => { sockets.delete(message.streamId); sendC2SRemoteMessage(ws, { type: "close", streamId: message.streamId, }); }); socket.on("error", (error) => { logToFile(`[c2s] remote stream ${message.streamId} failed:`, { tunnelName, error: error.message, }); sockets.delete(message.streamId); sendC2SRemoteMessage(ws, { type: "close", streamId: message.streamId, error: error.message, }); }); return; } if (message.type === "data" && message.streamId && message.data) { const socket = sockets.get(message.streamId); writeC2SRemoteChunk( socket, Buffer.from(message.data, "base64"), ws, () => { if (socket) { sockets.delete(message.streamId); socket.destroy(); } }, ); return; } if (message.type === "close" && message.streamId) { const socket = sockets.get(message.streamId); if (socket) { sockets.delete(message.streamId); socket.destroy(); } } }); ws.on("close", () => { cleanup(); c2sTunnelRuntimes.delete(tunnelName); emitC2STunnelStatuses(); settle({ success: false, error: "Remote tunnel relay closed" }); }); ws.on("error", (error) => { setC2STunnelError(tunnelName, error.message || "Relay connection failed"); cleanup(); c2sTunnelRuntimes.delete(tunnelName); emitC2STunnelStatuses(); settle({ success: false, error: error.message }); }); }); } async function startC2STunnel(tunnel, index = 0) { const mode = tunnel.mode || tunnel.tunnelType || "local"; const tunnelName = getC2STunnelName(tunnel, index); const bindHost = tunnel.bindHost || "127.0.0.1"; const sourcePort = Number(tunnel.sourcePort); logToFile(`[c2s] starting tunnel ${tunnelName}`, { mode, bindHost, sourcePort, sourceHostId: tunnel.sourceHostId, endpointPort: tunnel.endpointPort, }); if (mode === "remote") { return startC2SRemoteTunnel(tunnel, index); } if (!tunnel.sourceHostId) { return { success: false, error: "Endpoint SSH host is required" }; } if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) { return { success: false, error: "Invalid local port" }; } const existing = c2sTunnelRuntimes.get(tunnelName); if (existing) { return { success: true, tunnelName }; } for (const runtime of c2sTunnelRuntimes.values()) { if ( runtime.mode !== "remote" && runtime.bindHost === bindHost && runtime.sourcePort === sourcePort ) { return { success: false, error: `Another client tunnel already uses ${bindHost}:${sourcePort}`, }; } } const availability = await checkLocalPortAvailable(bindHost, sourcePort); if (!availability.available) { return { success: false, error: availability.error || "Port is already in use", }; } const sockets = new Set(); const server = net.createServer((socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); if (mode === "dynamic") { handleC2SDynamicConnection({ ...tunnel, name: tunnelName, mode }, socket); } else { handleC2SLocalConnection({ ...tunnel, name: tunnelName, mode }, socket); } }); c2sTunnelRuntimes.set(tunnelName, { server, sockets, bindHost, sourcePort, status: { connected: false, status: "CONNECTING" }, }); return new Promise((resolve) => { server.once("error", (error) => { c2sTunnelRuntimes.delete(tunnelName); logToFile(`[c2s] failed to listen for ${tunnelName}:`, error.message); emitC2STunnelStatuses(); resolve({ success: false, error: error.message }); }); server.listen({ host: bindHost, port: sourcePort }, () => { logToFile( `[c2s] listening for ${tunnelName} on ${bindHost}:${sourcePort}`, ); setC2STunnelStatus(tunnelName, { connected: true, status: "CONNECTED", }); resolve({ success: true, tunnelName }); }); }); } async function stopC2STunnel(tunnelName) { const runtime = c2sTunnelRuntimes.get(tunnelName); if (!runtime) { return { success: true }; } setC2STunnelStatus(tunnelName, { connected: false, status: "DISCONNECTING", }); return new Promise((resolve) => { if (typeof runtime.close === "function") { runtime.close(); c2sTunnelRuntimes.delete(tunnelName); emitC2STunnelStatuses(); resolve({ success: true }); return; } for (const socket of runtime.sockets || []) { socket.destroy(); } runtime.server?.close(() => { c2sTunnelRuntimes.delete(tunnelName); emitC2STunnelStatuses(); resolve({ success: true }); }); }); } function stopAllC2STunnels() { for (const [tunnelName, runtime] of c2sTunnelRuntimes.entries()) { try { if (typeof runtime.close === "function") { runtime.close(); } else { for (const socket of runtime.sockets || []) { socket.destroy(); } runtime.server?.close(); } } catch (error) { logToFile(`[c2s] failed to stop tunnel ${tunnelName}:`, error.message); } c2sTunnelRuntimes.delete(tunnelName); } emitC2STunnelStatuses(); } async function startC2SAutoStartTunnels() { const configPath = getC2STunnelConfigPath(); if (!fs.existsSync(configPath)) { return { success: true, started: 0, errors: [] }; } const config = JSON.parse(fs.readFileSync(configPath, "utf8")); const tunnels = Array.isArray(config) ? config : []; const errors = []; let started = 0; for (let index = 0; index < tunnels.length; index += 1) { const tunnel = tunnels[index]; if (!tunnel?.autoStart) continue; const result = await startC2STunnel(tunnel, index); if (result.success) { started += 1; } else { errors.push(result.error || "Failed to start client tunnel"); } } return { success: errors.length === 0, started, errors }; } ipcMain.handle("check-local-port-available", async (_event, host, port) => { const sourcePort = Number(port); if ( !host || !Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535 ) { return { available: false, error: "Invalid local bind address or port" }; } return checkLocalPortAvailable(host, sourcePort); }); ipcMain.handle("start-c2s-tunnel", async (_event, tunnel, index) => { try { return await startC2STunnel(tunnel, Number(index) || 0); } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle("test-c2s-tunnel", async (_event, tunnel, index) => { try { return await testC2STunnel(tunnel, Number(index) || 0); } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle("stop-c2s-tunnel", async (_event, tunnelName) => { try { return await stopC2STunnel(tunnelName); } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle("get-c2s-tunnel-statuses", () => { return getAllC2STunnelStatuses(); }); ipcMain.handle("start-c2s-autostart-tunnels", async () => { try { return await startC2SAutoStartTunnels(); } catch (error) { return { success: false, started: 0, errors: [error.message] }; } }); ipcMain.handle("get-c2s-tunnel-preset-default-name", () => { const now = new Date(); const date = now.toISOString().slice(0, 10); const platform = process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux"; const release = os.release(); const computerName = os.hostname(); return `[${date}] ${computerName} (${platform} ${release})`; }); ipcMain.handle("get-setting", (event, key) => { try { const userDataPath = app.getPath("userData"); const settingsPath = path.join(userDataPath, "settings.json"); if (!fs.existsSync(settingsPath)) { return null; } const settingsData = fs.readFileSync(settingsPath, "utf8"); const settings = JSON.parse(settingsData); return settings[key] !== undefined ? settings[key] : null; } catch (error) { console.error("Error reading setting:", error); return null; } }); ipcMain.handle("set-setting", (event, key, value) => { try { const userDataPath = app.getPath("userData"); const settingsPath = path.join(userDataPath, "settings.json"); if (!fs.existsSync(userDataPath)) { fs.mkdirSync(userDataPath, { recursive: true }); } let settings = {}; if (fs.existsSync(settingsPath)) { const settingsData = fs.readFileSync(settingsPath, "utf8"); settings = JSON.parse(settingsData); } settings[key] = value; fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); return { success: true }; } catch (error) { console.error("Error saving setting:", error); return { success: false, error: error.message }; } }); ipcMain.handle("get-session-cookie", async (_event, name, targetUrl) => { try { const ses = mainWindow?.webContents?.session; if (!ses) return null; const cookies = await ses.cookies.get({ name, ...(targetUrl ? { url: targetUrl } : {}), }); const cookie = cookies.find((candidate) => cookieMatchesUrl(candidate, targetUrl), ); return ( cookie?.value || getRememberedElectronAuthCookie(name, targetUrl)?.value || null ); } catch (error) { console.error("Failed to get session cookie:", error); return getRememberedElectronAuthCookie(name, targetUrl)?.value || null; } }); function cookieMatchesUrl(cookie, targetUrl) { if (!targetUrl) return true; try { const targetHost = new URL(targetUrl).hostname; const cookieDomain = (cookie.domain || "").replace(/^\./, ""); return ( cookieDomain === targetHost || targetHost.endsWith(`.${cookieDomain}`) || (!cookieDomain && targetHost === "localhost") ); } catch { return true; } } ipcMain.handle( "wait-session-cookie", async (_event, name, targetUrl, previousValue, timeoutMs = 5000) => { const ses = mainWindow?.webContents?.session; if (!ses) return { success: false, error: "No Electron session" }; const existingCookies = await ses.cookies.get({ name, ...(targetUrl ? { url: targetUrl } : {}), }); const existingCookie = existingCookies.find((cookie) => cookieMatchesUrl(cookie, targetUrl), ); if (existingCookie?.value && existingCookie.value !== previousValue) { return { success: true, value: existingCookie.value }; } const rememberedCookie = getRememberedElectronAuthCookie(name, targetUrl); if (rememberedCookie?.value && rememberedCookie.value !== previousValue) { return { success: true, value: rememberedCookie.value }; } return new Promise((resolve) => { const timeout = setTimeout(() => { ses.cookies.off("changed", onCookieChanged); resolve({ success: false, error: "Timed out waiting for cookie" }); }, timeoutMs); function onCookieChanged(_event, cookie, _cause, removed) { if ( removed || cookie.name !== name || !cookie.value || cookie.value === previousValue || !cookieMatchesUrl(cookie, targetUrl) ) { return; } clearTimeout(timeout); ses.cookies.off("changed", onCookieChanged); resolve({ success: true, value: cookie.value }); } ses.cookies.on("changed", onCookieChanged); }); }, ); ipcMain.handle("clear-session-cookies", async () => { try { clearPersistedElectronAuthCookies(); const ses = mainWindow?.webContents?.session; if (ses) { const cookies = await ses.cookies.get({}); for (const cookie of cookies) { await ses.cookies.remove(getCookieRemovalUrl(cookie), cookie.name); } } } catch (error) { console.error("Failed to clear session cookies:", error); } }); ipcMain.handle("test-server-connection", async (event, serverUrl) => { try { const normalizedServerUrl = serverUrl.replace(/\/$/, ""); const healthUrl = `${normalizedServerUrl}/health`; try { const response = await httpFetch(healthUrl, { method: "GET", timeout: 10000, }); if (response.ok) { const data = await response.text(); if ( data.includes("") || data.includes("") ) { return { success: false, error: "Server returned HTML instead of JSON. This does not appear to be a Termix server.", }; } try { const healthData = JSON.parse(data); if ( healthData && (healthData.status === "ok" || healthData.status === "healthy" || healthData.healthy === true || healthData.database === "connected") ) { return { success: true, status: response.status, testedUrl: healthUrl, }; } } catch (parseError) { console.log("Health endpoint did not return valid JSON"); } } } catch (urlError) { console.error("Health check failed:", urlError); } try { const versionUrl = `${normalizedServerUrl}/version`; const response = await httpFetch(versionUrl, { method: "GET", timeout: 10000, }); if (response.ok) { const data = await response.text(); if ( data.includes("") || data.includes("") ) { return { success: false, error: "Server returned HTML instead of JSON. This does not appear to be a Termix server.", }; } try { const versionData = JSON.parse(data); if ( versionData && (versionData.status === "up_to_date" || versionData.status === "requires_update" || (versionData.localVersion && versionData.version && versionData.latest_release)) ) { return { success: true, status: response.status, testedUrl: versionUrl, warning: "Health endpoint not available, but server appears to be running", }; } } catch (parseError) { console.log("Version endpoint did not return valid JSON"); } } } catch (versionError) { console.error("Version check failed:", versionError); } return { success: false, error: "Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.", }; } catch (error) { return { success: false, error: error.message }; } }); function createMenu() { if (process.platform === "darwin") { const template = [ { label: app.name, submenu: [ { role: "about" }, { type: "separator" }, { role: "services" }, { type: "separator" }, { role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" }, ], }, { label: "Edit", submenu: [ { role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "selectAll" }, ], }, { label: "View", submenu: [ { role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }, ], }, { label: "Window", submenu: [ { role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }, { type: "separator" }, { role: "window" }, ], }, ]; const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); } } app.whenReady().then(async () => { logToFile("=== App ready ==="); logToFile( "isDev:", isDev, "platform:", process.platform, "arch:", process.arch, ); createMenu(); await clearElectronClientCacheIfBuildChanged(); await clearElectronJwtCookiesAtStartup(); if (!isDev) { const result = await startBackendServer(); logToFile("startBackendServer result:", result); } else { logToFile( "Skipping embedded backend (isDev=true) - expecting separate dev:backend process", ); } createTray(); createWindow(); logToFile("=== Startup complete ==="); }); app.on("window-all-closed", () => { if (!tray || tray.isDestroyed()) { app.quit(); } }); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); app.on("before-quit", () => { isQuitting = true; }); app.on("will-quit", () => { console.log("App will quit..."); stopAllC2STunnels(); stopBackendServer(); }); process.on("uncaughtException", (error) => { console.error("Uncaught Exception:", error); }); process.on("unhandledRejection", (reason, promise) => { console.error("Unhandled Rejection at:", promise, "reason:", reason); });