mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-08 21:03:46 +00:00
3849 lines
109 KiB
JavaScript
3849 lines
109 KiB
JavaScript
const {
|
|
app,
|
|
BrowserWindow,
|
|
shell,
|
|
ipcMain,
|
|
dialog,
|
|
Menu,
|
|
session,
|
|
safeStorage,
|
|
Tray,
|
|
clipboard,
|
|
nativeImage,
|
|
} = require("electron");
|
|
const path = require("path");
|
|
const { getUnpackedAppRoot } = require("./backend-paths.cjs");
|
|
const { classifyBackendFailure } = require("./backend-failure.cjs");
|
|
const fs = require("fs");
|
|
const { renameLocalPath } = require("./rename-local-path.cjs");
|
|
const os = require("os");
|
|
const https = require("https");
|
|
const http = require("http");
|
|
const net = require("net");
|
|
const tls = require("tls");
|
|
const zlib = require("zlib");
|
|
const crypto = require("crypto");
|
|
const { URL, pathToFileURL } = require("url");
|
|
const { fork, spawn } = require("child_process");
|
|
const pty = require("node-pty");
|
|
const WebSocket = require("ws");
|
|
const linkedServer = require("./linked-server.cjs");
|
|
const desktopSettings = require("./desktop-settings.cjs");
|
|
const { launchNativeRdp } = require("./native-rdp.cjs");
|
|
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
|
|
const { quitApp } = require("./app-quit.cjs");
|
|
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
|
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
|
|
const { registerLocalFileHandlers } = require("./local-files.cjs");
|
|
|
|
const localTerminalSessions = new Map();
|
|
|
|
function ownedLocalTerminal(event, sessionId) {
|
|
if (typeof sessionId !== "string" || !/^[a-f0-9-]{36}$/.test(sessionId)) {
|
|
return null;
|
|
}
|
|
const session = localTerminalSessions.get(sessionId);
|
|
return session?.ownerId === event.sender.id ? session : null;
|
|
}
|
|
|
|
function closeLocalTerminalsFor(ownerId) {
|
|
for (const [sessionId, session] of localTerminalSessions) {
|
|
if (session.ownerId !== ownerId) continue;
|
|
session.process.kill();
|
|
localTerminalSessions.delete(sessionId);
|
|
}
|
|
}
|
|
|
|
// The main process's Node.js networking (the `https`/`http` modules used by
|
|
// httpFetch below and the global `fetch`) only
|
|
// trusts Node's bundled Mozilla CA list by default, not the OS/system trust
|
|
// store. Chromium (the renderer, i.e. the web app and the login iframe) uses
|
|
// the OS trust store instead, so a certificate that's valid in-browser --
|
|
// e.g. one issued by a reverse proxy's internal/corporate CA, or a system
|
|
// CA installed via Keychain/certmgr -- can still fail main-process requests
|
|
// with UNABLE_TO_VERIFY_LEAF_SIGNATURE. Merge the system store in so remote
|
|
// sync and the connection health check see the same trust as the browser.
|
|
try {
|
|
if (typeof tls.setDefaultCACertificates === "function") {
|
|
tls.setDefaultCACertificates([
|
|
...tls.getCACertificates("default"),
|
|
...tls.getCACertificates("system"),
|
|
]);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to merge system CA certificates:", error);
|
|
}
|
|
|
|
// Portable mode: if a `.portable` marker exists next to the executable,
|
|
// store all data in a `data` folder beside the exe instead of %APPDATA%.
|
|
(function setupPortableDataDir() {
|
|
const exeDir = path.dirname(process.execPath);
|
|
const markerPath = path.join(exeDir, ".portable");
|
|
const envOverride = process.env.TERMIX_DATA_DIR;
|
|
|
|
let portableDataDir = null;
|
|
if (envOverride) {
|
|
portableDataDir = envOverride;
|
|
} else if (app.isPackaged && fs.existsSync(markerPath)) {
|
|
portableDataDir = path.join(exeDir, "data");
|
|
}
|
|
|
|
if (portableDataDir) {
|
|
try {
|
|
if (!fs.existsSync(portableDataDir)) {
|
|
fs.mkdirSync(portableDataDir, { recursive: true });
|
|
}
|
|
app.setPath("userData", portableDataDir);
|
|
} catch {
|
|
// Fall back to default userData if we can't create the directory.
|
|
}
|
|
}
|
|
})();
|
|
|
|
const logFile = path.join(app.getPath("userData"), "termix-main.log");
|
|
const electronAuthCookiesPath = path.join(
|
|
app.getPath("userData"),
|
|
"electron-auth-cookies.json",
|
|
);
|
|
const electronAuthCookies = new Map();
|
|
|
|
function logToFile(...args) {
|
|
const timestamp = new Date().toISOString();
|
|
const msg = args
|
|
.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
|
|
.join(" ");
|
|
const line = `[${timestamp}] ${msg}\n`;
|
|
try {
|
|
fs.appendFileSync(logFile, line);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
console.log(...args);
|
|
}
|
|
|
|
function getCookieOrigin(url) {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
const protocol =
|
|
parsedUrl.protocol === "ws:"
|
|
? "http:"
|
|
: parsedUrl.protocol === "wss:"
|
|
? "https:"
|
|
: parsedUrl.protocol;
|
|
return `${protocol}//${parsedUrl.host}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseCookieTarget(url) {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
if (parsedUrl.protocol === "ws:") {
|
|
parsedUrl.protocol = "http:";
|
|
} else if (parsedUrl.protocol === "wss:") {
|
|
parsedUrl.protocol = "https:";
|
|
}
|
|
return parsedUrl;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getElectronAuthCookieKey(name, origin) {
|
|
return origin ? `${origin}|${name}` : null;
|
|
}
|
|
|
|
function getSafeStorageAvailable() {
|
|
try {
|
|
return safeStorage.isEncryptionAvailable();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function encodeElectronAuthCookieValue(value) {
|
|
return {
|
|
encrypted: true,
|
|
value: safeStorage.encryptString(value).toString("base64"),
|
|
};
|
|
}
|
|
|
|
function decodeElectronAuthCookieValue(record) {
|
|
if (!record.encrypted || !getSafeStorageAvailable()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return safeStorage.decryptString(Buffer.from(record.value, "base64"));
|
|
} catch (error) {
|
|
logToFile(
|
|
"Failed to decrypt persisted Electron auth cookie:",
|
|
error.message,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isElectronAuthCookieExpired(cookie) {
|
|
return Number.isFinite(cookie.expiresAt) && cookie.expiresAt <= Date.now();
|
|
}
|
|
|
|
function saveElectronAuthCookiesToDisk() {
|
|
try {
|
|
if (!getSafeStorageAvailable()) {
|
|
if (fs.existsSync(electronAuthCookiesPath)) {
|
|
fs.rmSync(electronAuthCookiesPath, { force: true });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const records = [];
|
|
|
|
for (const [key, cookie] of electronAuthCookies.entries()) {
|
|
if (isElectronAuthCookieExpired(cookie)) {
|
|
electronAuthCookies.delete(key);
|
|
continue;
|
|
}
|
|
|
|
records.push({
|
|
key,
|
|
name: cookie.name,
|
|
origin: cookie.origin,
|
|
path: cookie.path,
|
|
expiresAt: cookie.expiresAt,
|
|
...encodeElectronAuthCookieValue(cookie.value),
|
|
});
|
|
}
|
|
|
|
fs.writeFileSync(
|
|
electronAuthCookiesPath,
|
|
JSON.stringify({ version: 1, records }, null, 2),
|
|
);
|
|
} catch (error) {
|
|
logToFile("Failed to persist Electron auth cookies:", error.message);
|
|
}
|
|
}
|
|
|
|
function loadElectronAuthCookiesFromDisk() {
|
|
electronAuthCookies.clear();
|
|
|
|
try {
|
|
if (!getSafeStorageAvailable()) {
|
|
if (fs.existsSync(electronAuthCookiesPath)) {
|
|
fs.rmSync(electronAuthCookiesPath, { force: true });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!fs.existsSync(electronAuthCookiesPath)) {
|
|
return;
|
|
}
|
|
|
|
const data = JSON.parse(fs.readFileSync(electronAuthCookiesPath, "utf8"));
|
|
const records = Array.isArray(data.records) ? data.records : [];
|
|
|
|
for (const record of records) {
|
|
if (
|
|
!record ||
|
|
typeof record.key !== "string" ||
|
|
typeof record.name !== "string" ||
|
|
typeof record.origin !== "string"
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const value = decodeElectronAuthCookieValue(record);
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
|
|
const cookie = {
|
|
name: record.name,
|
|
value,
|
|
origin: record.origin,
|
|
path: typeof record.path === "string" ? record.path : "/",
|
|
expiresAt: Number.isFinite(record.expiresAt) ? record.expiresAt : null,
|
|
};
|
|
|
|
if (!isElectronAuthCookieExpired(cookie)) {
|
|
electronAuthCookies.set(record.key, cookie);
|
|
}
|
|
}
|
|
|
|
saveElectronAuthCookiesToDisk();
|
|
} catch (error) {
|
|
logToFile("Failed to load persisted Electron auth cookies:", error.message);
|
|
}
|
|
}
|
|
|
|
function clearPersistedElectronAuthCookies() {
|
|
electronAuthCookies.clear();
|
|
try {
|
|
if (fs.existsSync(electronAuthCookiesPath)) {
|
|
fs.rmSync(electronAuthCookiesPath, { force: true });
|
|
}
|
|
} catch (error) {
|
|
logToFile(
|
|
"Failed to clear persisted Electron auth cookies:",
|
|
error.message,
|
|
);
|
|
}
|
|
}
|
|
|
|
function parseSetCookieHeader(header) {
|
|
const [cookiePair, ...attributes] = String(header || "").split(";");
|
|
const separatorIndex = cookiePair.indexOf("=");
|
|
if (separatorIndex <= 0) return null;
|
|
|
|
const parsed = {
|
|
name: cookiePair.slice(0, separatorIndex).trim(),
|
|
value: cookiePair.slice(separatorIndex + 1).trim(),
|
|
path: "/",
|
|
maxAge: null,
|
|
expires: null,
|
|
};
|
|
|
|
for (const attribute of attributes) {
|
|
const [rawName, ...rawValueParts] = attribute.trim().split("=");
|
|
const attrName = rawName.toLowerCase();
|
|
const attrValue = rawValueParts.join("=");
|
|
|
|
if (attrName === "path" && attrValue) {
|
|
parsed.path = attrValue;
|
|
} else if (attrName === "max-age" && attrValue) {
|
|
const maxAge = Number(attrValue);
|
|
parsed.maxAge = Number.isFinite(maxAge) ? maxAge : null;
|
|
} else if (attrName === "expires" && attrValue) {
|
|
const expires = Date.parse(attrValue);
|
|
parsed.expires = Number.isFinite(expires) ? expires : null;
|
|
}
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function rememberElectronAuthCookieFromHeader(url, header) {
|
|
const origin = getCookieOrigin(url);
|
|
if (!origin) return;
|
|
|
|
const cookie = parseSetCookieHeader(header);
|
|
if (!cookie || cookie.name !== "jwt") return;
|
|
|
|
const key = getElectronAuthCookieKey(cookie.name, origin);
|
|
if (!key) return;
|
|
|
|
const expired =
|
|
cookie.maxAge === 0 ||
|
|
(cookie.expires !== null && cookie.expires <= Date.now());
|
|
|
|
if (expired || !cookie.value) {
|
|
electronAuthCookies.delete(key);
|
|
saveElectronAuthCookiesToDisk();
|
|
return;
|
|
}
|
|
|
|
const expiresAt =
|
|
cookie.maxAge !== null ? Date.now() + cookie.maxAge * 1000 : cookie.expires;
|
|
|
|
electronAuthCookies.set(key, {
|
|
name: cookie.name,
|
|
value: cookie.value,
|
|
origin,
|
|
path: cookie.path,
|
|
expiresAt,
|
|
});
|
|
saveElectronAuthCookiesToDisk();
|
|
}
|
|
|
|
function getRememberedElectronAuthCookie(name, targetUrl) {
|
|
const target = parseCookieTarget(targetUrl);
|
|
if (!target) return null;
|
|
|
|
const exactKey = getElectronAuthCookieKey(name, target.origin);
|
|
const exactCookie = exactKey ? electronAuthCookies.get(exactKey) : null;
|
|
if (exactCookie && !isElectronAuthCookieExpired(exactCookie)) {
|
|
return exactCookie;
|
|
}
|
|
|
|
if (target.protocol !== "https:") {
|
|
return null;
|
|
}
|
|
|
|
const httpOrigin = `http://${target.host}`;
|
|
const httpKey = getElectronAuthCookieKey(name, httpOrigin);
|
|
const httpCookie = httpKey ? electronAuthCookies.get(httpKey) : null;
|
|
return httpCookie && !isElectronAuthCookieExpired(httpCookie)
|
|
? httpCookie
|
|
: null;
|
|
}
|
|
|
|
function getHeaderName(headers, name) {
|
|
const lowerName = name.toLowerCase();
|
|
return Object.keys(headers || {}).find(
|
|
(key) => key.toLowerCase() === lowerName,
|
|
);
|
|
}
|
|
|
|
function setCookieHeaderValue(requestHeaders, name, value) {
|
|
const headerName = getHeaderName(requestHeaders, "Cookie") || "Cookie";
|
|
const existing = requestHeaders[headerName];
|
|
const existingValue = Array.isArray(existing)
|
|
? existing.join("; ")
|
|
: existing;
|
|
const nextCookie = `${name}=${value}`;
|
|
const otherCookies = String(existingValue || "")
|
|
.split(";")
|
|
.map((cookie) => cookie.trim())
|
|
.filter((cookie) => cookie && !cookie.startsWith(`${name}=`));
|
|
|
|
requestHeaders[headerName] =
|
|
otherCookies.length > 0
|
|
? `${otherCookies.join("; ")}; ${nextCookie}`
|
|
: nextCookie;
|
|
}
|
|
|
|
function parseSemver(version) {
|
|
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
if (!match) return null;
|
|
|
|
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
|
|
}
|
|
|
|
function compareSemver(a, b) {
|
|
const parsedA = parseSemver(a);
|
|
const parsedB = parseSemver(b);
|
|
if (!parsedA || !parsedB) return null;
|
|
|
|
for (let i = 0; i < 3; i += 1) {
|
|
if (parsedA[i] > parsedB[i]) return 1;
|
|
if (parsedA[i] < parsedB[i]) return -1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
const INSECURE_MODE_VALUES = new Set(["true", "1", "yes"]);
|
|
|
|
function isInsecureModeEnabled() {
|
|
return INSECURE_MODE_VALUES.has(
|
|
String(process.env.ENABLE_INSECURE_MODE || "")
|
|
.trim()
|
|
.toLowerCase(),
|
|
);
|
|
}
|
|
|
|
function getOrigin(url) {
|
|
try {
|
|
return new URL(url).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isPrivateNetworkHost(hostname) {
|
|
if (
|
|
hostname === "localhost" ||
|
|
hostname === "127.0.0.1" ||
|
|
hostname === "::1"
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const ipv4Match = hostname.match(
|
|
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
|
);
|
|
if (ipv4Match) {
|
|
const [, a, b] = ipv4Match.map(Number);
|
|
return (
|
|
a === 10 ||
|
|
(a === 172 && b >= 16 && b <= 31) ||
|
|
(a === 192 && b === 168) ||
|
|
(a === 169 && b === 254)
|
|
);
|
|
}
|
|
|
|
if (hostname.startsWith("fc") || hostname.startsWith("fd")) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isInvalidCertificateAllowedForUrl(url) {
|
|
if (isInsecureModeEnabled()) return true;
|
|
|
|
try {
|
|
const { hostname } = new URL(url);
|
|
if (isPrivateNetworkHost(hostname)) return true;
|
|
} catch {
|
|
// fall through
|
|
}
|
|
|
|
// Otherwise only the server this desktop is linked to, when its link
|
|
// allows a self-signed certificate.
|
|
const linked = linkedServer.getLinkedServer();
|
|
if (!linked?.allowInvalidCertificate) return false;
|
|
return getOrigin(url) === linked.origin;
|
|
}
|
|
|
|
function getTlsVerificationOptions(url) {
|
|
return {
|
|
rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url),
|
|
};
|
|
}
|
|
|
|
function getWebSocketOptions(url, options = {}) {
|
|
return {
|
|
...options,
|
|
...(String(url).startsWith("wss:") ? getTlsVerificationOptions(url) : {}),
|
|
};
|
|
}
|
|
|
|
function httpFetch(url, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const urlObj = new URL(url);
|
|
const isHttps = urlObj.protocol === "https:";
|
|
const client = isHttps ? https : http;
|
|
|
|
const requestOptions = {
|
|
method: options.method || "GET",
|
|
headers: options.headers || {},
|
|
timeout: options.timeout || 10000,
|
|
...(isHttps
|
|
? options.allowInvalidCertificate
|
|
? { rejectUnauthorized: false }
|
|
: getTlsVerificationOptions(url)
|
|
: {}),
|
|
};
|
|
|
|
const req = client.request(url, requestOptions, (res) => {
|
|
const chunks = [];
|
|
// Reverse proxies (nginx and friends) commonly gzip/deflate/br-compress
|
|
// responses regardless of client Accept-Encoding. Unlike browser fetch,
|
|
// Node's http/https modules never auto-decompress, so an unhandled
|
|
// content-encoding here silently turns the body into garbage bytes.
|
|
let stream = res;
|
|
const maxResponseBytes = options.maxResponseBytes || 10 * 1024 * 1024;
|
|
let responseBytes = 0;
|
|
let settled = false;
|
|
const fail = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
stream.destroy();
|
|
req.destroy();
|
|
reject(error);
|
|
};
|
|
const encoding = (res.headers["content-encoding"] || "")
|
|
.toLowerCase()
|
|
.trim();
|
|
try {
|
|
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
stream = res.pipe(zlib.createGunzip());
|
|
} else if (encoding === "br") {
|
|
stream = res.pipe(zlib.createBrotliDecompress());
|
|
} else if (encoding === "deflate") {
|
|
stream = res.pipe(zlib.createInflate());
|
|
}
|
|
} catch (decompressError) {
|
|
reject(decompressError);
|
|
return;
|
|
}
|
|
|
|
stream.on("data", (chunk) => {
|
|
responseBytes += chunk.length;
|
|
if (responseBytes > maxResponseBytes) {
|
|
fail(new Error(`Response exceeds ${maxResponseBytes} bytes`));
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
stream.on("end", () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
const data = Buffer.concat(chunks).toString("utf8");
|
|
resolve({
|
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
status: res.statusCode,
|
|
text: () => Promise.resolve(data),
|
|
json: () => Promise.resolve(JSON.parse(data)),
|
|
});
|
|
});
|
|
stream.on("error", fail);
|
|
});
|
|
|
|
req.on("error", reject);
|
|
req.on("timeout", () => {
|
|
req.destroy();
|
|
reject(new Error("Request timeout"));
|
|
});
|
|
|
|
if (options.body) {
|
|
req.write(options.body);
|
|
}
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
if (process.platform === "linux") {
|
|
app.commandLine.appendSwitch("--ozone-platform-hint=auto");
|
|
|
|
app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder");
|
|
|
|
// Fix click-coordinate misalignment with fractional display scaling on KDE/Wayland.
|
|
// Chromium's hit-testing uses unscaled coords while the compositor scales visually,
|
|
// so forcing scale factor 1 keeps them in sync. See: https://github.com/brave/brave-browser/issues/50028
|
|
app.commandLine.appendSwitch("--force-device-scale-factor", "1");
|
|
|
|
const passwordStore = selectLinuxPasswordStore(app.commandLine, process.env);
|
|
if (passwordStore) {
|
|
logToFile(
|
|
`[safeStorage] Selected the ${passwordStore} password store for this desktop.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (process.platform === "win32" || process.env.ELECTRON_DISABLE_GPU === "1") {
|
|
app.disableHardwareAcceleration();
|
|
}
|
|
|
|
if (isInsecureModeEnabled()) {
|
|
logToFile(
|
|
"[security] ENABLE_INSECURE_MODE is enabled; TLS certificate validation is disabled.",
|
|
);
|
|
app.commandLine.appendSwitch("--ignore-certificate-errors");
|
|
app.commandLine.appendSwitch("--ignore-ssl-errors");
|
|
app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list");
|
|
}
|
|
app.commandLine.appendSwitch("--enable-features=NetworkService");
|
|
|
|
let mainWindow = null;
|
|
const { createIsolatedWindows } = require("./isolated-window.cjs");
|
|
const isolatedWindows = createIsolatedWindows({
|
|
BrowserWindow,
|
|
session,
|
|
getMainWindow: () => mainWindow,
|
|
});
|
|
|
|
// Requests from the embedded backend over the fork IPC channel (the other
|
|
// direction from the "shutdown" message main already sends it). A plugin's
|
|
// ctx.desktop.openIsolatedWindow is the one caller today.
|
|
const BACKEND_REQUEST_HANDLERS = {
|
|
"open-isolated-window": (payload) => isolatedWindows.open(payload),
|
|
// The OS client for a protocol. Only RDP has one wired up.
|
|
"launch-external-client": (payload) =>
|
|
payload?.protocol === "rdp"
|
|
? launchNativeRdp(payload)
|
|
: {
|
|
success: false,
|
|
error: `No external client for ${payload?.protocol ?? "this protocol"}`,
|
|
},
|
|
"sync-proxy-config": (payload) => {
|
|
linkedServer.setLinkedServer(payload);
|
|
return { success: true };
|
|
},
|
|
};
|
|
|
|
function isDevRunner() {
|
|
return (
|
|
process.env.TERMIX_DEV_RUNNER === "true" &&
|
|
typeof process.send === "function"
|
|
);
|
|
}
|
|
|
|
async function handleBackendRequest(msg) {
|
|
if (!msg || msg.type !== "backend-request") return;
|
|
const { id, channel, payload } = msg;
|
|
const handler = BACKEND_REQUEST_HANDLERS[channel];
|
|
// Under npm run dev:electron the runner owns the backend and relays these messages.
|
|
const target =
|
|
backendProcess && !backendProcess.killed
|
|
? backendProcess
|
|
: isDevRunner()
|
|
? process
|
|
: null;
|
|
const reply = (response) => {
|
|
if (!target) return;
|
|
try {
|
|
target.send({ type: "backend-response", id, ...response });
|
|
} catch (error) {
|
|
logToFile("Failed to reply to backend request:", error.message);
|
|
}
|
|
};
|
|
if (!handler) {
|
|
reply({ ok: false, error: `Unknown backend request channel: ${channel}` });
|
|
return;
|
|
}
|
|
try {
|
|
const result = await handler(payload);
|
|
reply({ ok: true, result });
|
|
} catch (error) {
|
|
reply({ ok: false, error: error.message || String(error) });
|
|
}
|
|
}
|
|
|
|
let backendProcess = null;
|
|
let backendStartFailed = false;
|
|
// Why the embedded backend died, once it has. Null while it is healthy
|
|
// or still starting. The renderer polls this to tell "still booting"
|
|
// (keep waiting) apart from "never coming back" (show the reason).
|
|
let backendFailure = null;
|
|
// Set while we are deliberately tearing the backend down on quit, so a
|
|
// SIGKILL we sent ourselves is not reported to the user as a crash.
|
|
let backendStopRequested = false;
|
|
// Tail of the embedded backend's stderr, kept only so a failed start can be
|
|
// classified after the fact.
|
|
const BACKEND_STDERR_TAIL_LIMIT = 8192;
|
|
let backendStderrTail = "";
|
|
let tray = null;
|
|
let isQuitting = false;
|
|
const tempFiles = new Map();
|
|
const externalEditorSessions = new Map();
|
|
|
|
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
|
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
|
|
const windowsAppUserModelId = "com.karmaa.termix";
|
|
const electronCacheBuildPath = path.join(
|
|
app.getPath("userData"),
|
|
"client-cache-build.json",
|
|
);
|
|
const termixSessionPartition = "persist:termix";
|
|
|
|
function getTempRoot() {
|
|
const tempRoot = path.join(app.getPath("temp"), "termix");
|
|
fs.mkdirSync(tempRoot, { recursive: true });
|
|
return tempRoot;
|
|
}
|
|
|
|
function sanitizeFileName(fileName) {
|
|
const baseName = path.basename(String(fileName || "file"));
|
|
return baseName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") || "file";
|
|
}
|
|
|
|
function decodeFileContent(content, encoding) {
|
|
if (encoding === "base64") {
|
|
return Buffer.from(String(content || ""), "base64");
|
|
}
|
|
return Buffer.from(String(content || ""), "utf8");
|
|
}
|
|
|
|
function createManagedTempFile(fileName, content, encoding = "utf8") {
|
|
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`));
|
|
const filePath = path.join(tempDir, sanitizeFileName(fileName));
|
|
fs.writeFileSync(filePath, decodeFileContent(content, encoding));
|
|
tempFiles.set(tempId, { path: filePath, dir: tempDir });
|
|
return { tempId, path: filePath };
|
|
}
|
|
|
|
function cleanupManagedTempFile(tempId) {
|
|
const temp = tempFiles.get(tempId);
|
|
if (!temp) return;
|
|
try {
|
|
fs.rmSync(temp.dir, { recursive: true, force: true });
|
|
} catch (error) {
|
|
logToFile("Failed to clean up temporary file:", error.message);
|
|
}
|
|
tempFiles.delete(tempId);
|
|
}
|
|
|
|
function closeExternalEditorSession(editId) {
|
|
const session = externalEditorSessions.get(editId);
|
|
if (!session) return;
|
|
if (session.timer) clearTimeout(session.timer);
|
|
try {
|
|
session.watcher.close();
|
|
} catch {
|
|
// watcher may already be closed
|
|
}
|
|
externalEditorSessions.delete(editId);
|
|
cleanupManagedTempFile(editId);
|
|
}
|
|
|
|
function notifyExternalEditorSaved(editId) {
|
|
const session = externalEditorSessions.get(editId);
|
|
if (!session || !mainWindow || mainWindow.isDestroyed()) return;
|
|
|
|
try {
|
|
const stat = fs.statSync(session.path);
|
|
if (stat.mtimeMs === session.lastMtimeMs) return;
|
|
session.lastMtimeMs = stat.mtimeMs;
|
|
|
|
const content = fs.readFileSync(session.path, "utf8");
|
|
mainWindow.webContents.send("external-editor-saved", {
|
|
editId,
|
|
content,
|
|
encoding: "utf8",
|
|
path: session.path,
|
|
});
|
|
} catch (error) {
|
|
logToFile("Failed to read external editor file:", error.message);
|
|
}
|
|
}
|
|
|
|
function openPathWithEditor(filePath, editorPath) {
|
|
if (!editorPath) {
|
|
return shell.openPath(filePath);
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const child = spawn(editorPath, [filePath], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
});
|
|
|
|
child.once("error", (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(error.message);
|
|
});
|
|
|
|
setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
child.unref();
|
|
resolve("");
|
|
}, 500);
|
|
});
|
|
}
|
|
|
|
// Origins the renderer has explicitly marked as allowed to present an invalid
|
|
// certificate, for web endpoints whose ignoreCert is set. Session scoped and
|
|
// never persisted.
|
|
//
|
|
// Deliberately NOT wired into isInvalidCertificateAllowedForUrl: that function
|
|
// also governs this process's own outbound TLS via getTlsVerificationOptions,
|
|
// and widening it would grant more privilege than the feature needs.
|
|
//
|
|
// Entries expire rather than living until the app restarts (days, on a
|
|
// desktop app). Main has no host-database access, so it cannot verify a
|
|
// renderer's claim that an origin really is a configured web endpoint -- an
|
|
// unbounded allowance would make that an indefinite window rather than a
|
|
// momentary one. The renderer re-registers before each load, which refreshes
|
|
// the expiry, so a live endpoint keeps working while a stale entry lapses.
|
|
const WEB_ENDPOINT_CERTIFICATE_ALLOWLIST_TTL_MS = 5 * 60 * 1000;
|
|
const webEndpointCertificateAllowlist = new Map();
|
|
|
|
function isWebEndpointCertificateAllowed(url) {
|
|
try {
|
|
const parsed = new URL(url);
|
|
// The allowance exists solely to suppress a TLS certificate error, so a
|
|
// non-https origin in the map is meaningless at best.
|
|
if (parsed.protocol !== "https:") return false;
|
|
|
|
const expiresAt = webEndpointCertificateAllowlist.get(parsed.origin);
|
|
if (expiresAt === undefined) return false;
|
|
if (Date.now() >= expiresAt) {
|
|
webEndpointCertificateAllowlist.delete(parsed.origin);
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("allow-invalid-certificate-for-origin", (_event, origin) => {
|
|
try {
|
|
// Store the PARSED origin, never the caller's string, so a path or a
|
|
// wildcard cannot widen the allowance.
|
|
const parsed = new URL(String(origin));
|
|
if (parsed.protocol !== "https:") {
|
|
return { success: false };
|
|
}
|
|
webEndpointCertificateAllowlist.set(
|
|
parsed.origin,
|
|
Date.now() + WEB_ENDPOINT_CERTIFICATE_ALLOWLIST_TTL_MS,
|
|
);
|
|
return { success: true };
|
|
} catch {
|
|
return { success: false };
|
|
}
|
|
});
|
|
|
|
app.on(
|
|
"certificate-error",
|
|
(event, _webContents, url, error, certificate, callback) => {
|
|
if (
|
|
isolatedWindows.handleCertificateError(event, _webContents, url, callback)
|
|
)
|
|
return;
|
|
if (isWebEndpointCertificateAllowed(url)) {
|
|
event.preventDefault();
|
|
logToFile("Allowed invalid certificate for configured web endpoint", {
|
|
url,
|
|
error,
|
|
issuer: certificate?.issuerName,
|
|
subject: certificate?.subjectName,
|
|
});
|
|
callback(true);
|
|
return;
|
|
}
|
|
|
|
if (isInvalidCertificateAllowedForUrl(url)) {
|
|
event.preventDefault();
|
|
logToFile("Allowed invalid certificate for configured server", {
|
|
url,
|
|
error,
|
|
issuer: certificate?.issuerName,
|
|
subject: certificate?.subjectName,
|
|
});
|
|
callback(true);
|
|
return;
|
|
}
|
|
|
|
callback(false);
|
|
},
|
|
);
|
|
|
|
// A reverse proxy in front of the linked server may ask for basic auth; the
|
|
// link's saved username and password answer it. Anything else gets the
|
|
// default behaviour.
|
|
app.on("login", (event, _webContents, details, authInfo, callback) => {
|
|
const credentials = linkedServer.answerLogin(authInfo, details?.url);
|
|
if (!credentials) return;
|
|
event.preventDefault();
|
|
callback(credentials.username, credentials.password || "");
|
|
});
|
|
|
|
function getElectronBuildTimestamp() {
|
|
try {
|
|
const buildInfo = require("./build-info.cjs");
|
|
if (Number.isInteger(buildInfo.buildTimestamp)) {
|
|
return buildInfo.buildTimestamp;
|
|
}
|
|
} catch {
|
|
// Development runs may not have generated build metadata yet.
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
async function clearElectronClientCacheIfBuildChanged() {
|
|
const buildTimestamp = getElectronBuildTimestamp();
|
|
let cacheTimestamp = 0;
|
|
|
|
try {
|
|
if (fs.existsSync(electronCacheBuildPath)) {
|
|
const data = JSON.parse(fs.readFileSync(electronCacheBuildPath, "utf8"));
|
|
cacheTimestamp = Number.isInteger(data.buildTimestamp)
|
|
? data.buildTimestamp
|
|
: 0;
|
|
}
|
|
} catch (error) {
|
|
logToFile(
|
|
"Failed to read Electron client cache build info:",
|
|
error.message,
|
|
);
|
|
}
|
|
|
|
if (cacheTimestamp === buildTimestamp) {
|
|
return;
|
|
}
|
|
|
|
const clearStep = async (label, action) => {
|
|
try {
|
|
await action();
|
|
} catch (error) {
|
|
logToFile(`Failed to clear Electron ${label}:`, error.message);
|
|
}
|
|
};
|
|
|
|
try {
|
|
const defaultSession = session.defaultSession;
|
|
await clearStep("HTTP cache", () => defaultSession.clearCache());
|
|
await clearStep("code cache", () =>
|
|
defaultSession.clearCodeCaches({ urls: [] }),
|
|
);
|
|
await clearStep("auth cache", () => defaultSession.clearAuthCache());
|
|
await clearStep("storage data", () =>
|
|
defaultSession.clearStorageData({
|
|
storages: [
|
|
"appcache",
|
|
"cookies",
|
|
"filesystem",
|
|
"shadercache",
|
|
"websql",
|
|
"serviceworkers",
|
|
"cachestorage",
|
|
],
|
|
}),
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
electronCacheBuildPath,
|
|
JSON.stringify(
|
|
{
|
|
buildTimestamp,
|
|
appVersion: app.getVersion(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
logToFile("Electron client cache cleared for build change", {
|
|
from: cacheTimestamp,
|
|
to: buildTimestamp,
|
|
appVersion: app.getVersion(),
|
|
});
|
|
} catch (error) {
|
|
logToFile("Failed to clear Electron client cache:", error.message);
|
|
}
|
|
}
|
|
|
|
function getCookieRemovalUrl(cookie) {
|
|
const scheme = cookie.secure ? "https" : "http";
|
|
const domain = cookie.domain?.startsWith(".")
|
|
? cookie.domain.slice(1)
|
|
: cookie.domain || "localhost";
|
|
return `${scheme}://${domain}${cookie.path || "/"}`;
|
|
}
|
|
|
|
async function clearElectronJwtCookiesAtStartup() {
|
|
loadElectronAuthCookiesFromDisk();
|
|
|
|
const targetSessions = new Set([
|
|
session.defaultSession,
|
|
session.fromPartition(termixSessionPartition),
|
|
]);
|
|
|
|
for (const targetSession of targetSessions) {
|
|
try {
|
|
const cookies = await targetSession.cookies.get({ name: "jwt" });
|
|
await Promise.all(
|
|
cookies.map((cookie) =>
|
|
targetSession.cookies.remove(
|
|
getCookieRemovalUrl(cookie),
|
|
cookie.name,
|
|
),
|
|
),
|
|
);
|
|
|
|
if (cookies.length > 0) {
|
|
logToFile("Cleared Electron JWT cookies from cookie store", {
|
|
count: cookies.length,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logToFile("Failed to clear Electron JWT cookies:", error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getBackendPaths() {
|
|
if (isDev) {
|
|
const backendDir = path.join(appRoot, "dist", "backend", "backend");
|
|
return {
|
|
entryPath: path.join(backendDir, "starter.js"),
|
|
backendCwd: backendDir,
|
|
};
|
|
}
|
|
// fork() does not go through Electron's asar redirector — use the unpacked path.
|
|
// On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR
|
|
// app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
|
|
const unpackedRoot = getUnpackedAppRoot(appRoot);
|
|
const backendDir = path.join(unpackedRoot, "dist", "backend", "backend");
|
|
return {
|
|
entryPath: path.join(backendDir, "starter.js"),
|
|
backendCwd: backendDir,
|
|
};
|
|
}
|
|
|
|
function getBackendDataDir() {
|
|
const userDataPath = app.getPath("userData");
|
|
const dataDir = path.join(userDataPath, "server-data");
|
|
if (!fs.existsSync(dataDir)) {
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
}
|
|
return dataDir;
|
|
}
|
|
|
|
function getBackendPidFilePath() {
|
|
return path.join(app.getPath("userData"), "backend.pid");
|
|
}
|
|
|
|
// If the app was previously killed abnormally (crash, force-quit, Task
|
|
// Manager) rather than through the normal quit flow, will-quit never fires
|
|
// and stopBackendServer() never runs -- the forked backend child is a
|
|
// genuinely separate OS process on Windows/mac/Linux, so it keeps running
|
|
// and holding the port the backend binds (30001). Every subsequent
|
|
// launch's own backend then fails outright
|
|
// with EADDRINUSE and the app is stuck until something manually kills the
|
|
// orphan. Reap any such leftover process, identified by PID file, before
|
|
// spawning a new one.
|
|
function reapOrphanedBackendProcess() {
|
|
const pidFilePath = getBackendPidFilePath();
|
|
let recordedPid;
|
|
try {
|
|
recordedPid = parseInt(fs.readFileSync(pidFilePath, "utf8").trim(), 10);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (!Number.isInteger(recordedPid) || recordedPid <= 0) return;
|
|
|
|
try {
|
|
// Signal 0 does not kill the process -- it only checks whether a
|
|
// process with this PID exists and is signalable, throwing ESRCH if
|
|
// not. This avoids killing an unrelated process that happens to have
|
|
// reused the same PID since the last run.
|
|
process.kill(recordedPid, 0);
|
|
} catch {
|
|
// No live process at that PID; nothing to reap.
|
|
try {
|
|
fs.unlinkSync(pidFilePath);
|
|
} catch {
|
|
// already absent
|
|
}
|
|
return;
|
|
}
|
|
|
|
logToFile(
|
|
`Found orphaned backend process from a previous session (pid ${recordedPid}), terminating it before starting a new one`,
|
|
);
|
|
try {
|
|
process.kill(recordedPid, "SIGKILL");
|
|
} catch {
|
|
// already gone
|
|
}
|
|
try {
|
|
fs.unlinkSync(pidFilePath);
|
|
} catch {
|
|
// already absent
|
|
}
|
|
}
|
|
|
|
function startBackendServer() {
|
|
reapOrphanedBackendProcess();
|
|
backendFailure = null;
|
|
backendStopRequested = false;
|
|
backendStderrTail = "";
|
|
return new Promise((resolve) => {
|
|
const { entryPath, backendCwd } = getBackendPaths();
|
|
|
|
logToFile("isDev:", isDev, "appRoot:", appRoot);
|
|
logToFile("app.isPackaged:", app.isPackaged);
|
|
logToFile("process.env.NODE_ENV:", process.env.NODE_ENV);
|
|
|
|
if (!fs.existsSync(entryPath)) {
|
|
logToFile("Backend entry not found:", entryPath);
|
|
backendFailure = { reason: "crashed", port: null };
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
const dataDir = getBackendDataDir();
|
|
logToFile("Starting embedded backend server...");
|
|
logToFile("Backend entry:", entryPath);
|
|
logToFile("Data directory:", dataDir);
|
|
logToFile("Backend cwd:", backendCwd);
|
|
|
|
logToFile("Checking paths...");
|
|
logToFile(" entryPath exists:", fs.existsSync(entryPath));
|
|
logToFile(" dataDir exists:", fs.existsSync(dataDir));
|
|
logToFile(" backendCwd exists:", fs.existsSync(backendCwd));
|
|
|
|
backendProcess = fork(entryPath, [], {
|
|
cwd:
|
|
fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory()
|
|
? backendCwd
|
|
: dataDir,
|
|
env: {
|
|
...process.env,
|
|
DATA_DIR: dataDir,
|
|
NODE_ENV: "production",
|
|
ELECTRON_EMBEDDED: "true",
|
|
PORT: "30001",
|
|
VERSION: app.getVersion(),
|
|
},
|
|
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
});
|
|
|
|
logToFile("Backend process spawned, pid:", backendProcess.pid);
|
|
try {
|
|
fs.writeFileSync(getBackendPidFilePath(), String(backendProcess.pid));
|
|
} catch {
|
|
// Non-fatal: only means a future crash won't self-heal via reap.
|
|
}
|
|
|
|
let resolved = false;
|
|
const readyTimeout = setTimeout(() => {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
logToFile("Backend ready timeout (15s), proceeding anyway...");
|
|
resolve(true);
|
|
}
|
|
}, 15000);
|
|
|
|
backendProcess.stdout.on("data", (data) => {
|
|
const msg = data.toString().trim();
|
|
logToFile("[backend]", msg);
|
|
if (!resolved && msg.includes("started successfully")) {
|
|
resolved = true;
|
|
clearTimeout(readyTimeout);
|
|
logToFile("Backend ready signal received");
|
|
resolve(true);
|
|
}
|
|
});
|
|
|
|
// The reason for a failed start is only ever visible in what the child
|
|
// wrote to stderr before dying, so keep a bounded tail of it. Bounded
|
|
// because a backend that stays up for days can otherwise log without
|
|
// limit into a buffer nothing ever drains.
|
|
backendProcess.stderr.on("data", (data) => {
|
|
const chunk = data.toString();
|
|
backendStderrTail = (backendStderrTail + chunk).slice(
|
|
-BACKEND_STDERR_TAIL_LIMIT,
|
|
);
|
|
logToFile("[backend:stderr]", chunk.trim());
|
|
});
|
|
|
|
backendProcess.on("message", (msg) => {
|
|
if (!resolved && msg?.type === "backend-ready") {
|
|
resolved = true;
|
|
clearTimeout(readyTimeout);
|
|
logToFile("Backend ready signal received via IPC");
|
|
resolve(true);
|
|
}
|
|
void handleBackendRequest(msg);
|
|
});
|
|
|
|
backendProcess.on("exit", (code, signal) => {
|
|
logToFile(`Backend process exited with code ${code}, signal ${signal}`);
|
|
if (!resolved && code !== 0) {
|
|
backendStartFailed = true;
|
|
}
|
|
// Classified whether or not the ready promise already settled: the
|
|
// 15s ready timeout resolves optimistically, so a backend that dies
|
|
// at second 20 still has to be reported rather than silently dropped.
|
|
// backendStopRequested is the only evidence that an exit was asked
|
|
// for, so it is the sole guard here -- past it, every exit means the
|
|
// backend is gone and the renderer must stop waiting for it.
|
|
if (!backendStopRequested) {
|
|
backendFailure = classifyBackendFailure(backendStderrTail);
|
|
logToFile("Backend failure classified as:", backendFailure.reason);
|
|
}
|
|
backendProcess = null;
|
|
clearBackendPidFile();
|
|
if (!resolved) {
|
|
resolved = true;
|
|
clearTimeout(readyTimeout);
|
|
resolve(false);
|
|
}
|
|
});
|
|
|
|
backendProcess.on("error", (err) => {
|
|
logToFile("Failed to start backend process:", err.message);
|
|
backendFailure = { reason: "crashed", port: null };
|
|
backendProcess = null;
|
|
if (!resolved) {
|
|
resolved = true;
|
|
clearTimeout(readyTimeout);
|
|
resolve(false);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function clearBackendPidFile() {
|
|
try {
|
|
fs.unlinkSync(getBackendPidFilePath());
|
|
} catch {
|
|
// already absent
|
|
}
|
|
}
|
|
|
|
function stopBackendServer() {
|
|
if (!backendProcess) return;
|
|
|
|
console.log("Stopping embedded backend server...");
|
|
backendStopRequested = true;
|
|
|
|
try {
|
|
backendProcess.send({ type: "shutdown" });
|
|
} catch {
|
|
// IPC channel may already be closed
|
|
}
|
|
|
|
const forceKillTimeout = setTimeout(() => {
|
|
if (backendProcess) {
|
|
console.log("Force killing backend process...");
|
|
backendProcess.kill("SIGKILL");
|
|
backendProcess = null;
|
|
}
|
|
}, 5000);
|
|
|
|
backendProcess.on("exit", () => {
|
|
clearTimeout(forceKillTimeout);
|
|
backendProcess = null;
|
|
clearBackendPidFile();
|
|
});
|
|
}
|
|
|
|
// App Sandbox denies the singleton socket bind, which reads as a held lock and
|
|
// quits the app on every launch. macOS reactivates bundled apps anyway.
|
|
const gotTheLock = process.mas || app.requestSingleInstanceLock();
|
|
if (!gotTheLock) {
|
|
console.log("Another instance is already running, quitting...");
|
|
app.quit();
|
|
process.exit(0);
|
|
} else {
|
|
app.on("second-instance", (event, commandLine, workingDirectory) => {
|
|
if (mainWindow) {
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.focus();
|
|
mainWindow.show();
|
|
}
|
|
});
|
|
}
|
|
|
|
function createTray() {
|
|
try {
|
|
const { nativeImage } = require("electron");
|
|
|
|
// Native APIs (Tray, nativeImage) can't load files from inside app.asar —
|
|
// use the unpacked path so the OS sees a real file.
|
|
const publicRoot = isDev
|
|
? path.join(appRoot, "public")
|
|
: path.join(
|
|
appRoot.replace(
|
|
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
|
|
"app.asar.unpacked",
|
|
),
|
|
"public",
|
|
);
|
|
|
|
let trayIcon;
|
|
if (process.platform === "darwin") {
|
|
const iconPath = path.join(publicRoot, "icons", "16x16.png");
|
|
trayIcon = nativeImage.createFromPath(iconPath);
|
|
trayIcon.setTemplateImage(true);
|
|
} else if (process.platform === "win32") {
|
|
trayIcon = path.join(publicRoot, "icon.ico");
|
|
} else {
|
|
trayIcon = loadLinuxTrayIcon(nativeImage, publicRoot);
|
|
if (!trayIcon) {
|
|
logToFile("Tray icon not found, skipping the system tray");
|
|
return;
|
|
}
|
|
if (/gnome/i.test(process.env.XDG_CURRENT_DESKTOP || "")) {
|
|
logToFile(
|
|
"GNOME shows tray icons only with the AppIndicator extension installed",
|
|
);
|
|
}
|
|
}
|
|
|
|
tray = new Tray(trayIcon);
|
|
tray.setToolTip("Termix");
|
|
|
|
const contextMenu = Menu.buildFromTemplate([
|
|
{
|
|
label: "Show Window",
|
|
click: () => {
|
|
if (mainWindow) {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: "Quit",
|
|
click: () => {
|
|
isQuitting = true;
|
|
quitApp(app, mainWindow);
|
|
},
|
|
},
|
|
]);
|
|
|
|
tray.setContextMenu(contextMenu);
|
|
|
|
tray.on("click", () => {
|
|
if (mainWindow) {
|
|
if (mainWindow.isVisible()) {
|
|
mainWindow.hide();
|
|
} else {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
}
|
|
});
|
|
|
|
logToFile("System tray created");
|
|
} catch (err) {
|
|
tray = null;
|
|
logToFile("Failed to create system tray:", err?.message || String(err));
|
|
}
|
|
}
|
|
|
|
function loadLinuxTrayIcon(nativeImage, publicRoot) {
|
|
const roots = [
|
|
publicRoot,
|
|
path.join(process.resourcesPath || "", "app.asar.unpacked", "public"),
|
|
];
|
|
for (const root of roots) {
|
|
for (const size of ["32x32", "24x24", "48x48"]) {
|
|
const image = nativeImage.createFromPath(
|
|
path.join(root, "icons", `${size}.png`),
|
|
);
|
|
if (!image.isEmpty()) return image;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function createWindow() {
|
|
const appVersion = app.getVersion();
|
|
const electronVersion = process.versions.electron;
|
|
const platform =
|
|
process.platform === "win32"
|
|
? "Windows"
|
|
: process.platform === "darwin"
|
|
? "macOS"
|
|
: "Linux";
|
|
|
|
mainWindow = new BrowserWindow({
|
|
width: 1200,
|
|
height: 800,
|
|
minWidth: 800,
|
|
minHeight: 600,
|
|
title: "Termix",
|
|
icon: path.join(
|
|
appRoot,
|
|
"public",
|
|
process.platform === "win32" ? "icon.ico" : "icon.png",
|
|
),
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
webSecurity: true,
|
|
preload: path.join(__dirname, "preload.js"),
|
|
partition: termixSessionPartition,
|
|
allowRunningInsecureContent: false,
|
|
webviewTag: false,
|
|
offscreen: false,
|
|
},
|
|
show: true,
|
|
});
|
|
|
|
mainWindow.webContents.session.setPermissionRequestHandler(
|
|
(webContents, permission, callback) => {
|
|
if (
|
|
permission === "clipboard-read" ||
|
|
permission === "clipboard-write" ||
|
|
permission === "clipboard-sanitized-write"
|
|
) {
|
|
callback(true);
|
|
return;
|
|
}
|
|
callback(false);
|
|
},
|
|
);
|
|
|
|
if (process.platform !== "darwin") {
|
|
mainWindow.setMenuBarVisibility(false);
|
|
}
|
|
|
|
const customUserAgent = `Termix-Desktop/${appVersion} (${platform}; Electron/${electronVersion})`;
|
|
mainWindow.webContents.setUserAgent(customUserAgent);
|
|
|
|
mainWindow.webContents.on("before-input-event", (event, input) => {
|
|
if (process.platform !== "win32" || !isCloseActiveTabInput(input)) return;
|
|
|
|
event.preventDefault();
|
|
mainWindow.webContents.send("close-active-tab");
|
|
});
|
|
|
|
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
|
(details, callback) => {
|
|
details.requestHeaders["X-Electron-App"] = "true";
|
|
|
|
details.requestHeaders["User-Agent"] = customUserAgent;
|
|
|
|
// YouTube refuses embeds without a Referer, which file:// never sends.
|
|
if (
|
|
details.url.startsWith("https://www.youtube-nocookie.com/embed/") &&
|
|
!details.requestHeaders.Referer
|
|
) {
|
|
details.requestHeaders.Referer = "https://termix.site/";
|
|
}
|
|
|
|
const rememberedJwt = getRememberedElectronAuthCookie("jwt", details.url);
|
|
if (rememberedJwt) {
|
|
setCookieHeaderValue(
|
|
details.requestHeaders,
|
|
rememberedJwt.name,
|
|
rememberedJwt.value,
|
|
);
|
|
}
|
|
|
|
callback({
|
|
requestHeaders: linkedServer.applyLinkHeaders(
|
|
details.url,
|
|
details.requestHeaders,
|
|
),
|
|
});
|
|
},
|
|
);
|
|
|
|
if (isDev) {
|
|
mainWindow.loadURL("http://localhost:5173");
|
|
mainWindow.webContents.openDevTools();
|
|
} else {
|
|
const indexPath = path.join(appRoot, "dist", "index.html");
|
|
mainWindow.loadFile(indexPath).catch((err) => {
|
|
console.error("Failed to load file:", err);
|
|
});
|
|
}
|
|
|
|
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
|
(details, callback) => {
|
|
const headers = details.responseHeaders;
|
|
|
|
if (headers) {
|
|
delete headers["x-frame-options"];
|
|
delete headers["X-Frame-Options"];
|
|
|
|
if (headers["content-security-policy"]) {
|
|
headers["content-security-policy"] = headers[
|
|
"content-security-policy"
|
|
]
|
|
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
|
|
.filter((value) => value.trim().length > 0);
|
|
|
|
if (headers["content-security-policy"].length === 0) {
|
|
delete headers["content-security-policy"];
|
|
}
|
|
}
|
|
if (headers["Content-Security-Policy"]) {
|
|
headers["Content-Security-Policy"] = headers[
|
|
"Content-Security-Policy"
|
|
]
|
|
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
|
|
.filter((value) => value.trim().length > 0);
|
|
|
|
if (headers["Content-Security-Policy"].length === 0) {
|
|
delete headers["Content-Security-Policy"];
|
|
}
|
|
}
|
|
|
|
const setCookieHeaderName = getHeaderName(headers, "Set-Cookie");
|
|
if (setCookieHeaderName) {
|
|
const setCookieHeaders = Array.isArray(headers[setCookieHeaderName])
|
|
? headers[setCookieHeaderName]
|
|
: [headers[setCookieHeaderName]];
|
|
|
|
setCookieHeaders.forEach((cookie) => {
|
|
rememberElectronAuthCookieFromHeader(details.url, cookie);
|
|
});
|
|
|
|
headers[setCookieHeaderName] = setCookieHeaders.map((cookie) => {
|
|
let modified = cookie.replace(
|
|
/;\s*SameSite=Strict/gi,
|
|
"; SameSite=None",
|
|
);
|
|
modified = modified.replace(
|
|
/;\s*SameSite=Lax/gi,
|
|
"; SameSite=None",
|
|
);
|
|
if (!modified.includes("SameSite=")) {
|
|
modified += "; SameSite=None";
|
|
}
|
|
if (
|
|
!modified.includes("Secure") &&
|
|
details.url.startsWith("https")
|
|
) {
|
|
modified += "; Secure";
|
|
}
|
|
return modified;
|
|
});
|
|
}
|
|
}
|
|
|
|
callback({ responseHeaders: headers });
|
|
},
|
|
);
|
|
|
|
mainWindow.once("ready-to-show", () => {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
});
|
|
|
|
setTimeout(() => {
|
|
if (mainWindow && !mainWindow.isVisible()) {
|
|
mainWindow.show();
|
|
}
|
|
}, 3000);
|
|
|
|
mainWindow.webContents.on(
|
|
"did-fail-load",
|
|
(event, errorCode, errorDescription, validatedURL) => {
|
|
console.error(
|
|
"Failed to load:",
|
|
errorCode,
|
|
errorDescription,
|
|
validatedURL,
|
|
);
|
|
},
|
|
);
|
|
|
|
mainWindow.webContents.on("did-finish-load", () => {
|
|
console.log("Frontend loaded successfully");
|
|
});
|
|
|
|
mainWindow.webContents.on("will-prevent-unload", (event) => {
|
|
// Explicit Quit must not be vetoed by the renderer's connection guard.
|
|
// In this Electron event, preventDefault allows the unload to proceed.
|
|
if (isQuitting) event.preventDefault();
|
|
});
|
|
|
|
mainWindow.on("close", (event) => {
|
|
if (!isQuitting && tray && !tray.isDestroyed()) {
|
|
event.preventDefault();
|
|
// Stock GNOME has no tray to bring a hidden window back from.
|
|
if (
|
|
process.platform === "linux" &&
|
|
/gnome/i.test(process.env.XDG_CURRENT_DESKTOP || "")
|
|
) {
|
|
mainWindow.minimize();
|
|
} else {
|
|
mainWindow.hide();
|
|
}
|
|
}
|
|
});
|
|
|
|
mainWindow.on("closed", () => {
|
|
mainWindow = null;
|
|
});
|
|
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
shell.openExternal(url);
|
|
}
|
|
} catch {
|
|
// invalid URL, ignore
|
|
}
|
|
return { action: "deny" };
|
|
});
|
|
|
|
mainWindow.webContents.on("will-navigate", (event, url) => {
|
|
const allowedUrl = isDev
|
|
? url.startsWith("http://localhost:5173/")
|
|
: url === pathToFileURL(path.join(appRoot, "dist", "index.html")).href;
|
|
if (!allowedUrl) event.preventDefault();
|
|
});
|
|
}
|
|
|
|
ipcMain.handle("get-app-version", () => {
|
|
return app.getVersion();
|
|
});
|
|
|
|
ipcMain.handle("get-platform", () => {
|
|
return process.platform;
|
|
});
|
|
|
|
ipcMain.handle("get-embedded-server-status", () => {
|
|
return {
|
|
running:
|
|
backendProcess !== null && !backendProcess.killed && !backendStartFailed,
|
|
// A backend that is merely slow to boot reports no failure, so the
|
|
// renderer keeps waiting for it. Only a classified failure means the
|
|
// process is gone for good and waiting is pointless.
|
|
failure: backendFailure,
|
|
dataDir: isDev ? null : getBackendDataDir(),
|
|
};
|
|
});
|
|
|
|
// External login through the system browser (RFC 8252)
|
|
ipcMain.handle(
|
|
"external-browser-login",
|
|
async (_event, authUrl, callbackPort) => {
|
|
const http = require("http");
|
|
|
|
return new Promise((resolve) => {
|
|
let timeout;
|
|
let settled = false;
|
|
const server = http.createServer((req, res) => {
|
|
const url = new URL(req.url || "/", `http://localhost:${callbackPort}`);
|
|
if (url.pathname === "/oidc-callback") {
|
|
const success = url.searchParams.get("success");
|
|
const error = url.searchParams.get("error");
|
|
const token = url.searchParams.get("token");
|
|
const secondFactor = url.searchParams.get("second_factor") === "1";
|
|
|
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
res.end(
|
|
`<html><body><h2>${success === "true" || secondFactor ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`,
|
|
);
|
|
|
|
if (secondFactor) {
|
|
finish({
|
|
success: false,
|
|
secondFactor: true,
|
|
tempToken: url.searchParams.get("temp_token"),
|
|
factors: url.searchParams.get("second_factors"),
|
|
});
|
|
} else if (success === "true") {
|
|
finish({ success: true, token });
|
|
} else {
|
|
finish({
|
|
success: false,
|
|
error: error || "Authentication failed",
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
res.end("Not found");
|
|
});
|
|
|
|
const finish = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timeout) clearTimeout(timeout);
|
|
try {
|
|
server.close();
|
|
} catch {
|
|
// Server may not have started yet.
|
|
}
|
|
resolve(result);
|
|
};
|
|
|
|
const fail = (error) => {
|
|
finish({
|
|
success: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
};
|
|
|
|
server.once("error", fail);
|
|
|
|
server.listen(callbackPort, "localhost", async () => {
|
|
try {
|
|
await shell.openExternal(authUrl);
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
// Timeout after 5 minutes
|
|
timeout = setTimeout(
|
|
() => {
|
|
fail(new Error("External login timed out"));
|
|
},
|
|
5 * 60 * 1000,
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
// Desktop-only settings, such as whether hosts connect from this device or
|
|
// through the linked server by default.
|
|
ipcMain.handle("get-desktop-settings", () => {
|
|
return desktopSettings.getDesktopSettings();
|
|
});
|
|
|
|
ipcMain.handle("save-desktop-settings", (_event, settings) => {
|
|
return desktopSettings.saveDesktopSettings(settings);
|
|
});
|
|
|
|
// The server an older version of the app was pointed at, offered again when
|
|
// linking so upgraded installs do not have to look it up.
|
|
ipcMain.handle("get-previous-server-url", () => {
|
|
return { serverUrl: desktopSettings.getPreviousServerUrl() };
|
|
});
|
|
|
|
function getC2STunnelConfigPath() {
|
|
return path.join(app.getPath("userData"), "c2s-tunnels.json");
|
|
}
|
|
|
|
ipcMain.handle("get-c2s-tunnel-config", () => {
|
|
try {
|
|
const configPath = getC2STunnelConfigPath();
|
|
if (!fs.existsSync(configPath)) {
|
|
return [];
|
|
}
|
|
const configData = fs.readFileSync(configPath, "utf8");
|
|
const parsed = JSON.parse(configData);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch (error) {
|
|
console.error("Error reading C2S tunnel config:", error);
|
|
return [];
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("save-c2s-tunnel-config", async (_event, config) => {
|
|
try {
|
|
if (!Array.isArray(config)) {
|
|
return { success: false, error: "C2S tunnel config must be an array" };
|
|
}
|
|
const autoStartListeners = new Set();
|
|
const autoStartRemoteListeners = new Set();
|
|
for (const tunnel of config) {
|
|
if (!tunnel?.autoStart) continue;
|
|
const mode = tunnel.mode || tunnel.tunnelType || "local";
|
|
if (mode === "remote") {
|
|
const sourceHostId = Number(tunnel.sourceHostId);
|
|
const sourcePort = Number(tunnel.sourcePort);
|
|
const remoteAddress = getC2SRemoteAddress(tunnel);
|
|
if (
|
|
!Number.isInteger(sourceHostId) ||
|
|
sourceHostId < 1 ||
|
|
!Number.isInteger(sourcePort) ||
|
|
sourcePort < 1 ||
|
|
sourcePort > 65535
|
|
) {
|
|
return {
|
|
success: false,
|
|
error: "Invalid remote client tunnel endpoint or port",
|
|
};
|
|
}
|
|
const listenerKey = `${sourceHostId}:${remoteAddress}:${sourcePort}`;
|
|
if (autoStartRemoteListeners.has(listenerKey)) {
|
|
return {
|
|
success: false,
|
|
error: `Another auto-start client tunnel already uses remote ${listenerKey}`,
|
|
};
|
|
}
|
|
autoStartRemoteListeners.add(listenerKey);
|
|
continue;
|
|
}
|
|
|
|
const bindHost = getC2SLocalAddress(tunnel);
|
|
const sourcePort = Number(tunnel.sourcePort);
|
|
const listenerKey = `${bindHost}:${sourcePort}`;
|
|
if (autoStartListeners.has(listenerKey)) {
|
|
return {
|
|
success: false,
|
|
error: `Another auto-start client tunnel already uses ${listenerKey}`,
|
|
};
|
|
}
|
|
autoStartListeners.add(listenerKey);
|
|
}
|
|
for (const listenerKey of autoStartListeners) {
|
|
const sourcePortSeparator = listenerKey.lastIndexOf(":");
|
|
const bindHost = listenerKey.slice(0, sourcePortSeparator);
|
|
const sourcePort = listenerKey.slice(sourcePortSeparator + 1);
|
|
const result = await checkLocalPortAvailable(
|
|
bindHost,
|
|
Number(sourcePort),
|
|
);
|
|
const ownedByClientTunnel = Array.from(c2sTunnelRuntimes.values()).some(
|
|
(runtime) =>
|
|
runtime.bindHost === bindHost &&
|
|
runtime.sourcePort === Number(sourcePort),
|
|
);
|
|
if (!result.available && !ownedByClientTunnel) {
|
|
return {
|
|
success: false,
|
|
error: `Cannot auto-start client tunnel on ${listenerKey}: ${result.error || "port is already in use"}`,
|
|
};
|
|
}
|
|
}
|
|
const userDataPath = app.getPath("userData");
|
|
if (!fs.existsSync(userDataPath)) {
|
|
fs.mkdirSync(userDataPath, { recursive: true });
|
|
}
|
|
fs.writeFileSync(getC2STunnelConfigPath(), JSON.stringify(config, null, 2));
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Error saving C2S tunnel config:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
function checkLocalPortAvailable(host, port) {
|
|
return new Promise((resolve) => {
|
|
const server = net.createServer();
|
|
server.once("error", (error) => {
|
|
resolve({ available: false, error: error.message });
|
|
});
|
|
server.once("listening", () => {
|
|
server.close(() => resolve({ available: true }));
|
|
});
|
|
server.listen({ host, port });
|
|
});
|
|
}
|
|
|
|
function checkTcpConnection(host, port) {
|
|
return new Promise((resolve) => {
|
|
const socket = net.createConnection({ host, port });
|
|
const timer = setTimeout(() => {
|
|
socket.destroy();
|
|
resolve({ success: false, error: "Connection timed out" });
|
|
}, 5000);
|
|
|
|
socket.once("connect", () => {
|
|
clearTimeout(timer);
|
|
socket.destroy();
|
|
resolve({ success: true });
|
|
});
|
|
socket.once("error", (error) => {
|
|
clearTimeout(timer);
|
|
socket.destroy();
|
|
resolve({ success: false, error: error.message });
|
|
});
|
|
});
|
|
}
|
|
|
|
function getC2SAddress(value, fallback) {
|
|
const address = typeof value === "string" ? value.trim() : "";
|
|
return address || fallback;
|
|
}
|
|
|
|
function getC2SLocalAddress(tunnel) {
|
|
return getC2SAddress(tunnel?.localAddress || tunnel?.bindHost, "127.0.0.1");
|
|
}
|
|
|
|
function getC2SRemoteAddress(tunnel) {
|
|
return getC2SAddress(
|
|
tunnel?.remoteAddress || tunnel?.targetHost,
|
|
"127.0.0.1",
|
|
);
|
|
}
|
|
|
|
function normalizeC2STunnelAddresses(tunnel) {
|
|
const localAddress = getC2SLocalAddress(tunnel);
|
|
const remoteAddress = getC2SRemoteAddress(tunnel);
|
|
return {
|
|
...tunnel,
|
|
localAddress,
|
|
remoteAddress,
|
|
bindHost: localAddress,
|
|
targetHost: remoteAddress,
|
|
};
|
|
}
|
|
|
|
const c2sTunnelRuntimes = new Map();
|
|
const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
|
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
|
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
|
|
|
// C2S (client-to-server) tunnels relay through the server this desktop is
|
|
// linked to, not the embedded backend.
|
|
function getC2SRemoteBaseUrl() {
|
|
const serverUrl = linkedServer.getLinkedServer()?.serverUrl;
|
|
if (!serverUrl) {
|
|
throw new Error(
|
|
"This device is not linked to a Termix server. Link it in Sync first.",
|
|
);
|
|
}
|
|
return serverUrl.replace(/\/$/, "");
|
|
}
|
|
|
|
// The socket path on the linked server that relays C2S streams. The plugin
|
|
// that owns the relay tells us where it is, so nothing here names it.
|
|
let c2sRelayPath = null;
|
|
const C2S_RELAY_PATH_PATTERN =
|
|
/^\/plugin-ws\/[a-z0-9][a-z0-9-]*\/[A-Za-z0-9/_-]+$/;
|
|
|
|
ipcMain.handle("set-c2s-relay-path", (_event, relayPath) => {
|
|
if (
|
|
typeof relayPath !== "string" ||
|
|
!C2S_RELAY_PATH_PATTERN.test(relayPath)
|
|
) {
|
|
return { success: false, error: "Invalid relay path" };
|
|
}
|
|
c2sRelayPath = relayPath;
|
|
return { success: true };
|
|
});
|
|
|
|
function getC2SRelayUrl() {
|
|
if (!c2sRelayPath) {
|
|
throw new Error("Client tunnels are not available yet. Try again shortly.");
|
|
}
|
|
const relayHttpUrl = `${getC2SRemoteBaseUrl()}${c2sRelayPath}`;
|
|
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
}
|
|
|
|
const C2S_REMOTE_SESSION_EXPIRED_ERROR =
|
|
"This device was signed out of its server. Sign in again from Sync and try again.";
|
|
|
|
function normalizeC2SAuthToken(authToken) {
|
|
return typeof authToken === "string" ? authToken.trim() : "";
|
|
}
|
|
|
|
function isC2SAuthError(message) {
|
|
const value = String(message || "")
|
|
.trim()
|
|
.toLowerCase();
|
|
return (
|
|
value === "authentication required" ||
|
|
value === "missing authentication token" ||
|
|
value === "invalid token" ||
|
|
value === "session expired" ||
|
|
value === "session not found" ||
|
|
// The plugin relay refuses an unauthenticated upgrade before any message.
|
|
value === "unexpected server response: 401"
|
|
);
|
|
}
|
|
|
|
function normalizeC2SErrorMessage(message, fallback = "Client tunnel failed") {
|
|
const value = message || fallback;
|
|
return isC2SAuthError(value) ? C2S_REMOTE_SESSION_EXPIRED_ERROR : value;
|
|
}
|
|
|
|
function createC2SFailure(message, fallback) {
|
|
const error = normalizeC2SErrorMessage(message, fallback);
|
|
return {
|
|
success: false,
|
|
error,
|
|
...(error === C2S_REMOTE_SESSION_EXPIRED_ERROR
|
|
? { code: "REMOTE_SESSION_EXPIRED" }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
async function fetchC2SRemoteJson(pathname) {
|
|
if (!linkedServer.getLinkedServer()?.token) {
|
|
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
|
}
|
|
|
|
const url = `${getC2SRemoteBaseUrl()}${pathname}`;
|
|
const response = await httpFetch(url, {
|
|
headers: linkedServer.linkedRequestHeaders(),
|
|
});
|
|
if (response.status === 401 || response.status === 403) {
|
|
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(`Request failed (${response.status}): ${url}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function resolveC2SRemoteSourceHost(tunnel) {
|
|
const normalized = normalizeC2STunnelAddresses(tunnel);
|
|
const sourceHostSyncId =
|
|
typeof normalized.sourceHostSyncId === "string"
|
|
? normalized.sourceHostSyncId.trim()
|
|
: "";
|
|
if (!sourceHostSyncId) return normalized;
|
|
|
|
let remoteHost = null;
|
|
try {
|
|
remoteHost = await fetchC2SRemoteJson(
|
|
`/sync/v2/hosts/${encodeURIComponent(sourceHostSyncId)}`,
|
|
);
|
|
} catch (error) {
|
|
if (error.message === C2S_REMOTE_SESSION_EXPIRED_ERROR) throw error;
|
|
}
|
|
const remoteHostId = Number(remoteHost?.id);
|
|
if (!Number.isInteger(remoteHostId) || remoteHostId < 1) {
|
|
throw new Error(
|
|
"The intermediate host is not on the server yet. Sync, then try again.",
|
|
);
|
|
}
|
|
|
|
return {
|
|
...normalized,
|
|
sourceHostId: remoteHostId,
|
|
sourceHostSyncId,
|
|
sourceHostName: remoteHost.name || normalized.sourceHostName,
|
|
endpointHost: normalized.endpointHost || remoteHost.name,
|
|
};
|
|
}
|
|
|
|
async function getC2SRelayHeaders(relayUrl) {
|
|
if (linkedServer.getLinkedServer()?.token) {
|
|
return linkedServer.linkedRequestHeaders();
|
|
}
|
|
const headers = { "X-Electron-App": "true" };
|
|
|
|
if (!mainWindow?.webContents?.session) {
|
|
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
|
}
|
|
|
|
const cookieUrl = relayUrl
|
|
.replace(/^ws:/, "http:")
|
|
.replace(/^wss:/, "https:");
|
|
const cookies = await mainWindow.webContents.session.cookies.get({
|
|
url: cookieUrl,
|
|
name: "jwt",
|
|
});
|
|
const jwt = cookies[0]?.value;
|
|
if (!jwt) {
|
|
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
|
}
|
|
|
|
headers.Cookie = `jwt=${encodeURIComponent(jwt)}`;
|
|
return headers;
|
|
}
|
|
|
|
const attachC2SAuth = require("./c2s-auth.cjs").createC2SAuthBridge(
|
|
ipcMain,
|
|
() => mainWindow,
|
|
);
|
|
|
|
function getC2STunnelName(tunnel, index = 0) {
|
|
if (tunnel.name) return tunnel.name;
|
|
const localAddress = getC2SLocalAddress(tunnel);
|
|
const remoteAddress = getC2SRemoteAddress(tunnel);
|
|
return [
|
|
"c2s",
|
|
index,
|
|
tunnel.sourceHostId || 0,
|
|
tunnel.mode || tunnel.tunnelType || "local",
|
|
localAddress,
|
|
remoteAddress,
|
|
tunnel.sourcePort,
|
|
tunnel.endpointPort || 0,
|
|
].join("::");
|
|
}
|
|
|
|
function getC2STunnelStatus(tunnelName) {
|
|
return (
|
|
c2sTunnelRuntimes.get(tunnelName)?.status || {
|
|
connected: false,
|
|
status: "DISCONNECTED",
|
|
}
|
|
);
|
|
}
|
|
|
|
function getAllC2STunnelStatuses() {
|
|
const statuses = {};
|
|
for (const [tunnelName] of c2sTunnelRuntimes.entries()) {
|
|
statuses[tunnelName] = getC2STunnelStatus(tunnelName);
|
|
}
|
|
return statuses;
|
|
}
|
|
|
|
function emitC2STunnelStatuses() {
|
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
mainWindow.webContents.send("c2s-tunnel-statuses", getAllC2STunnelStatuses());
|
|
}
|
|
|
|
function setC2STunnelStatus(tunnelName, status) {
|
|
const runtime = c2sTunnelRuntimes.get(tunnelName);
|
|
if (runtime) {
|
|
runtime.status = status;
|
|
emitC2STunnelStatuses();
|
|
}
|
|
}
|
|
|
|
function setC2STunnelError(tunnelName, message) {
|
|
logToFile(`[c2s] ${tunnelName} failed:`, message);
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: "ERROR",
|
|
reason: message,
|
|
});
|
|
}
|
|
|
|
function parseSocks5Target(buffer) {
|
|
if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) {
|
|
return null;
|
|
}
|
|
|
|
const addressType = buffer[3];
|
|
let offset = 4;
|
|
let host;
|
|
|
|
if (addressType === 0x01) {
|
|
if (buffer.length < offset + 4 + 2) return null;
|
|
host = Array.from(buffer.subarray(offset, offset + 4)).join(".");
|
|
offset += 4;
|
|
} else if (addressType === 0x03) {
|
|
const length = buffer[offset];
|
|
offset += 1;
|
|
if (buffer.length < offset + length + 2) return null;
|
|
host = buffer.subarray(offset, offset + length).toString("utf8");
|
|
offset += length;
|
|
} else if (addressType === 0x04) {
|
|
if (buffer.length < offset + 16 + 2) return null;
|
|
const parts = [];
|
|
for (let i = 0; i < 16; i += 2) {
|
|
parts.push(buffer.readUInt16BE(offset + i).toString(16));
|
|
}
|
|
host = parts.join(":");
|
|
offset += 16;
|
|
} else {
|
|
throw new Error("Unsupported SOCKS5 address type");
|
|
}
|
|
|
|
const port = buffer.readUInt16BE(offset);
|
|
return { host, port, bytesRead: offset + 2 };
|
|
}
|
|
|
|
async function openC2SRelay(
|
|
tunnel,
|
|
targetHost,
|
|
targetPort,
|
|
socket,
|
|
initialData,
|
|
authToken,
|
|
) {
|
|
const tunnelConfig = normalizeC2STunnelAddresses(tunnel);
|
|
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
|
|
const relayUrl = getC2SRelayUrl();
|
|
const headers = await getC2SRelayHeaders(relayUrl, authToken);
|
|
logToFile(`[c2s] opening relay for ${tunnelName}`, {
|
|
relayUrl,
|
|
targetHost,
|
|
targetPort,
|
|
});
|
|
const ws = new WebSocket(
|
|
relayUrl,
|
|
getWebSocketOptions(relayUrl, { headers }),
|
|
);
|
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
|
const pendingChunks = [];
|
|
let ready = false;
|
|
let closed = false;
|
|
|
|
const cleanup = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
try {
|
|
socket.destroy();
|
|
} catch {
|
|
// expected during shutdown
|
|
}
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// expected during shutdown
|
|
}
|
|
};
|
|
|
|
const sendChunk = (chunk) => {
|
|
if (ready && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(chunk);
|
|
} else {
|
|
pendingChunks.push(chunk);
|
|
}
|
|
};
|
|
|
|
socket.on("data", sendChunk);
|
|
socket.on("close", cleanup);
|
|
// One connection failing must not stop the tunnel: the listener refuses
|
|
// new connections while the tunnel is not connected, and a browser opens
|
|
// many at once.
|
|
socket.on("error", (error) => {
|
|
logToFile(`[c2s] ${tunnelName} local socket error:`, error.message);
|
|
cleanup();
|
|
});
|
|
ws.on("close", cleanup);
|
|
ws.on("error", (error) => {
|
|
logToFile(`[c2s] ${tunnelName} relay error:`, error.message);
|
|
cleanup();
|
|
});
|
|
|
|
ws.on("open", () => {
|
|
logToFile(`[c2s] relay connected for ${tunnelName}`);
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "open",
|
|
tunnelConfig,
|
|
targetHost,
|
|
targetPort,
|
|
}),
|
|
);
|
|
});
|
|
|
|
ws.on("message", (data, isBinary) => {
|
|
if (isBinary) {
|
|
socket.write(Buffer.isBuffer(data) ? data : Buffer.from(data));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const message = JSON.parse(data.toString());
|
|
if (message.type === "ready") {
|
|
ready = true;
|
|
logToFile(`[c2s] relay ready for ${tunnelName}`);
|
|
if (initialData?.length) {
|
|
ws.send(initialData);
|
|
}
|
|
while (pendingChunks.length > 0) {
|
|
ws.send(pendingChunks.shift());
|
|
}
|
|
} else if (message.type === "error") {
|
|
const relayError = normalizeC2SErrorMessage(
|
|
message.error,
|
|
"Relay rejected the client tunnel",
|
|
);
|
|
logToFile("[c2s] relay error:", relayError);
|
|
if (relayError === C2S_REMOTE_SESSION_EXPIRED_ERROR) {
|
|
setC2STunnelError(tunnelName, relayError);
|
|
}
|
|
cleanup();
|
|
}
|
|
} catch (error) {
|
|
logToFile("[c2s] invalid relay message:", error.message);
|
|
cleanup();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function testC2SRelay(
|
|
tunnel,
|
|
targetHost,
|
|
targetPort,
|
|
authToken,
|
|
runtime,
|
|
) {
|
|
const tunnelConfig = normalizeC2STunnelAddresses(tunnel);
|
|
const relayUrl = getC2SRelayUrl();
|
|
const headers = await getC2SRelayHeaders(relayUrl, authToken);
|
|
if (runtime && c2sTunnelRuntimes.get(tunnel.name) !== runtime) {
|
|
return { success: false, error: "Tunnel stopped" };
|
|
}
|
|
const ws = new WebSocket(
|
|
relayUrl,
|
|
getWebSocketOptions(relayUrl, { headers }),
|
|
);
|
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
|
|
|
if (runtime) runtime.authSocket = ws;
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const settle = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try {
|
|
if (!runtime || !result.success) ws.close();
|
|
} catch {
|
|
// expected during shutdown
|
|
}
|
|
resolve(result);
|
|
};
|
|
|
|
const timer = setTimeout(() => {
|
|
settle({ success: false, error: "Tunnel test timed out" });
|
|
}, 180_000);
|
|
|
|
ws.on("open", () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "test",
|
|
keepAlive: !!runtime,
|
|
tunnelConfig,
|
|
targetHost,
|
|
targetPort,
|
|
}),
|
|
);
|
|
});
|
|
ws.on("message", (data, isBinary) => {
|
|
if (isBinary) return;
|
|
|
|
try {
|
|
const message = JSON.parse(data.toString());
|
|
if (message.type === "ready") {
|
|
clearTimeout(timer);
|
|
settle({ success: true });
|
|
} else if (message.type === "error") {
|
|
clearTimeout(timer);
|
|
settle(createC2SFailure(message.error, "Tunnel test failed"));
|
|
}
|
|
} catch (error) {
|
|
clearTimeout(timer);
|
|
settle(createC2SFailure(error.message, "Tunnel test failed"));
|
|
}
|
|
});
|
|
ws.on("error", (error) => {
|
|
clearTimeout(timer);
|
|
settle(createC2SFailure(error.message, "Tunnel test failed"));
|
|
});
|
|
ws.on("close", () => {
|
|
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
|
|
setC2STunnelError(tunnel.name, "Endpoint SSH connection closed");
|
|
}
|
|
clearTimeout(timer);
|
|
settle({ success: false, error: "Tunnel test connection closed" });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function testC2STunnel(tunnel, index = 0, authToken) {
|
|
const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel);
|
|
const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local";
|
|
const testTunnel = {
|
|
...normalizeC2STunnelAddresses(resolvedTunnel),
|
|
name: `${getC2STunnelName(resolvedTunnel, index)}::test`,
|
|
mode,
|
|
};
|
|
const localAddress = getC2SLocalAddress(testTunnel);
|
|
const remoteAddress = getC2SRemoteAddress(testTunnel);
|
|
const sourcePort = Number(resolvedTunnel.sourcePort);
|
|
const endpointPort = Number(resolvedTunnel.endpointPort);
|
|
|
|
if (!resolvedTunnel.sourceHostId) {
|
|
return { success: false, error: "Endpoint SSH host is required" };
|
|
}
|
|
|
|
if (mode === "remote") {
|
|
const localTarget = await checkTcpConnection(localAddress, endpointPort);
|
|
if (!localTarget.success) {
|
|
return {
|
|
success: false,
|
|
error: `Local target ${localAddress}:${endpointPort} is not reachable: ${localTarget.error}`,
|
|
};
|
|
}
|
|
|
|
return testC2SRelay(testTunnel, undefined, undefined, authToken);
|
|
}
|
|
|
|
if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) {
|
|
return { success: false, error: "Invalid local port" };
|
|
}
|
|
|
|
const runtime = c2sTunnelRuntimes.get(
|
|
getC2STunnelName(resolvedTunnel, index),
|
|
);
|
|
if (!runtime) {
|
|
const availability = await checkLocalPortAvailable(
|
|
localAddress,
|
|
sourcePort,
|
|
);
|
|
if (!availability.available) {
|
|
return {
|
|
success: false,
|
|
error: `Local listener ${localAddress}:${sourcePort} is not available: ${availability.error}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (mode === "dynamic") {
|
|
return testC2SRelay(testTunnel, undefined, undefined, authToken);
|
|
}
|
|
|
|
if (!Number.isInteger(endpointPort) || endpointPort < 1) {
|
|
return { success: false, error: "Invalid remote port" };
|
|
}
|
|
|
|
return testC2SRelay(testTunnel, remoteAddress, endpointPort, authToken);
|
|
}
|
|
|
|
function handleC2SDynamicConnection(tunnel, socket, authToken) {
|
|
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
|
|
let buffer = Buffer.alloc(0);
|
|
let stage = "greeting";
|
|
|
|
const fail = (code = 0x01, message = "SOCKS5 request failed") => {
|
|
setC2STunnelError(tunnelName, message);
|
|
if (!socket.destroyed) {
|
|
socket.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
|
socket.destroy();
|
|
}
|
|
};
|
|
|
|
const onData = (chunk) => {
|
|
buffer = Buffer.concat([buffer, chunk]);
|
|
|
|
try {
|
|
if (stage === "greeting") {
|
|
if (buffer.length < 2) return;
|
|
if (buffer[0] !== 0x05) {
|
|
fail(0x01, "Invalid SOCKS5 greeting");
|
|
return;
|
|
}
|
|
const methodsLength = buffer[1];
|
|
if (buffer.length < 2 + methodsLength) return;
|
|
socket.write(Buffer.from([0x05, 0x00]));
|
|
buffer = buffer.subarray(2 + methodsLength);
|
|
stage = "connect";
|
|
}
|
|
|
|
if (stage === "connect") {
|
|
const target = parseSocks5Target(buffer);
|
|
if (!target) return;
|
|
|
|
stage = "piping";
|
|
socket.off("data", onData);
|
|
const remainder = buffer.subarray(target.bytesRead);
|
|
socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
|
openC2SRelay(
|
|
tunnel,
|
|
target.host,
|
|
target.port,
|
|
socket,
|
|
remainder,
|
|
authToken,
|
|
).catch((error) => {
|
|
const message = normalizeC2SErrorMessage(
|
|
error.message,
|
|
"Dynamic relay failed",
|
|
);
|
|
logToFile("[c2s] dynamic relay failed:", message);
|
|
fail(0x05, message);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logToFile("[c2s] SOCKS5 parse failed:", error.message);
|
|
fail(0x01, error.message || "SOCKS5 parse failed");
|
|
}
|
|
};
|
|
|
|
socket.on("data", onData);
|
|
socket.on("error", () => socket.destroy());
|
|
}
|
|
|
|
function handleC2SLocalConnection(tunnel, socket, authToken) {
|
|
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
|
|
const targetHost = getC2SRemoteAddress(tunnel);
|
|
const targetPort = Number(tunnel.endpointPort);
|
|
openC2SRelay(
|
|
tunnel,
|
|
targetHost,
|
|
targetPort,
|
|
socket,
|
|
undefined,
|
|
authToken,
|
|
).catch((error) => {
|
|
const message = normalizeC2SErrorMessage(
|
|
error.message,
|
|
"Local relay failed",
|
|
);
|
|
logToFile("[c2s] local relay failed:", message);
|
|
if (message === C2S_REMOTE_SESSION_EXPIRED_ERROR) {
|
|
setC2STunnelError(tunnelName, message);
|
|
}
|
|
socket.destroy();
|
|
});
|
|
}
|
|
|
|
function pauseSourceForC2SWebSocket(ws, source) {
|
|
if (!source?.pause || !source?.resume) return;
|
|
if (ws.bufferedAmount <= C2S_WS_HIGH_WATERMARK) return;
|
|
|
|
source.pause();
|
|
const resumeTimer = setInterval(() => {
|
|
if (
|
|
ws.readyState !== WebSocket.OPEN ||
|
|
source.destroyed ||
|
|
ws.bufferedAmount <= C2S_WS_LOW_WATERMARK
|
|
) {
|
|
clearInterval(resumeTimer);
|
|
if (ws.readyState === WebSocket.OPEN && !source.destroyed) {
|
|
source.resume();
|
|
}
|
|
}
|
|
}, 25);
|
|
}
|
|
|
|
function sendC2SRemoteMessage(ws, message, source) {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify(message), (error) => {
|
|
if (error && source?.destroy) {
|
|
source.destroy(error);
|
|
}
|
|
});
|
|
pauseSourceForC2SWebSocket(ws, source);
|
|
}
|
|
}
|
|
|
|
function writeC2SRemoteChunk(target, chunk, ws, closeTarget) {
|
|
if (!target || target.destroyed) return;
|
|
|
|
if (target.writableLength > C2S_STREAM_WRITE_LIMIT) {
|
|
closeTarget();
|
|
return;
|
|
}
|
|
|
|
const canContinue = target.write(chunk);
|
|
if (!canContinue && typeof ws.pause === "function") {
|
|
ws.pause();
|
|
target.once("drain", () => {
|
|
if (ws.readyState === WebSocket.OPEN && typeof ws.resume === "function") {
|
|
ws.resume();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async function startC2SRemoteTunnel(tunnel, index = 0, authToken) {
|
|
const tunnelName = getC2STunnelName(tunnel, index);
|
|
const tunnelConfig = normalizeC2STunnelAddresses(tunnel);
|
|
const localHost = getC2SLocalAddress(tunnelConfig);
|
|
const remoteHost = getC2SRemoteAddress(tunnelConfig);
|
|
const localPort = Number(tunnel.endpointPort);
|
|
const remotePort = Number(tunnel.sourcePort);
|
|
|
|
if (!tunnel.sourceHostId) {
|
|
return { success: false, error: "Endpoint SSH host is required" };
|
|
}
|
|
if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65535) {
|
|
return { success: false, error: "Invalid remote port" };
|
|
}
|
|
if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
|
|
return { success: false, error: "Invalid local port" };
|
|
}
|
|
|
|
const localTarget = await checkTcpConnection(localHost, localPort);
|
|
if (!localTarget.success) {
|
|
return {
|
|
success: false,
|
|
error: `Local target ${localHost}:${localPort} is not reachable: ${localTarget.error}`,
|
|
};
|
|
}
|
|
|
|
const existing = c2sTunnelRuntimes.get(tunnelName);
|
|
if (existing) {
|
|
return { success: true, tunnelName };
|
|
}
|
|
|
|
for (const runtime of c2sTunnelRuntimes.values()) {
|
|
if (
|
|
runtime.mode === "remote" &&
|
|
runtime.sourceHostId === Number(tunnel.sourceHostId) &&
|
|
runtime.remoteAddress === remoteHost &&
|
|
runtime.sourcePort === remotePort
|
|
) {
|
|
return {
|
|
success: false,
|
|
error: `Another client remote tunnel already uses ${remoteHost}:${remotePort} on this endpoint`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const relayUrl = getC2SRelayUrl();
|
|
const headers = await getC2SRelayHeaders(relayUrl, authToken);
|
|
const ws = new WebSocket(
|
|
relayUrl,
|
|
getWebSocketOptions(relayUrl, { headers }),
|
|
);
|
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
|
const sockets = new Map();
|
|
let closed = false;
|
|
|
|
const cleanup = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
for (const socket of sockets.values()) {
|
|
socket.destroy();
|
|
}
|
|
sockets.clear();
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// expected during shutdown
|
|
}
|
|
};
|
|
|
|
c2sTunnelRuntimes.set(tunnelName, {
|
|
ws,
|
|
sockets,
|
|
mode: "remote",
|
|
sourceHostId: Number(tunnel.sourceHostId),
|
|
sourcePort: remotePort,
|
|
bindHost: localHost,
|
|
remoteAddress: remoteHost,
|
|
status: { connected: false, status: "CONNECTING" },
|
|
close: cleanup,
|
|
});
|
|
emitC2STunnelStatuses();
|
|
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const settle = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(result);
|
|
};
|
|
|
|
ws.on("open", () => {
|
|
logToFile(`[c2s] opening remote tunnel ${tunnelName}`, {
|
|
relayUrl,
|
|
remoteHost,
|
|
remotePort,
|
|
localHost,
|
|
localPort,
|
|
});
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "open",
|
|
tunnelConfig: { ...tunnelConfig, name: tunnelName, mode: "remote" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
ws.on("message", (data, isBinary) => {
|
|
if (isBinary) return;
|
|
|
|
let message;
|
|
try {
|
|
message = JSON.parse(data.toString());
|
|
} catch (error) {
|
|
const message = normalizeC2SErrorMessage(
|
|
error.message,
|
|
"Invalid relay message",
|
|
);
|
|
setC2STunnelError(tunnelName, message);
|
|
cleanup();
|
|
settle(createC2SFailure(message, "Invalid relay message"));
|
|
return;
|
|
}
|
|
|
|
if (message.type === "ready") {
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: true,
|
|
status: "CONNECTED",
|
|
});
|
|
settle({ success: true, tunnelName });
|
|
return;
|
|
}
|
|
|
|
if (message.type === "error") {
|
|
const error = normalizeC2SErrorMessage(
|
|
message.error,
|
|
"Relay rejected the client tunnel",
|
|
);
|
|
setC2STunnelError(tunnelName, error);
|
|
cleanup();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
settle(createC2SFailure(error, "Relay rejected the client tunnel"));
|
|
return;
|
|
}
|
|
|
|
if (message.type === "connection" && message.streamId) {
|
|
const socket = net.createConnection(
|
|
{ host: localHost, port: localPort },
|
|
() => {
|
|
logToFile(`[c2s] remote stream ${message.streamId} connected`, {
|
|
tunnelName,
|
|
localHost,
|
|
localPort,
|
|
});
|
|
},
|
|
);
|
|
sockets.set(message.streamId, socket);
|
|
socket.on("data", (chunk) => {
|
|
sendC2SRemoteMessage(
|
|
ws,
|
|
{
|
|
type: "data",
|
|
streamId: message.streamId,
|
|
data: chunk.toString("base64"),
|
|
},
|
|
socket,
|
|
);
|
|
});
|
|
socket.on("close", () => {
|
|
sockets.delete(message.streamId);
|
|
sendC2SRemoteMessage(ws, {
|
|
type: "close",
|
|
streamId: message.streamId,
|
|
});
|
|
});
|
|
socket.on("error", (error) => {
|
|
logToFile(`[c2s] remote stream ${message.streamId} failed:`, {
|
|
tunnelName,
|
|
error: error.message,
|
|
});
|
|
sockets.delete(message.streamId);
|
|
sendC2SRemoteMessage(ws, {
|
|
type: "close",
|
|
streamId: message.streamId,
|
|
error: error.message,
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (message.type === "data" && message.streamId && message.data) {
|
|
const socket = sockets.get(message.streamId);
|
|
writeC2SRemoteChunk(
|
|
socket,
|
|
Buffer.from(message.data, "base64"),
|
|
ws,
|
|
() => {
|
|
if (socket) {
|
|
sockets.delete(message.streamId);
|
|
socket.destroy();
|
|
}
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (message.type === "close" && message.streamId) {
|
|
const socket = sockets.get(message.streamId);
|
|
if (socket) {
|
|
sockets.delete(message.streamId);
|
|
socket.destroy();
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on("close", () => {
|
|
cleanup();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
settle({ success: false, error: "Remote tunnel relay closed" });
|
|
});
|
|
|
|
ws.on("error", (error) => {
|
|
const message = normalizeC2SErrorMessage(
|
|
error.message,
|
|
"Relay connection failed",
|
|
);
|
|
setC2STunnelError(tunnelName, message);
|
|
cleanup();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
settle(createC2SFailure(message, "Relay connection failed"));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function startC2STunnel(tunnel, index = 0, authToken) {
|
|
const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel);
|
|
const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local";
|
|
const tunnelName = getC2STunnelName(resolvedTunnel, index);
|
|
const tunnelConfig = {
|
|
...normalizeC2STunnelAddresses(resolvedTunnel),
|
|
sessionId: crypto.randomUUID(),
|
|
};
|
|
const bindHost = getC2SLocalAddress(tunnelConfig);
|
|
const sourcePort = Number(resolvedTunnel.sourcePort);
|
|
logToFile(`[c2s] starting tunnel ${tunnelName}`, {
|
|
mode,
|
|
bindHost,
|
|
remoteAddress: getC2SRemoteAddress(tunnelConfig),
|
|
sourcePort,
|
|
sourceHostId: resolvedTunnel.sourceHostId,
|
|
sourceHostSyncId: resolvedTunnel.sourceHostSyncId,
|
|
endpointPort: resolvedTunnel.endpointPort,
|
|
});
|
|
|
|
if (mode === "remote") {
|
|
return startC2SRemoteTunnel(resolvedTunnel, index, authToken);
|
|
}
|
|
if (!resolvedTunnel.sourceHostId) {
|
|
return { success: false, error: "Endpoint SSH host is required" };
|
|
}
|
|
if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) {
|
|
return { success: false, error: "Invalid local port" };
|
|
}
|
|
|
|
const existing = c2sTunnelRuntimes.get(tunnelName);
|
|
if (existing) {
|
|
return { success: true, tunnelName };
|
|
}
|
|
|
|
for (const runtime of c2sTunnelRuntimes.values()) {
|
|
if (
|
|
runtime.mode !== "remote" &&
|
|
runtime.bindHost === bindHost &&
|
|
runtime.sourcePort === sourcePort
|
|
) {
|
|
return {
|
|
success: false,
|
|
error: `Another client tunnel already uses ${bindHost}:${sourcePort}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const availability = await checkLocalPortAvailable(bindHost, sourcePort);
|
|
if (!availability.available) {
|
|
return {
|
|
success: false,
|
|
error: availability.error || "Port is already in use",
|
|
};
|
|
}
|
|
|
|
const sockets = new Set();
|
|
const server = net.createServer((socket) => {
|
|
if (!c2sTunnelRuntimes.get(tunnelName)?.status.connected) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
sockets.add(socket);
|
|
socket.on("close", () => sockets.delete(socket));
|
|
if (mode === "dynamic") {
|
|
handleC2SDynamicConnection(
|
|
{ ...tunnelConfig, name: tunnelName, mode },
|
|
socket,
|
|
authToken,
|
|
);
|
|
} else {
|
|
handleC2SLocalConnection(
|
|
{ ...tunnelConfig, name: tunnelName, mode },
|
|
socket,
|
|
authToken,
|
|
);
|
|
}
|
|
});
|
|
|
|
c2sTunnelRuntimes.set(tunnelName, {
|
|
server,
|
|
sockets,
|
|
bindHost,
|
|
sourcePort,
|
|
status: { connected: false, status: "CONNECTING" },
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
server.once("error", (error) => {
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
logToFile(`[c2s] failed to listen for ${tunnelName}:`, error.message);
|
|
emitC2STunnelStatuses();
|
|
resolve({ success: false, error: error.message });
|
|
});
|
|
server.listen({ host: bindHost, port: sourcePort }, () => {
|
|
logToFile(
|
|
`[c2s] listening for ${tunnelName} on ${bindHost}:${sourcePort}`,
|
|
);
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: "CONNECTING",
|
|
reason: "Verifying endpoint SSH connection",
|
|
});
|
|
|
|
const runtime = c2sTunnelRuntimes.get(tunnelName);
|
|
const verifyTunnel = testC2SRelay(
|
|
{ ...tunnelConfig, name: tunnelName, mode },
|
|
mode === "dynamic" ? undefined : getC2SRemoteAddress(tunnelConfig),
|
|
mode === "dynamic" ? undefined : Number(tunnelConfig.endpointPort),
|
|
authToken,
|
|
runtime,
|
|
);
|
|
|
|
verifyTunnel
|
|
.then((result) => {
|
|
if (c2sTunnelRuntimes.get(tunnelName) !== runtime) return;
|
|
if (result.success) {
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: true,
|
|
status: "CONNECTED",
|
|
});
|
|
} else {
|
|
setC2STunnelError(
|
|
tunnelName,
|
|
result.error || "Endpoint SSH connection failed",
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
if (c2sTunnelRuntimes.get(tunnelName) === runtime) {
|
|
setC2STunnelError(
|
|
tunnelName,
|
|
error.message || "Endpoint SSH connection failed",
|
|
);
|
|
}
|
|
});
|
|
|
|
resolve({ success: true, tunnelName });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function stopC2STunnel(tunnelName) {
|
|
const runtime = c2sTunnelRuntimes.get(tunnelName);
|
|
if (!runtime) {
|
|
return { success: true };
|
|
}
|
|
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: "DISCONNECTING",
|
|
});
|
|
runtime.authSocket?.close();
|
|
|
|
return new Promise((resolve) => {
|
|
if (typeof runtime.close === "function") {
|
|
runtime.close();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
resolve({ success: true });
|
|
return;
|
|
}
|
|
|
|
for (const socket of runtime.sockets || []) {
|
|
socket.destroy();
|
|
}
|
|
runtime.server?.close(() => {
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
resolve({ success: true });
|
|
});
|
|
});
|
|
}
|
|
|
|
function stopAllC2STunnels() {
|
|
for (const [tunnelName, runtime] of c2sTunnelRuntimes.entries()) {
|
|
try {
|
|
runtime.authSocket?.close();
|
|
if (typeof runtime.close === "function") {
|
|
runtime.close();
|
|
} else {
|
|
for (const socket of runtime.sockets || []) {
|
|
socket.destroy();
|
|
}
|
|
runtime.server?.close();
|
|
}
|
|
} catch (error) {
|
|
logToFile(`[c2s] failed to stop tunnel ${tunnelName}:`, error.message);
|
|
}
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
}
|
|
emitC2STunnelStatuses();
|
|
}
|
|
|
|
async function startC2SAutoStartTunnels() {
|
|
const configPath = getC2STunnelConfigPath();
|
|
if (!fs.existsSync(configPath)) {
|
|
return { success: true, started: 0, errors: [] };
|
|
}
|
|
|
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
const tunnels = Array.isArray(config) ? config : [];
|
|
const errors = [];
|
|
let started = 0;
|
|
|
|
for (let index = 0; index < tunnels.length; index += 1) {
|
|
const tunnel = tunnels[index];
|
|
if (!tunnel?.autoStart) continue;
|
|
const result = await startC2STunnel(tunnel, index);
|
|
if (result.success) {
|
|
started += 1;
|
|
} else {
|
|
errors.push(result.error || "Failed to start client tunnel");
|
|
}
|
|
}
|
|
|
|
return { success: errors.length === 0, started, errors };
|
|
}
|
|
|
|
ipcMain.handle("check-local-port-available", async (_event, host, port) => {
|
|
const sourcePort = Number(port);
|
|
if (
|
|
!host ||
|
|
!Number.isInteger(sourcePort) ||
|
|
sourcePort < 1 ||
|
|
sourcePort > 65535
|
|
) {
|
|
return { available: false, error: "Invalid local bind address or port" };
|
|
}
|
|
return checkLocalPortAvailable(host, sourcePort);
|
|
});
|
|
|
|
ipcMain.handle("start-c2s-tunnel", async (_event, tunnel, index, authToken) => {
|
|
try {
|
|
return await startC2STunnel(
|
|
tunnel,
|
|
Number(index) || 0,
|
|
normalizeC2SAuthToken(authToken),
|
|
);
|
|
} catch (error) {
|
|
return createC2SFailure(error.message, "Failed to start client tunnel");
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("test-c2s-tunnel", async (_event, tunnel, index, authToken) => {
|
|
try {
|
|
return await testC2STunnel(
|
|
tunnel,
|
|
Number(index) || 0,
|
|
normalizeC2SAuthToken(authToken),
|
|
);
|
|
} catch (error) {
|
|
return createC2SFailure(error.message, "Failed to test client tunnel");
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("stop-c2s-tunnel", async (_event, tunnelName) => {
|
|
try {
|
|
return await stopC2STunnel(tunnelName);
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("get-c2s-tunnel-statuses", () => {
|
|
return getAllC2STunnelStatuses();
|
|
});
|
|
|
|
ipcMain.handle("start-c2s-autostart-tunnels", async () => {
|
|
try {
|
|
return await startC2SAutoStartTunnels();
|
|
} catch (error) {
|
|
return { success: false, started: 0, errors: [error.message] };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("get-c2s-tunnel-preset-default-name", () => {
|
|
const now = new Date();
|
|
const date = now.toISOString().slice(0, 10);
|
|
const platform =
|
|
process.platform === "darwin"
|
|
? "macOS"
|
|
: process.platform === "win32"
|
|
? "Windows"
|
|
: "Linux";
|
|
const release = os.release();
|
|
const computerName = os.hostname();
|
|
return `[${date}] ${computerName} (${platform} ${release})`;
|
|
});
|
|
|
|
ipcMain.handle("get-setting", (event, key) => {
|
|
try {
|
|
const userDataPath = app.getPath("userData");
|
|
const settingsPath = path.join(userDataPath, "settings.json");
|
|
|
|
if (!fs.existsSync(settingsPath)) {
|
|
return null;
|
|
}
|
|
|
|
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
|
const settings = JSON.parse(settingsData);
|
|
return settings[key] !== undefined ? settings[key] : null;
|
|
} catch (error) {
|
|
console.error("Error reading setting:", error);
|
|
return null;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("set-setting", (event, key, value) => {
|
|
try {
|
|
const userDataPath = app.getPath("userData");
|
|
const settingsPath = path.join(userDataPath, "settings.json");
|
|
|
|
if (!fs.existsSync(userDataPath)) {
|
|
fs.mkdirSync(userDataPath, { recursive: true });
|
|
}
|
|
|
|
let settings = {};
|
|
if (fs.existsSync(settingsPath)) {
|
|
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
|
settings = JSON.parse(settingsData);
|
|
}
|
|
|
|
settings[key] = value;
|
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Error saving setting:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("get-session-cookie", async (_event, name, targetUrl) => {
|
|
try {
|
|
const ses = mainWindow?.webContents?.session;
|
|
if (!ses) return null;
|
|
const cookies = await ses.cookies.get({
|
|
name,
|
|
...(targetUrl ? { url: targetUrl } : {}),
|
|
});
|
|
const cookie = cookies.find((candidate) =>
|
|
cookieMatchesUrl(candidate, targetUrl),
|
|
);
|
|
return (
|
|
cookie?.value ||
|
|
getRememberedElectronAuthCookie(name, targetUrl)?.value ||
|
|
null
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to get session cookie:", error);
|
|
return getRememberedElectronAuthCookie(name, targetUrl)?.value || null;
|
|
}
|
|
});
|
|
|
|
function cookieMatchesUrl(cookie, targetUrl) {
|
|
if (!targetUrl) return true;
|
|
|
|
try {
|
|
const targetHost = new URL(targetUrl).hostname;
|
|
const cookieDomain = (cookie.domain || "").replace(/^\./, "");
|
|
|
|
return (
|
|
cookieDomain === targetHost ||
|
|
targetHost.endsWith(`.${cookieDomain}`) ||
|
|
(!cookieDomain && targetHost === "localhost")
|
|
);
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
ipcMain.handle(
|
|
"wait-session-cookie",
|
|
async (_event, name, targetUrl, previousValue, timeoutMs = 5000) => {
|
|
const ses = mainWindow?.webContents?.session;
|
|
if (!ses) return { success: false, error: "No Electron session" };
|
|
|
|
const existingCookies = await ses.cookies.get({
|
|
name,
|
|
...(targetUrl ? { url: targetUrl } : {}),
|
|
});
|
|
const existingCookie = existingCookies.find((cookie) =>
|
|
cookieMatchesUrl(cookie, targetUrl),
|
|
);
|
|
if (existingCookie?.value && existingCookie.value !== previousValue) {
|
|
return { success: true, value: existingCookie.value };
|
|
}
|
|
|
|
const rememberedCookie = getRememberedElectronAuthCookie(name, targetUrl);
|
|
if (rememberedCookie?.value && rememberedCookie.value !== previousValue) {
|
|
return { success: true, value: rememberedCookie.value };
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
ses.cookies.off("changed", onCookieChanged);
|
|
resolve({ success: false, error: "Timed out waiting for cookie" });
|
|
}, timeoutMs);
|
|
|
|
function onCookieChanged(_event, cookie, _cause, removed) {
|
|
if (
|
|
removed ||
|
|
cookie.name !== name ||
|
|
!cookie.value ||
|
|
cookie.value === previousValue ||
|
|
!cookieMatchesUrl(cookie, targetUrl)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
clearTimeout(timeout);
|
|
ses.cookies.off("changed", onCookieChanged);
|
|
resolve({ success: true, value: cookie.value });
|
|
}
|
|
|
|
ses.cookies.on("changed", onCookieChanged);
|
|
});
|
|
},
|
|
);
|
|
|
|
ipcMain.handle("clear-session-cookies", async () => {
|
|
try {
|
|
clearPersistedElectronAuthCookies();
|
|
const ses = mainWindow?.webContents?.session;
|
|
if (ses) {
|
|
const cookies = await ses.cookies.get({});
|
|
for (const cookie of cookies) {
|
|
await ses.cookies.remove(getCookieRemovalUrl(cookie), cookie.name);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to clear session cookies:", error);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("clipboard-write-text", (_event, text) => {
|
|
clipboard.writeText(typeof text === "string" ? text : String(text ?? ""));
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("clipboard-read-text", () => clipboard.readText());
|
|
|
|
ipcMain.handle("local-terminal-start", (event, dimensions = {}) => {
|
|
const cols = Math.min(500, Math.max(2, Number(dimensions.cols) || 80));
|
|
const rows = Math.min(300, Math.max(1, Number(dimensions.rows) || 24));
|
|
const sessionId = crypto.randomUUID();
|
|
const shellConfig = resolveLocalShell(process.platform, dimensions.shell);
|
|
const spawnShell = () =>
|
|
pty.spawn(shellConfig.file, shellConfig.args, {
|
|
name: "xterm-256color",
|
|
cols,
|
|
rows,
|
|
cwd: os.homedir(),
|
|
env: {
|
|
...process.env,
|
|
TERM: "xterm-256color",
|
|
COLORTERM: "truecolor",
|
|
},
|
|
});
|
|
let child;
|
|
try {
|
|
child = spawnShell();
|
|
} catch (error) {
|
|
const ptyDir = path.dirname(require.resolve("node-pty/package.json"));
|
|
if (
|
|
!String(error?.message).includes("posix_spawnp") ||
|
|
repairSpawnHelpers(ptyDir) === 0
|
|
) {
|
|
throw error;
|
|
}
|
|
logToFile("[local-terminal] restored the spawn-helper execute bit");
|
|
child = spawnShell();
|
|
}
|
|
const ownerId = event.sender.id;
|
|
const session = { ownerId, process: child, ready: false, buffered: "" };
|
|
localTerminalSessions.set(sessionId, session);
|
|
child.onData((data) => {
|
|
if (!session.ready) {
|
|
session.buffered = (session.buffered + data).slice(-1024 * 1024);
|
|
return;
|
|
}
|
|
if (!event.sender.isDestroyed()) {
|
|
event.sender.send(`local-terminal:data:${sessionId}`, data);
|
|
}
|
|
});
|
|
child.onExit(({ exitCode }) => {
|
|
localTerminalSessions.delete(sessionId);
|
|
if (!event.sender.isDestroyed()) {
|
|
event.sender.send(`local-terminal:exit:${sessionId}`, exitCode);
|
|
}
|
|
});
|
|
event.sender.once("destroyed", () => closeLocalTerminalsFor(ownerId));
|
|
return { sessionId, shell: shellConfig.file };
|
|
});
|
|
|
|
ipcMain.handle("local-terminal-ready", (event, sessionId) => {
|
|
const session = ownedLocalTerminal(event, sessionId);
|
|
if (!session) return false;
|
|
session.ready = true;
|
|
if (session.buffered && !event.sender.isDestroyed()) {
|
|
event.sender.send(`local-terminal:data:${sessionId}`, session.buffered);
|
|
session.buffered = "";
|
|
}
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("local-terminal-write", (event, sessionId, data) => {
|
|
const session = ownedLocalTerminal(event, sessionId);
|
|
if (!session || typeof data !== "string" || data.length > 64 * 1024) {
|
|
return false;
|
|
}
|
|
session.process.write(data);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("local-terminal-resize", (event, sessionId, cols, rows) => {
|
|
const session = ownedLocalTerminal(event, sessionId);
|
|
const width = Number(cols);
|
|
const height = Number(rows);
|
|
if (
|
|
!session ||
|
|
!Number.isInteger(width) ||
|
|
!Number.isInteger(height) ||
|
|
width < 2 ||
|
|
width > 500 ||
|
|
height < 1 ||
|
|
height > 300
|
|
) {
|
|
return false;
|
|
}
|
|
session.process.resize(width, height);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("local-terminal-close", (event, sessionId) => {
|
|
const session = ownedLocalTerminal(event, sessionId);
|
|
if (!session) return false;
|
|
localTerminalSessions.delete(sessionId);
|
|
session.process.kill();
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("show-save-dialog", async (_event, options) => {
|
|
return dialog.showSaveDialog(mainWindow, options || {});
|
|
});
|
|
|
|
ipcMain.handle("show-open-dialog", async (_event, options) => {
|
|
return dialog.showOpenDialog(mainWindow, options || {});
|
|
});
|
|
|
|
function toLocalFileType(stat) {
|
|
if (stat.isDirectory()) return "directory";
|
|
if (stat.isFile()) return "file";
|
|
if (stat.isSymbolicLink()) return "link";
|
|
return "other";
|
|
}
|
|
|
|
function getLocalEntry(entryPath, name = path.basename(entryPath)) {
|
|
const stat = fs.lstatSync(entryPath);
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
type: toLocalFileType(stat),
|
|
size: stat.isFile() ? stat.size : 0,
|
|
created: stat.birthtime.toISOString(),
|
|
modified: stat.mtime.toISOString(),
|
|
modifiedTimestamp: stat.mtimeMs,
|
|
hidden: name.startsWith("."),
|
|
permissions: (stat.mode & 0o777).toString(8).padStart(3, "0"),
|
|
owner: String(stat.uid),
|
|
group: String(stat.gid),
|
|
};
|
|
}
|
|
|
|
function normalizeRelativeLocalPath(relativePath) {
|
|
return relativePath.split(path.sep).join("/");
|
|
}
|
|
|
|
function validateLocalName(name) {
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
throw new Error("Missing name");
|
|
}
|
|
const trimmed = name.trim();
|
|
if (
|
|
trimmed.includes("/") ||
|
|
trimmed.includes("\\") ||
|
|
trimmed === "." ||
|
|
trimmed === ".."
|
|
) {
|
|
throw new Error("Name cannot contain path separators");
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function ensureLocalDirectory(dirPath) {
|
|
if (typeof dirPath !== "string" || !dirPath) {
|
|
throw new Error("Missing directory path");
|
|
}
|
|
const stat = fs.statSync(dirPath);
|
|
if (!stat.isDirectory()) {
|
|
throw new Error("Path is not a directory");
|
|
}
|
|
}
|
|
|
|
function collectLocalFilesFromPath(rootPath, rootName, files, limit) {
|
|
if (files.length >= limit) return;
|
|
|
|
const stat = fs.lstatSync(rootPath);
|
|
if (stat.isSymbolicLink()) return;
|
|
|
|
if (stat.isFile()) {
|
|
files.push({
|
|
path: rootPath,
|
|
name: path.basename(rootPath),
|
|
relativePath: normalizeRelativeLocalPath(rootName),
|
|
size: stat.size,
|
|
created: stat.birthtime.toISOString(),
|
|
modified: stat.mtime.toISOString(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!stat.isDirectory()) return;
|
|
|
|
for (const childName of fs.readdirSync(rootPath)) {
|
|
if (files.length >= limit) return;
|
|
const childPath = path.join(rootPath, childName);
|
|
collectLocalFilesFromPath(
|
|
childPath,
|
|
path.join(rootName, childName),
|
|
files,
|
|
limit,
|
|
);
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("get-local-home-directory", () => os.homedir());
|
|
|
|
ipcMain.handle("list-local-directory", (_event, dirPath) => {
|
|
try {
|
|
const requestedPath =
|
|
typeof dirPath === "string" && dirPath.trim() ? dirPath : os.homedir();
|
|
const stat = fs.statSync(requestedPath);
|
|
if (!stat.isDirectory()) {
|
|
return {
|
|
success: false,
|
|
path: requestedPath,
|
|
entries: [],
|
|
error: "Path is not a directory",
|
|
};
|
|
}
|
|
|
|
const entries = fs
|
|
.readdirSync(requestedPath, { withFileTypes: true })
|
|
.map((entry) => {
|
|
try {
|
|
return getLocalEntry(
|
|
path.join(requestedPath, entry.name),
|
|
entry.name,
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => {
|
|
if (a.type === "directory" && b.type !== "directory") return -1;
|
|
if (a.type !== "directory" && b.type === "directory") return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
path: requestedPath,
|
|
parent: path.dirname(requestedPath),
|
|
entries,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
path: typeof dirPath === "string" ? dirPath : "",
|
|
entries: [],
|
|
error: error instanceof Error ? error.message : "Failed to list path",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("stat-local-paths", (_event, paths) => {
|
|
const pathList = Array.isArray(paths) ? paths : [];
|
|
return pathList.map((entryPath) => {
|
|
try {
|
|
return { success: true, ...getLocalEntry(entryPath) };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
path: entryPath,
|
|
error: error instanceof Error ? error.message : "Failed to stat path",
|
|
};
|
|
}
|
|
});
|
|
});
|
|
|
|
ipcMain.handle("collect-local-files", (_event, paths) => {
|
|
try {
|
|
const pathList = Array.isArray(paths) ? paths : [];
|
|
const files = [];
|
|
const limit = 10000;
|
|
|
|
for (const entryPath of pathList) {
|
|
if (typeof entryPath !== "string" || !entryPath) continue;
|
|
collectLocalFilesFromPath(
|
|
entryPath,
|
|
path.basename(entryPath),
|
|
files,
|
|
limit + 1,
|
|
);
|
|
if (files.length > limit) break;
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
files: files.slice(0, limit),
|
|
truncated: files.length > limit,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
files: [],
|
|
error: error instanceof Error ? error.message : "Failed to collect files",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("create-local-folder", (_event, parentPath, folderName) => {
|
|
try {
|
|
ensureLocalDirectory(parentPath);
|
|
const safeName = validateLocalName(folderName);
|
|
const targetPath = path.join(parentPath, safeName);
|
|
fs.mkdirSync(targetPath);
|
|
return { success: true, ...getLocalEntry(targetPath, safeName) };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to create folder",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("rename-local-path", (_event, entryPath, newName) => {
|
|
try {
|
|
if (typeof entryPath !== "string" || !entryPath) {
|
|
throw new Error("Missing path");
|
|
}
|
|
const safeName = validateLocalName(newName);
|
|
const targetPath = path.join(path.dirname(entryPath), safeName);
|
|
renameLocalPath(entryPath, targetPath);
|
|
return { success: true, ...getLocalEntry(targetPath, safeName) };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to rename path",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("trash-local-path", async (_event, entryPath) => {
|
|
try {
|
|
if (typeof entryPath !== "string" || !entryPath) {
|
|
throw new Error("Missing path");
|
|
}
|
|
await shell.trashItem(entryPath);
|
|
return { success: true };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to delete path",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("chmod-local-path", (_event, entryPath, permissions) => {
|
|
try {
|
|
if (typeof entryPath !== "string" || !entryPath) {
|
|
throw new Error("Missing path");
|
|
}
|
|
if (typeof permissions !== "string" || !/^[0-7]{3,4}$/.test(permissions)) {
|
|
throw new Error("Permissions must be an octal value");
|
|
}
|
|
fs.chmodSync(entryPath, parseInt(permissions, 8));
|
|
return { success: true, ...getLocalEntry(entryPath) };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to update permissions",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("create-temp-file", async (_event, fileData) => {
|
|
try {
|
|
const result = createManagedTempFile(
|
|
fileData?.fileName,
|
|
fileData?.content,
|
|
fileData?.encoding,
|
|
);
|
|
return { success: true, ...result };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("create-temp-folder", async (_event, folderData) => {
|
|
try {
|
|
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`));
|
|
const folderPath = path.join(
|
|
tempDir,
|
|
sanitizeFileName(folderData?.folderName || "files"),
|
|
);
|
|
fs.mkdirSync(folderPath, { recursive: true });
|
|
|
|
for (const file of folderData?.files || []) {
|
|
const relativePath = String(file.relativePath || "")
|
|
.split(/[\\/]+/)
|
|
.map(sanitizeFileName)
|
|
.filter(Boolean)
|
|
.join(path.sep);
|
|
if (!relativePath) continue;
|
|
const targetPath = path.join(folderPath, relativePath);
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
targetPath,
|
|
decodeFileContent(file.content, file.encoding),
|
|
);
|
|
}
|
|
|
|
tempFiles.set(tempId, { path: folderPath, dir: tempDir });
|
|
return { success: true, tempId, path: folderPath };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("start-drag-to-desktop", (event, dragData) => {
|
|
try {
|
|
const temp = tempFiles.get(dragData?.tempId);
|
|
if (!temp) return { success: false, error: "Temporary file not found" };
|
|
|
|
event.sender.startDrag({
|
|
file: temp.path,
|
|
icon: nativeImage.createEmpty(),
|
|
});
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("cleanup-temp-file", (_event, tempId) => {
|
|
try {
|
|
cleanupManagedTempFile(tempId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
|
try {
|
|
const result = createManagedTempFile(
|
|
fileData?.fileName,
|
|
fileData?.content,
|
|
fileData?.encoding,
|
|
);
|
|
const editId = result.tempId;
|
|
const stat = fs.statSync(result.path);
|
|
const watcher = fs.watch(result.path, { persistent: false }, () => {
|
|
const session = externalEditorSessions.get(editId);
|
|
if (!session) return;
|
|
if (session.timer) clearTimeout(session.timer);
|
|
session.timer = setTimeout(() => notifyExternalEditorSaved(editId), 500);
|
|
});
|
|
|
|
externalEditorSessions.set(editId, {
|
|
path: result.path,
|
|
watcher,
|
|
timer: null,
|
|
lastMtimeMs: stat.mtimeMs,
|
|
});
|
|
|
|
const editorPath =
|
|
typeof fileData?.editorPath === "string" && fileData.editorPath.trim()
|
|
? fileData.editorPath.trim()
|
|
: null;
|
|
const openError = await openPathWithEditor(result.path, editorPath);
|
|
if (openError) {
|
|
closeExternalEditorSession(editId);
|
|
return { success: false, error: openError };
|
|
}
|
|
|
|
return { success: true, editId, path: result.path };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("close-external-editor", (_event, editId) => {
|
|
try {
|
|
closeExternalEditorSession(editId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
// Local disk browsing + streamed local<->remote transfers for the file
|
|
// manager's dual-pane mode (see electron/local-files.cjs).
|
|
registerLocalFileHandlers({ ipcMain, shell });
|
|
|
|
function createMenu() {
|
|
if (process.platform === "darwin") {
|
|
const template = [
|
|
{
|
|
label: app.name,
|
|
submenu: [
|
|
{ role: "about" },
|
|
{ type: "separator" },
|
|
{ role: "services" },
|
|
{ type: "separator" },
|
|
{ role: "hide" },
|
|
{ role: "hideOthers" },
|
|
{ role: "unhide" },
|
|
{ type: "separator" },
|
|
{ role: "quit" },
|
|
],
|
|
},
|
|
{
|
|
label: "Edit",
|
|
submenu: [
|
|
{ role: "undo" },
|
|
{ role: "redo" },
|
|
{ type: "separator" },
|
|
{ role: "cut" },
|
|
{ role: "copy" },
|
|
{ role: "paste" },
|
|
{ role: "selectAll" },
|
|
],
|
|
},
|
|
{
|
|
label: "View",
|
|
submenu: [
|
|
{ role: "reload" },
|
|
{ role: "forceReload" },
|
|
{ role: "toggleDevTools" },
|
|
{ type: "separator" },
|
|
{ role: "resetZoom" },
|
|
{ role: "zoomIn" },
|
|
{ role: "zoomOut" },
|
|
{ type: "separator" },
|
|
{ role: "togglefullscreen" },
|
|
],
|
|
},
|
|
{
|
|
label: "Window",
|
|
submenu: [
|
|
{ role: "minimize" },
|
|
{ role: "zoom" },
|
|
{ type: "separator" },
|
|
{ role: "front" },
|
|
{ type: "separator" },
|
|
{ role: "window" },
|
|
],
|
|
},
|
|
];
|
|
const menu = Menu.buildFromTemplate(template);
|
|
Menu.setApplicationMenu(menu);
|
|
}
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
logToFile("=== App ready ===");
|
|
logToFile(
|
|
"isDev:",
|
|
isDev,
|
|
"platform:",
|
|
process.platform,
|
|
"arch:",
|
|
process.arch,
|
|
);
|
|
if (process.platform === "win32") {
|
|
app.setAppUserModelId(windowsAppUserModelId);
|
|
}
|
|
createMenu();
|
|
await clearElectronClientCacheIfBuildChanged();
|
|
await clearElectronJwtCookiesAtStartup();
|
|
|
|
if (!isDev) {
|
|
const result = await startBackendServer();
|
|
logToFile("startBackendServer result:", result);
|
|
} else {
|
|
logToFile(
|
|
"Skipping embedded backend (isDev=true) - expecting the dev runner's backend",
|
|
);
|
|
if (isDevRunner()) {
|
|
process.on("message", (msg) => void handleBackendRequest(msg));
|
|
}
|
|
}
|
|
|
|
createTray();
|
|
createWindow();
|
|
desktopSettings.removeOldSyncFiles();
|
|
logToFile("=== Startup complete ===");
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
if (!tray || tray.isDestroyed()) {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
app.on("activate", () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createWindow();
|
|
} else if (mainWindow) {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
});
|
|
|
|
app.on("before-quit", () => {
|
|
isQuitting = true;
|
|
});
|
|
|
|
app.on("will-quit", () => {
|
|
console.log("App will quit...");
|
|
for (const editId of externalEditorSessions.keys()) {
|
|
closeExternalEditorSession(editId);
|
|
}
|
|
for (const tempId of tempFiles.keys()) {
|
|
cleanupManagedTempFile(tempId);
|
|
}
|
|
stopAllC2STunnels();
|
|
stopBackendServer();
|
|
});
|
|
|
|
process.on("uncaughtException", (error) => {
|
|
console.error("Uncaught Exception:", error);
|
|
});
|
|
|
|
process.on("unhandledRejection", (reason, promise) => {
|
|
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
|
});
|