mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
* fix: keep concurrent ssh pool connects from overwriting each other (#1619) * fix: stop auto-accepting changed jump host keys (#1620) A jump hop whose key no longer matched the pinned fingerprint was silently accepted and the pin overwritten. Background status probes reconnect to jump hosts every cycle, so a MITM on that hop got the credentials with no user interaction. Changed jump host keys now go through the same path as any other host: prompt when there is a terminal socket, otherwise refuse. * fix(electron): validate external login URL scheme and callback port (#1621) * fix(plugins): read artifact manifest with the same tar parser as the unpack (#1622) The consent check read the first manifest.json with a hand-written tar reader, while the unpack keeps the last one and also follows pax and long-name headers. An archive with two manifests could pass the check with one capability list and get granted another. Read the manifest with node-tar, count only plain files, and refuse archives that hold more than one manifest.json. * fix: only trust the server's origin in the desktop login frame (#1624) The desktop sign-in iframe accepted AUTH_SUCCESS and system browser requests from whatever page it had navigated to, and posted replies (including 2FA temp tokens) to any origin. Messages must now come from the server's origin and replies are posted to it. The web UI also treated any framing page as the desktop app and posted the JWT to it. It now also requires the Termix-Desktop user agent. * fix: check trusted proxy login against nginx's peer, not nginx (#1625) The backend only sees the bundled nginx on loopback, so the allowlist could only work by trusting 127.0.0.1, which let any client that reached nginx log in as anyone by sending the identity headers. nginx now passes its own TCP peer (before real_ip rewriting) in X-Termix-Proxy-Peer, and loopback connections are judged by that address instead. * fix: keep foreign keys on during user data import (#1626) Import credentials before hosts and remap the file's credential ids to the ones created here, so hosts and protocol logins no longer point at another server's ids. With rows written in dependency order, the import no longer turns off SQLite foreign keys on the shared connection. The uploaded file is now removed on every exit path. * fix: enforce unique usernames (#1628) * fix: enforce unique usernames Registration checked for a taken name, hashed the password, then inserted, with nothing in the schema enforcing uniqueness. Concurrent sign-ups could create two accounts with one name, after which login, password reset, proxy login and admin lookups by username picked between them arbitrarily. Add a unique index on users.username for every dialect. Existing duplicates are renamed (later rows get a -<id> suffix, the oldest keeps the name) rather than deleted. Registration and admin user creation now return the same 409 when the insert loses the race. * fix: avoid username collisions during migration * fix: don't auto-enable synced plugins signed only by a server-sent key (#1623) * fix: don't auto-enable synced plugins signed only by a server-sent key Registry keys a linked server sends still let its plugins load on the desktop, but no longer count as a signer for switching them on or copying their grants. Keys the desktop added itself keep their trust. * fix: enforce local plugin trust across remote source labels * fix: require positive local trust for synced plugin keys * fix(desktop): harden external editor and session cookie IPC (#1627) * fix(desktop): stop the renderer choosing the external editor binary or reading the session cookie open-external-editor now launches only the editor saved from a file dialog and refuses to hand executable file types to the OS opener. The unused get-session-cookie and wait-session-cookie channels, which returned the httpOnly jwt, are removed. * fix: validate setting keys before editor authorization * fix(sidebar): sort hosts by IP address numerically (#1629) * fix(desktop): find spawn-helper in universal macOS builds (#1630) The universal build ships app-arm64.asar and app-x64.asar instead of app.asar, so node-pty's helper path rewrite never pointed at the unpacked copy and every local terminal failed with posix_spawnp failed. * fix(sync): send shared hosts with text columns in their stored form (#1631) resolveHostById turns forceKeyboardInteractive into a boolean, and the shared-host export passed it on as is. A desktop could not bind the boolean into the text column, so every shared host failed to apply. * fix: drop ssh_data.vault_profile_id so removing the vault plugin keeps hosts writable (#1633) The vault plugin renames vault_profiles to p_vault_profiles, and SQLite rewrites ssh_data's foreign key to follow it. Uninstalling the plugin then dropped p_vault_profiles and left ssh_data pointing at a missing table, so creating, cloning and deleting hosts failed with "no such table: main.p_vault_profiles". Postgres and MySQL refused the uninstall instead. The column was already copied into the vault plugin's host settings by 2.9, so retire it on every engine. The SQLite boot drop also repairs databases already in the broken state; MySQL drops the column's foreign key first. * fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632) * fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own * fix(sync): switch off an untrusted mirrored plugin when the server updates it * fix: require local consent for mirrored plugin activation * docs: describe consent fields on the plugin state endpoint * fix(i18n): keep Indonesian technical terms consistent (#1636) Use the English terms Indonesian users know for tunnel, snippet, deploy, file manager, remote desktop, jump host, passphrase, sidebar, toolbar, endpoint, browser and SSH agent, instead of mixing literal translations (terowongan, cuplikan, terapkan, bilah sisi, titik akhir, ...) with the English words. Match the split shortcuts to the split screen menu. Also fix the user data export description, which said secrets are encrypted while the English says they are decrypted. * fix(settings): leave user manage view when switching admin sections (#1649) The admin panel stays mounted across Settings pages, so an open user manage view kept replacing every other admin section until Back to users was clicked. Reset it whenever the selected section changes. * fix(sync): don't delete a local row when the server can't send its copy (#1650) * fix(sync): don't delete a local row when the server can't send its copy A push conflict on a live record whose server row failed to serialize came back as row: null, deleted: false, and the desktop treated that as a delete. The server now rejects that op as unavailable, the desktop never deletes on a conflict without a row, and serialization failures are logged. * fix(sync): retry temporarily unavailable records * fix: smooth host editor jumps to lower sections * chore: add changelog entries for 26.10.2 fixes * fix: stop building ssh2 native crypto in the docker image (#1644) * chore: test that an update keeps a changed auth type (#1601) * feat: option to hide the sidebar after opening a host (#1652) * feat: sort the dashboard host status card (Plugin-Host-Metrics#6) * fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2) * fix: say the server is down and keep retrying instead of a database error (#1637) * fix: show why a client tunnel failed to start (Plugin-Tunnels#2) * fix(sdk): skip comment-only chunks when splitting migrations * feat: setting to let the hover side menu push content again * fix: use the vite proxy for plugin sockets on any dev port * feat(sdk): let a plugin raise its public route rate limit * chore: update translations * chore: add the overview video to the readme * chore: update the overview video and add a play button to its thumbnail * feat: plugin builds bundle worker entries and import wasm and fonts as bytes * feat: plugin packs include LICENSE and NOTICE * chore: drop guacd from compose files and dev runner * chore: update remote desktop origin comment for the built-in engine * fix: show plugin names and descriptions in the UI language (#1658) * fix: show plugin names and descriptions in the UI language Read plugin.name and plugin.description from each plugin's own locale namespace, falling back to the manifest, everywhere core shows a plugin's name: the Plugins manager, onboarding picker, host editor tabs and sections, host filters and bulk actions, beta updates, interface presets and plugin crash/loading notices. * docs(sdk): document plugin.name and plugin.description locale keys --------- Co-authored-by: ZacharyZcR <zkl205305@gmail.com> * fix(hosts): reject or infer a missing authType on host create (#1664) Creating an SSH host through the API without authType hit the NOT NULL auth_type column and came back as a 500. The create route now infers the type from the credential, key or password in the body, falls back to the host defaults, and otherwise answers 400. A non-string authType is a 400 on create and update. * chore: changelog for merged plugin name and host auth type PRs * fix: move macOS split pane navigation to Cmd+Option+Arrow * fix: pick a free port for the desktop backend when 30001 is taken (#1663) * feat: build the Windows desktop app for ARM64 (#1615) * fix(sync): keep the changes cursor within the 32-bit seq column (#1665) The desktop learned the server's entity types by asking for changes after Number.MAX_SAFE_INTEGER. sync_records.seq is a 32-bit integer on Postgres, which rejects that bound value, so every pass needing the probe failed with 22003 out of range. The server now clamps the cursor to the largest seq the column holds and answers 400 for a cursor or limit that is not a non-negative integer. The desktop asks for a one-row page from its saved cursor instead. * fix(audit): coalesce identical plugin routine audits so they cannot flush the log (#1667) Successful plugin_as_user and plugin_service_* entries written by a plugin with the same user, action, resource and details are recorded once per 10 minutes. Failures and every other action are still written each time. * feat(plugins): publish host.created when a host is added (#1668) Plugins that keep an inventory of hosts had no way to hear about a new one short of polling. Core now publishes host.created with the same { hostId, userId } payload as host.updated wherever a host row is created: the host create route, ctx.hosts.create, JSON and SSH config bulk import, database file import, and sync writing a host it did not have yet. * fix: show the login for signed-out full-screen view links and return to them (#1669) A ?view= link opened without a session spun forever: the gate rendered the bare view when the session check failed, so the plugin runtime never started and the view never registered. A regular browser now keeps the link in sessionStorage, shows the normal login and opens the link again after any login, including redirect logins. Only same-origin paths with a view are restored. The desktop app, embedded sign-in frames and guest views keep their current behavior. * fix(desktop): apply linked server proxy headers and certificate exception to WebSockets (#1670) * fix(desktop): send linked server proxy headers on WebSocket upgrades isLinkedOrigin compared the request origin with the link's http(s) origin, so the terminal WebSocket (wss://host/...) never matched and its upgrade went out without the proxy headers and basic auth. Behind a forward-auth proxy that upgrade was redirected to the login and the terminal closed with "connection closed unexpectedly". ws and wss now count as http and https for the comparison, the same mapping the WebSocket handshake itself uses, with host and port still matched exactly. * fix(desktop): allow a linked server's self-signed certificate for its WebSockets isInvalidCertificateAllowedForUrl compared the URL with a local getOrigin copy, so wss://host never matched the link's https://host origin and an allowed self-signed certificate did not cover the terminal and tunnel sockets. It now uses linkedServer.isLinkedOrigin, which maps ws and wss the same way as the proxy headers, and the local copy is removed. * feat(sdk): let plugins mint a short-lived user session and read a user's locale (#1666) ctx.auth.issueUserToken(userId, { purpose, ttlSeconds }) gives background work a session for calling Termix's own API as an existing user. It needs users:impersonate, defaults to 300s and is capped at 900s, is stored as a normal session row marked as plugin-issued, and every issue is audited. Such sessions are refused on routes that change the user's password, second factors, API keys or linked logins. ctx.users.getLocale(userId) reads the user's UI language under users:read. SDK 1.0.11, plugin API 1.3.0. * chore: changelog for merged sync, audit, event and desktop PRs * feat: add shell.rightEdge slot so plugin panels can push the page over * chore: pin bundled plugins to the latest releases * chore: lint, format, and bump version to 26.10.2 --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Ferdina Kusumah <19812460+FerdinaKusumah@users.noreply.github.com> Co-authored-by: ZacharyZcR <zkl205305@gmail.com> Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
3901 lines
111 KiB
JavaScript
3901 lines
111 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 autoUpdates = require("./auto-updater.cjs");
|
|
const { launchNativeRdp } = require("./native-rdp.cjs");
|
|
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
|
|
const { quitApp } = require("./app-quit.cjs");
|
|
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
|
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
|
|
const {
|
|
registerLocalFileHandlers,
|
|
setLocalBackendPort,
|
|
} = require("./local-files.cjs");
|
|
const {
|
|
EXTERNAL_EDITOR_SETTING,
|
|
isLaunchableFileName,
|
|
} = require("./external-editor.cjs");
|
|
const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs");
|
|
|
|
const localTerminalSessions = new Map();
|
|
|
|
function ownedLocalTerminal(event, sessionId) {
|
|
if (typeof sessionId !== "string" || !/^[a-f0-9-]{36}$/.test(sessionId)) {
|
|
return null;
|
|
}
|
|
const session = localTerminalSessions.get(sessionId);
|
|
return session?.ownerId === event.sender.id ? session : null;
|
|
}
|
|
|
|
function closeLocalTerminalsFor(ownerId) {
|
|
for (const [sessionId, session] of localTerminalSessions) {
|
|
if (session.ownerId !== ownerId) continue;
|
|
session.process.kill();
|
|
localTerminalSessions.delete(sessionId);
|
|
}
|
|
}
|
|
|
|
// The main process's Node.js networking (the `https`/`http` modules used by
|
|
// httpFetch below and the global `fetch`) only
|
|
// trusts Node's bundled Mozilla CA list by default, not the OS/system trust
|
|
// store. Chromium (the renderer, i.e. the web app and the login iframe) uses
|
|
// the OS trust store instead, so a certificate that's valid in-browser --
|
|
// e.g. one issued by a reverse proxy's internal/corporate CA, or a system
|
|
// CA installed via Keychain/certmgr -- can still fail main-process requests
|
|
// with UNABLE_TO_VERIFY_LEAF_SIGNATURE. Merge the system store in so remote
|
|
// sync and the connection health check see the same trust as the browser.
|
|
try {
|
|
if (typeof tls.setDefaultCACertificates === "function") {
|
|
tls.setDefaultCACertificates([
|
|
...tls.getCACertificates("default"),
|
|
...tls.getCACertificates("system"),
|
|
]);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to merge system CA certificates:", error);
|
|
}
|
|
|
|
// Portable mode: if a `.portable` marker exists next to the executable,
|
|
// store all data in a `data` folder beside the exe instead of %APPDATA%.
|
|
(function setupPortableDataDir() {
|
|
const exeDir = path.dirname(process.execPath);
|
|
const markerPath = path.join(exeDir, ".portable");
|
|
const envOverride = process.env.TERMIX_DATA_DIR;
|
|
|
|
let portableDataDir = null;
|
|
if (envOverride) {
|
|
portableDataDir = envOverride;
|
|
} else if (app.isPackaged && fs.existsSync(markerPath)) {
|
|
portableDataDir = path.join(exeDir, "data");
|
|
}
|
|
|
|
if (portableDataDir) {
|
|
try {
|
|
if (!fs.existsSync(portableDataDir)) {
|
|
fs.mkdirSync(portableDataDir, { recursive: true });
|
|
}
|
|
app.setPath("userData", portableDataDir);
|
|
} catch {
|
|
// Fall back to default userData if we can't create the directory.
|
|
}
|
|
}
|
|
})();
|
|
|
|
const logFile = path.join(app.getPath("userData"), "termix-main.log");
|
|
const electronAuthCookiesPath = path.join(
|
|
app.getPath("userData"),
|
|
"electron-auth-cookies.json",
|
|
);
|
|
const electronAuthCookies = new Map();
|
|
|
|
function logToFile(...args) {
|
|
const timestamp = new Date().toISOString();
|
|
const msg = args
|
|
.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
|
|
.join(" ");
|
|
const line = `[${timestamp}] ${msg}\n`;
|
|
try {
|
|
fs.appendFileSync(logFile, line);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
console.log(...args);
|
|
}
|
|
|
|
function getCookieOrigin(url) {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
const protocol =
|
|
parsedUrl.protocol === "ws:"
|
|
? "http:"
|
|
: parsedUrl.protocol === "wss:"
|
|
? "https:"
|
|
: parsedUrl.protocol;
|
|
return `${protocol}//${parsedUrl.host}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseCookieTarget(url) {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
if (parsedUrl.protocol === "ws:") {
|
|
parsedUrl.protocol = "http:";
|
|
} else if (parsedUrl.protocol === "wss:") {
|
|
parsedUrl.protocol = "https:";
|
|
}
|
|
return parsedUrl;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getElectronAuthCookieKey(name, origin) {
|
|
return origin ? `${origin}|${name}` : null;
|
|
}
|
|
|
|
function getSafeStorageAvailable() {
|
|
try {
|
|
return safeStorage.isEncryptionAvailable();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function encodeElectronAuthCookieValue(value) {
|
|
return {
|
|
encrypted: true,
|
|
value: safeStorage.encryptString(value).toString("base64"),
|
|
};
|
|
}
|
|
|
|
function decodeElectronAuthCookieValue(record) {
|
|
if (!record.encrypted || !getSafeStorageAvailable()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return safeStorage.decryptString(Buffer.from(record.value, "base64"));
|
|
} catch (error) {
|
|
logToFile(
|
|
"Failed to decrypt persisted Electron auth cookie:",
|
|
error.message,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isElectronAuthCookieExpired(cookie) {
|
|
return Number.isFinite(cookie.expiresAt) && cookie.expiresAt <= Date.now();
|
|
}
|
|
|
|
function saveElectronAuthCookiesToDisk() {
|
|
try {
|
|
if (!getSafeStorageAvailable()) {
|
|
if (fs.existsSync(electronAuthCookiesPath)) {
|
|
fs.rmSync(electronAuthCookiesPath, { force: true });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const records = [];
|
|
|
|
for (const [key, cookie] of electronAuthCookies.entries()) {
|
|
if (isElectronAuthCookieExpired(cookie)) {
|
|
electronAuthCookies.delete(key);
|
|
continue;
|
|
}
|
|
|
|
records.push({
|
|
key,
|
|
name: cookie.name,
|
|
origin: cookie.origin,
|
|
path: cookie.path,
|
|
expiresAt: cookie.expiresAt,
|
|
...encodeElectronAuthCookieValue(cookie.value),
|
|
});
|
|
}
|
|
|
|
fs.writeFileSync(
|
|
electronAuthCookiesPath,
|
|
JSON.stringify({ version: 1, records }, null, 2),
|
|
);
|
|
} catch (error) {
|
|
logToFile("Failed to persist Electron auth cookies:", error.message);
|
|
}
|
|
}
|
|
|
|
function loadElectronAuthCookiesFromDisk() {
|
|
electronAuthCookies.clear();
|
|
|
|
try {
|
|
if (!getSafeStorageAvailable()) {
|
|
if (fs.existsSync(electronAuthCookiesPath)) {
|
|
fs.rmSync(electronAuthCookiesPath, { force: true });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!fs.existsSync(electronAuthCookiesPath)) {
|
|
return;
|
|
}
|
|
|
|
const data = JSON.parse(fs.readFileSync(electronAuthCookiesPath, "utf8"));
|
|
const records = Array.isArray(data.records) ? data.records : [];
|
|
|
|
for (const record of records) {
|
|
if (
|
|
!record ||
|
|
typeof record.key !== "string" ||
|
|
typeof record.name !== "string" ||
|
|
typeof record.origin !== "string"
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const value = decodeElectronAuthCookieValue(record);
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
|
|
const cookie = {
|
|
name: record.name,
|
|
value,
|
|
origin: record.origin,
|
|
path: typeof record.path === "string" ? record.path : "/",
|
|
expiresAt: Number.isFinite(record.expiresAt) ? record.expiresAt : null,
|
|
};
|
|
|
|
if (!isElectronAuthCookieExpired(cookie)) {
|
|
electronAuthCookies.set(record.key, cookie);
|
|
}
|
|
}
|
|
|
|
saveElectronAuthCookiesToDisk();
|
|
} catch (error) {
|
|
logToFile("Failed to load persisted Electron auth cookies:", error.message);
|
|
}
|
|
}
|
|
|
|
function clearPersistedElectronAuthCookies() {
|
|
electronAuthCookies.clear();
|
|
try {
|
|
if (fs.existsSync(electronAuthCookiesPath)) {
|
|
fs.rmSync(electronAuthCookiesPath, { force: true });
|
|
}
|
|
} catch (error) {
|
|
logToFile(
|
|
"Failed to clear persisted Electron auth cookies:",
|
|
error.message,
|
|
);
|
|
}
|
|
}
|
|
|
|
function parseSetCookieHeader(header) {
|
|
const [cookiePair, ...attributes] = String(header || "").split(";");
|
|
const separatorIndex = cookiePair.indexOf("=");
|
|
if (separatorIndex <= 0) return null;
|
|
|
|
const parsed = {
|
|
name: cookiePair.slice(0, separatorIndex).trim(),
|
|
value: cookiePair.slice(separatorIndex + 1).trim(),
|
|
path: "/",
|
|
maxAge: null,
|
|
expires: null,
|
|
};
|
|
|
|
for (const attribute of attributes) {
|
|
const [rawName, ...rawValueParts] = attribute.trim().split("=");
|
|
const attrName = rawName.toLowerCase();
|
|
const attrValue = rawValueParts.join("=");
|
|
|
|
if (attrName === "path" && attrValue) {
|
|
parsed.path = attrValue;
|
|
} else if (attrName === "max-age" && attrValue) {
|
|
const maxAge = Number(attrValue);
|
|
parsed.maxAge = Number.isFinite(maxAge) ? maxAge : null;
|
|
} else if (attrName === "expires" && attrValue) {
|
|
const expires = Date.parse(attrValue);
|
|
parsed.expires = Number.isFinite(expires) ? expires : null;
|
|
}
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function rememberElectronAuthCookieFromHeader(url, header) {
|
|
const origin = getCookieOrigin(url);
|
|
if (!origin) return;
|
|
|
|
const cookie = parseSetCookieHeader(header);
|
|
if (!cookie || cookie.name !== "jwt") return;
|
|
|
|
const key = getElectronAuthCookieKey(cookie.name, origin);
|
|
if (!key) return;
|
|
|
|
const expired =
|
|
cookie.maxAge === 0 ||
|
|
(cookie.expires !== null && cookie.expires <= Date.now());
|
|
|
|
if (expired || !cookie.value) {
|
|
electronAuthCookies.delete(key);
|
|
saveElectronAuthCookiesToDisk();
|
|
return;
|
|
}
|
|
|
|
const expiresAt =
|
|
cookie.maxAge !== null ? Date.now() + cookie.maxAge * 1000 : cookie.expires;
|
|
|
|
electronAuthCookies.set(key, {
|
|
name: cookie.name,
|
|
value: cookie.value,
|
|
origin,
|
|
path: cookie.path,
|
|
expiresAt,
|
|
});
|
|
saveElectronAuthCookiesToDisk();
|
|
}
|
|
|
|
function getRememberedElectronAuthCookie(name, targetUrl) {
|
|
const target = parseCookieTarget(targetUrl);
|
|
if (!target) return null;
|
|
|
|
const exactKey = getElectronAuthCookieKey(name, target.origin);
|
|
const exactCookie = exactKey ? electronAuthCookies.get(exactKey) : null;
|
|
if (exactCookie && !isElectronAuthCookieExpired(exactCookie)) {
|
|
return exactCookie;
|
|
}
|
|
|
|
if (target.protocol !== "https:") {
|
|
return null;
|
|
}
|
|
|
|
const httpOrigin = `http://${target.host}`;
|
|
const httpKey = getElectronAuthCookieKey(name, httpOrigin);
|
|
const httpCookie = httpKey ? electronAuthCookies.get(httpKey) : null;
|
|
return httpCookie && !isElectronAuthCookieExpired(httpCookie)
|
|
? httpCookie
|
|
: null;
|
|
}
|
|
|
|
function getHeaderName(headers, name) {
|
|
const lowerName = name.toLowerCase();
|
|
return Object.keys(headers || {}).find(
|
|
(key) => key.toLowerCase() === lowerName,
|
|
);
|
|
}
|
|
|
|
function setCookieHeaderValue(requestHeaders, name, value) {
|
|
const headerName = getHeaderName(requestHeaders, "Cookie") || "Cookie";
|
|
const existing = requestHeaders[headerName];
|
|
const existingValue = Array.isArray(existing)
|
|
? existing.join("; ")
|
|
: existing;
|
|
const nextCookie = `${name}=${value}`;
|
|
const otherCookies = String(existingValue || "")
|
|
.split(";")
|
|
.map((cookie) => cookie.trim())
|
|
.filter((cookie) => cookie && !cookie.startsWith(`${name}=`));
|
|
|
|
requestHeaders[headerName] =
|
|
otherCookies.length > 0
|
|
? `${otherCookies.join("; ")}; ${nextCookie}`
|
|
: nextCookie;
|
|
}
|
|
|
|
function parseSemver(version) {
|
|
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
if (!match) return null;
|
|
|
|
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
|
|
}
|
|
|
|
function compareSemver(a, b) {
|
|
const parsedA = parseSemver(a);
|
|
const parsedB = parseSemver(b);
|
|
if (!parsedA || !parsedB) return null;
|
|
|
|
for (let i = 0; i < 3; i += 1) {
|
|
if (parsedA[i] > parsedB[i]) return 1;
|
|
if (parsedA[i] < parsedB[i]) return -1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
const INSECURE_MODE_VALUES = new Set(["true", "1", "yes"]);
|
|
|
|
function isInsecureModeEnabled() {
|
|
return INSECURE_MODE_VALUES.has(
|
|
String(process.env.ENABLE_INSECURE_MODE || "")
|
|
.trim()
|
|
.toLowerCase(),
|
|
);
|
|
}
|
|
|
|
function isPrivateNetworkHost(hostname) {
|
|
if (
|
|
hostname === "localhost" ||
|
|
hostname === "127.0.0.1" ||
|
|
hostname === "::1"
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const ipv4Match = hostname.match(
|
|
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
|
);
|
|
if (ipv4Match) {
|
|
const [, a, b] = ipv4Match.map(Number);
|
|
return (
|
|
a === 10 ||
|
|
(a === 172 && b >= 16 && b <= 31) ||
|
|
(a === 192 && b === 168) ||
|
|
(a === 169 && b === 254)
|
|
);
|
|
}
|
|
|
|
if (hostname.startsWith("fc") || hostname.startsWith("fd")) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isInvalidCertificateAllowedForUrl(url) {
|
|
if (isInsecureModeEnabled()) return true;
|
|
|
|
try {
|
|
const { hostname } = new URL(url);
|
|
if (isPrivateNetworkHost(hostname)) return true;
|
|
} catch {
|
|
// fall through
|
|
}
|
|
|
|
// Otherwise only the server this desktop is linked to, when its link
|
|
// allows a self-signed certificate.
|
|
const linked = linkedServer.getLinkedServer();
|
|
if (!linked?.allowInvalidCertificate) return false;
|
|
return linkedServer.isLinkedOrigin(url);
|
|
}
|
|
|
|
function getTlsVerificationOptions(url) {
|
|
return {
|
|
rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url),
|
|
};
|
|
}
|
|
|
|
function getWebSocketOptions(url, options = {}) {
|
|
return {
|
|
...options,
|
|
...(String(url).startsWith("wss:") ? getTlsVerificationOptions(url) : {}),
|
|
};
|
|
}
|
|
|
|
function httpFetch(url, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const urlObj = new URL(url);
|
|
const isHttps = urlObj.protocol === "https:";
|
|
const client = isHttps ? https : http;
|
|
|
|
const requestOptions = {
|
|
method: options.method || "GET",
|
|
headers: options.headers || {},
|
|
timeout: options.timeout || 10000,
|
|
...(isHttps
|
|
? options.allowInvalidCertificate
|
|
? { rejectUnauthorized: false }
|
|
: getTlsVerificationOptions(url)
|
|
: {}),
|
|
};
|
|
|
|
const req = client.request(url, requestOptions, (res) => {
|
|
const chunks = [];
|
|
// Reverse proxies (nginx and friends) commonly gzip/deflate/br-compress
|
|
// responses regardless of client Accept-Encoding. Unlike browser fetch,
|
|
// Node's http/https modules never auto-decompress, so an unhandled
|
|
// content-encoding here silently turns the body into garbage bytes.
|
|
let stream = res;
|
|
const maxResponseBytes = options.maxResponseBytes || 10 * 1024 * 1024;
|
|
let responseBytes = 0;
|
|
let settled = false;
|
|
const fail = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
stream.destroy();
|
|
req.destroy();
|
|
reject(error);
|
|
};
|
|
const encoding = (res.headers["content-encoding"] || "")
|
|
.toLowerCase()
|
|
.trim();
|
|
try {
|
|
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
stream = res.pipe(zlib.createGunzip());
|
|
} else if (encoding === "br") {
|
|
stream = res.pipe(zlib.createBrotliDecompress());
|
|
} else if (encoding === "deflate") {
|
|
stream = res.pipe(zlib.createInflate());
|
|
}
|
|
} catch (decompressError) {
|
|
reject(decompressError);
|
|
return;
|
|
}
|
|
|
|
stream.on("data", (chunk) => {
|
|
responseBytes += chunk.length;
|
|
if (responseBytes > maxResponseBytes) {
|
|
fail(new Error(`Response exceeds ${maxResponseBytes} bytes`));
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
stream.on("end", () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
const data = Buffer.concat(chunks).toString("utf8");
|
|
resolve({
|
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
status: res.statusCode,
|
|
text: () => Promise.resolve(data),
|
|
json: () => Promise.resolve(JSON.parse(data)),
|
|
});
|
|
});
|
|
stream.on("error", fail);
|
|
});
|
|
|
|
req.on("error", reject);
|
|
req.on("timeout", () => {
|
|
req.destroy();
|
|
reject(new Error("Request timeout"));
|
|
});
|
|
|
|
if (options.body) {
|
|
req.write(options.body);
|
|
}
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
if (process.platform === "linux") {
|
|
app.commandLine.appendSwitch("--ozone-platform-hint=auto");
|
|
|
|
app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder");
|
|
|
|
// Fix click-coordinate misalignment with fractional display scaling on KDE/Wayland.
|
|
// Chromium's hit-testing uses unscaled coords while the compositor scales visually,
|
|
// so forcing scale factor 1 keeps them in sync. See: https://github.com/brave/brave-browser/issues/50028
|
|
app.commandLine.appendSwitch("--force-device-scale-factor", "1");
|
|
|
|
const passwordStore = selectLinuxPasswordStore(app.commandLine, process.env);
|
|
if (passwordStore) {
|
|
logToFile(
|
|
`[safeStorage] Selected the ${passwordStore} password store for this desktop.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (process.platform === "win32" || process.env.ELECTRON_DISABLE_GPU === "1") {
|
|
app.disableHardwareAcceleration();
|
|
}
|
|
|
|
if (isInsecureModeEnabled()) {
|
|
logToFile(
|
|
"[security] ENABLE_INSECURE_MODE is enabled; TLS certificate validation is disabled.",
|
|
);
|
|
app.commandLine.appendSwitch("--ignore-certificate-errors");
|
|
app.commandLine.appendSwitch("--ignore-ssl-errors");
|
|
app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list");
|
|
}
|
|
app.commandLine.appendSwitch("--enable-features=NetworkService");
|
|
|
|
let mainWindow = null;
|
|
const { createIsolatedWindows } = require("./isolated-window.cjs");
|
|
const isolatedWindows = createIsolatedWindows({
|
|
BrowserWindow,
|
|
session,
|
|
getMainWindow: () => mainWindow,
|
|
});
|
|
|
|
// Requests from the embedded backend over the fork IPC channel (the other
|
|
// direction from the "shutdown" message main already sends it). A plugin's
|
|
// ctx.desktop.openIsolatedWindow is the one caller today.
|
|
const BACKEND_REQUEST_HANDLERS = {
|
|
"open-isolated-window": (payload) => isolatedWindows.open(payload),
|
|
// The OS client for a protocol. Only RDP has one wired up.
|
|
"launch-external-client": (payload) =>
|
|
payload?.protocol === "rdp"
|
|
? launchNativeRdp(payload)
|
|
: {
|
|
success: false,
|
|
error: `No external client for ${payload?.protocol ?? "this protocol"}`,
|
|
},
|
|
"sync-proxy-config": (payload) => {
|
|
linkedServer.setLinkedServer(payload);
|
|
return { success: true };
|
|
},
|
|
};
|
|
|
|
function isDevRunner() {
|
|
return (
|
|
process.env.TERMIX_DEV_RUNNER === "true" &&
|
|
typeof process.send === "function"
|
|
);
|
|
}
|
|
|
|
async function handleBackendRequest(msg) {
|
|
if (!msg || msg.type !== "backend-request") return;
|
|
const { id, channel, payload } = msg;
|
|
const handler = BACKEND_REQUEST_HANDLERS[channel];
|
|
// Under npm run dev:electron the runner owns the backend and relays these messages.
|
|
const target =
|
|
backendProcess && !backendProcess.killed
|
|
? backendProcess
|
|
: isDevRunner()
|
|
? process
|
|
: null;
|
|
const reply = (response) => {
|
|
if (!target) return;
|
|
try {
|
|
target.send({ type: "backend-response", id, ...response });
|
|
} catch (error) {
|
|
logToFile("Failed to reply to backend request:", error.message);
|
|
}
|
|
};
|
|
if (!handler) {
|
|
reply({ ok: false, error: `Unknown backend request channel: ${channel}` });
|
|
return;
|
|
}
|
|
try {
|
|
const result = await handler(payload);
|
|
reply({ ok: true, result });
|
|
} catch (error) {
|
|
reply({ ok: false, error: error.message || String(error) });
|
|
}
|
|
}
|
|
|
|
let backendProcess = null;
|
|
let backendStartFailed = false;
|
|
// Why the embedded backend died, once it has. Null while it is healthy
|
|
// or still starting. The renderer polls this to tell "still booting"
|
|
// (keep waiting) apart from "never coming back" (show the reason).
|
|
let backendFailure = null;
|
|
// Set while we are deliberately tearing the backend down on quit, so a
|
|
// SIGKILL we sent ourselves is not reported to the user as a crash.
|
|
let backendStopRequested = false;
|
|
// Tail of the embedded backend's stderr, kept only so a failed start can be
|
|
// classified after the fact.
|
|
const BACKEND_STDERR_TAIL_LIMIT = 8192;
|
|
let backendStderrTail = "";
|
|
let tray = null;
|
|
let isQuitting = false;
|
|
const tempFiles = new Map();
|
|
const externalEditorSessions = new Map();
|
|
// Paths the user picked in an open dialog this session. Only these may be
|
|
// saved as the external editor, so the renderer cannot name an executable.
|
|
const dialogChosenPaths = new Set();
|
|
|
|
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
|
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
|
|
const windowsAppUserModelId = "com.karmaa.termix";
|
|
const electronCacheBuildPath = path.join(
|
|
app.getPath("userData"),
|
|
"client-cache-build.json",
|
|
);
|
|
const termixSessionPartition = "persist:termix";
|
|
|
|
function getTempRoot() {
|
|
const tempRoot = path.join(app.getPath("temp"), "termix");
|
|
fs.mkdirSync(tempRoot, { recursive: true });
|
|
return tempRoot;
|
|
}
|
|
|
|
function sanitizeFileName(fileName) {
|
|
const baseName = path.basename(String(fileName || "file"));
|
|
return baseName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") || "file";
|
|
}
|
|
|
|
function decodeFileContent(content, encoding) {
|
|
if (encoding === "base64") {
|
|
return Buffer.from(String(content || ""), "base64");
|
|
}
|
|
return Buffer.from(String(content || ""), "utf8");
|
|
}
|
|
|
|
function createManagedTempFile(fileName, content, encoding = "utf8") {
|
|
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`));
|
|
const filePath = path.join(tempDir, sanitizeFileName(fileName));
|
|
fs.writeFileSync(filePath, decodeFileContent(content, encoding));
|
|
tempFiles.set(tempId, { path: filePath, dir: tempDir });
|
|
return { tempId, path: filePath };
|
|
}
|
|
|
|
function cleanupManagedTempFile(tempId) {
|
|
const temp = tempFiles.get(tempId);
|
|
if (!temp) return;
|
|
try {
|
|
fs.rmSync(temp.dir, { recursive: true, force: true });
|
|
} catch (error) {
|
|
logToFile("Failed to clean up temporary file:", error.message);
|
|
}
|
|
tempFiles.delete(tempId);
|
|
}
|
|
|
|
function closeExternalEditorSession(editId) {
|
|
const session = externalEditorSessions.get(editId);
|
|
if (!session) return;
|
|
if (session.timer) clearTimeout(session.timer);
|
|
try {
|
|
session.watcher.close();
|
|
} catch {
|
|
// watcher may already be closed
|
|
}
|
|
externalEditorSessions.delete(editId);
|
|
cleanupManagedTempFile(editId);
|
|
}
|
|
|
|
function notifyExternalEditorSaved(editId) {
|
|
const session = externalEditorSessions.get(editId);
|
|
if (!session || !mainWindow || mainWindow.isDestroyed()) return;
|
|
|
|
try {
|
|
const stat = fs.statSync(session.path);
|
|
if (stat.mtimeMs === session.lastMtimeMs) return;
|
|
session.lastMtimeMs = stat.mtimeMs;
|
|
|
|
const content = fs.readFileSync(session.path, "utf8");
|
|
mainWindow.webContents.send("external-editor-saved", {
|
|
editId,
|
|
content,
|
|
encoding: "utf8",
|
|
path: session.path,
|
|
});
|
|
} catch (error) {
|
|
logToFile("Failed to read external editor file:", error.message);
|
|
}
|
|
}
|
|
|
|
function openPathWithEditor(filePath, editorPath) {
|
|
if (!editorPath) {
|
|
return shell.openPath(filePath);
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const child = spawn(editorPath, [filePath], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
});
|
|
|
|
child.once("error", (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(error.message);
|
|
});
|
|
|
|
setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
child.unref();
|
|
resolve("");
|
|
}, 500);
|
|
});
|
|
}
|
|
|
|
// Origins the renderer has explicitly marked as allowed to present an invalid
|
|
// certificate, for web endpoints whose ignoreCert is set. Session scoped and
|
|
// never persisted.
|
|
//
|
|
// Deliberately NOT wired into isInvalidCertificateAllowedForUrl: that function
|
|
// also governs this process's own outbound TLS via getTlsVerificationOptions,
|
|
// and widening it would grant more privilege than the feature needs.
|
|
//
|
|
// Entries expire rather than living until the app restarts (days, on a
|
|
// desktop app). Main has no host-database access, so it cannot verify a
|
|
// renderer's claim that an origin really is a configured web endpoint -- an
|
|
// unbounded allowance would make that an indefinite window rather than a
|
|
// momentary one. The renderer re-registers before each load, which refreshes
|
|
// the expiry, so a live endpoint keeps working while a stale entry lapses.
|
|
const WEB_ENDPOINT_CERTIFICATE_ALLOWLIST_TTL_MS = 5 * 60 * 1000;
|
|
const webEndpointCertificateAllowlist = new Map();
|
|
|
|
function isWebEndpointCertificateAllowed(url) {
|
|
try {
|
|
const parsed = new URL(url);
|
|
// The allowance exists solely to suppress a TLS certificate error, so a
|
|
// non-https origin in the map is meaningless at best.
|
|
if (parsed.protocol !== "https:") return false;
|
|
|
|
const expiresAt = webEndpointCertificateAllowlist.get(parsed.origin);
|
|
if (expiresAt === undefined) return false;
|
|
if (Date.now() >= expiresAt) {
|
|
webEndpointCertificateAllowlist.delete(parsed.origin);
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("allow-invalid-certificate-for-origin", (_event, origin) => {
|
|
try {
|
|
// Store the PARSED origin, never the caller's string, so a path or a
|
|
// wildcard cannot widen the allowance.
|
|
const parsed = new URL(String(origin));
|
|
if (parsed.protocol !== "https:") {
|
|
return { success: false };
|
|
}
|
|
webEndpointCertificateAllowlist.set(
|
|
parsed.origin,
|
|
Date.now() + WEB_ENDPOINT_CERTIFICATE_ALLOWLIST_TTL_MS,
|
|
);
|
|
return { success: true };
|
|
} catch {
|
|
return { success: false };
|
|
}
|
|
});
|
|
|
|
app.on(
|
|
"certificate-error",
|
|
(event, _webContents, url, error, certificate, callback) => {
|
|
if (
|
|
isolatedWindows.handleCertificateError(event, _webContents, url, callback)
|
|
)
|
|
return;
|
|
if (isWebEndpointCertificateAllowed(url)) {
|
|
event.preventDefault();
|
|
logToFile("Allowed invalid certificate for configured web endpoint", {
|
|
url,
|
|
error,
|
|
issuer: certificate?.issuerName,
|
|
subject: certificate?.subjectName,
|
|
});
|
|
callback(true);
|
|
return;
|
|
}
|
|
|
|
if (isInvalidCertificateAllowedForUrl(url)) {
|
|
event.preventDefault();
|
|
logToFile("Allowed invalid certificate for configured server", {
|
|
url,
|
|
error,
|
|
issuer: certificate?.issuerName,
|
|
subject: certificate?.subjectName,
|
|
});
|
|
callback(true);
|
|
return;
|
|
}
|
|
|
|
callback(false);
|
|
},
|
|
);
|
|
|
|
// A reverse proxy in front of the linked server may ask for basic auth; the
|
|
// link's saved username and password answer it. Anything else gets the
|
|
// default behaviour.
|
|
app.on("login", (event, _webContents, details, authInfo, callback) => {
|
|
const credentials = linkedServer.answerLogin(authInfo, details?.url);
|
|
if (!credentials) return;
|
|
event.preventDefault();
|
|
callback(credentials.username, credentials.password || "");
|
|
});
|
|
|
|
function getElectronBuildTimestamp() {
|
|
try {
|
|
const buildInfo = require("./build-info.cjs");
|
|
if (Number.isInteger(buildInfo.buildTimestamp)) {
|
|
return buildInfo.buildTimestamp;
|
|
}
|
|
} catch {
|
|
// Development runs may not have generated build metadata yet.
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
async function clearElectronClientCacheIfBuildChanged() {
|
|
const buildTimestamp = getElectronBuildTimestamp();
|
|
let cacheTimestamp = 0;
|
|
|
|
try {
|
|
if (fs.existsSync(electronCacheBuildPath)) {
|
|
const data = JSON.parse(fs.readFileSync(electronCacheBuildPath, "utf8"));
|
|
cacheTimestamp = Number.isInteger(data.buildTimestamp)
|
|
? data.buildTimestamp
|
|
: 0;
|
|
}
|
|
} catch (error) {
|
|
logToFile(
|
|
"Failed to read Electron client cache build info:",
|
|
error.message,
|
|
);
|
|
}
|
|
|
|
if (cacheTimestamp === buildTimestamp) {
|
|
return;
|
|
}
|
|
|
|
const clearStep = async (label, action) => {
|
|
try {
|
|
await action();
|
|
} catch (error) {
|
|
logToFile(`Failed to clear Electron ${label}:`, error.message);
|
|
}
|
|
};
|
|
|
|
try {
|
|
const defaultSession = session.defaultSession;
|
|
await clearStep("HTTP cache", () => defaultSession.clearCache());
|
|
await clearStep("code cache", () =>
|
|
defaultSession.clearCodeCaches({ urls: [] }),
|
|
);
|
|
await clearStep("auth cache", () => defaultSession.clearAuthCache());
|
|
await clearStep("storage data", () =>
|
|
defaultSession.clearStorageData({
|
|
storages: [
|
|
"appcache",
|
|
"cookies",
|
|
"filesystem",
|
|
"shadercache",
|
|
"websql",
|
|
"serviceworkers",
|
|
"cachestorage",
|
|
],
|
|
}),
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
electronCacheBuildPath,
|
|
JSON.stringify(
|
|
{
|
|
buildTimestamp,
|
|
appVersion: app.getVersion(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
logToFile("Electron client cache cleared for build change", {
|
|
from: cacheTimestamp,
|
|
to: buildTimestamp,
|
|
appVersion: app.getVersion(),
|
|
});
|
|
} catch (error) {
|
|
logToFile("Failed to clear Electron client cache:", error.message);
|
|
}
|
|
}
|
|
|
|
function getCookieRemovalUrl(cookie) {
|
|
const scheme = cookie.secure ? "https" : "http";
|
|
const domain = cookie.domain?.startsWith(".")
|
|
? cookie.domain.slice(1)
|
|
: cookie.domain || "localhost";
|
|
return `${scheme}://${domain}${cookie.path || "/"}`;
|
|
}
|
|
|
|
async function clearElectronJwtCookiesAtStartup() {
|
|
loadElectronAuthCookiesFromDisk();
|
|
|
|
const targetSessions = new Set([
|
|
session.defaultSession,
|
|
session.fromPartition(termixSessionPartition),
|
|
]);
|
|
|
|
for (const targetSession of targetSessions) {
|
|
try {
|
|
const cookies = await targetSession.cookies.get({ name: "jwt" });
|
|
await Promise.all(
|
|
cookies.map((cookie) =>
|
|
targetSession.cookies.remove(
|
|
getCookieRemovalUrl(cookie),
|
|
cookie.name,
|
|
),
|
|
),
|
|
);
|
|
|
|
if (cookies.length > 0) {
|
|
logToFile("Cleared Electron JWT cookies from cookie store", {
|
|
count: cookies.length,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logToFile("Failed to clear Electron JWT cookies:", error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getBackendPaths() {
|
|
if (isDev) {
|
|
const backendDir = path.join(appRoot, "dist", "backend", "backend");
|
|
return {
|
|
entryPath: path.join(backendDir, "starter.js"),
|
|
backendCwd: backendDir,
|
|
};
|
|
}
|
|
// fork() does not go through Electron's asar redirector, use the unpacked path.
|
|
// On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR
|
|
// app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
|
|
const unpackedRoot = getUnpackedAppRoot(appRoot);
|
|
const backendDir = path.join(unpackedRoot, "dist", "backend", "backend");
|
|
return {
|
|
entryPath: path.join(backendDir, "starter.js"),
|
|
backendCwd: backendDir,
|
|
};
|
|
}
|
|
|
|
function getBackendDataDir() {
|
|
const userDataPath = app.getPath("userData");
|
|
const dataDir = path.join(userDataPath, "server-data");
|
|
if (!fs.existsSync(dataDir)) {
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
}
|
|
return dataDir;
|
|
}
|
|
|
|
function getBackendPidFilePath() {
|
|
return path.join(app.getPath("userData"), "backend.pid");
|
|
}
|
|
|
|
// If the app was previously killed abnormally (crash, force-quit, Task
|
|
// Manager) rather than through the normal quit flow, will-quit never fires
|
|
// and stopBackendServer() never runs -- the forked backend child is a
|
|
// genuinely separate OS process on Windows/mac/Linux, so it keeps running
|
|
// and holding the port the backend binds (30001). Every subsequent
|
|
// launch's own backend then fails outright
|
|
// with EADDRINUSE and the app is stuck until something manually kills the
|
|
// orphan. Reap any such leftover process, identified by PID file, before
|
|
// spawning a new one.
|
|
function reapOrphanedBackendProcess() {
|
|
const pidFilePath = getBackendPidFilePath();
|
|
let recordedPid;
|
|
try {
|
|
recordedPid = parseInt(fs.readFileSync(pidFilePath, "utf8").trim(), 10);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (!Number.isInteger(recordedPid) || recordedPid <= 0) return;
|
|
|
|
try {
|
|
// Signal 0 does not kill the process -- it only checks whether a
|
|
// process with this PID exists and is signalable, throwing ESRCH if
|
|
// not. This avoids killing an unrelated process that happens to have
|
|
// reused the same PID since the last run.
|
|
process.kill(recordedPid, 0);
|
|
} catch {
|
|
// No live process at that PID; nothing to reap.
|
|
try {
|
|
fs.unlinkSync(pidFilePath);
|
|
} catch {
|
|
// already absent
|
|
}
|
|
return;
|
|
}
|
|
|
|
logToFile(
|
|
`Found orphaned backend process from a previous session (pid ${recordedPid}), terminating it before starting a new one`,
|
|
);
|
|
try {
|
|
process.kill(recordedPid, "SIGKILL");
|
|
} catch {
|
|
// already gone
|
|
}
|
|
try {
|
|
fs.unlinkSync(pidFilePath);
|
|
} catch {
|
|
// already absent
|
|
}
|
|
}
|
|
|
|
const DEFAULT_BACKEND_PORT = 30001;
|
|
let backendPort = DEFAULT_BACKEND_PORT;
|
|
|
|
function canListenOn(port) {
|
|
return new Promise((resolve) => {
|
|
const probe = net.createServer();
|
|
probe.once("error", () => resolve(null));
|
|
probe.listen(port, "127.0.0.1", () => {
|
|
const { port: bound } = probe.address();
|
|
probe.close(() => resolve(bound));
|
|
});
|
|
});
|
|
}
|
|
|
|
// 30001 when it is free, otherwise any free port. Only the desktop app talks
|
|
// to this backend, so the number itself does not matter.
|
|
async function findBackendPort() {
|
|
const preferred = await canListenOn(DEFAULT_BACKEND_PORT);
|
|
if (preferred) return preferred;
|
|
const fallback = await canListenOn(0);
|
|
if (fallback) {
|
|
logToFile(
|
|
`Port ${DEFAULT_BACKEND_PORT} is in use, starting the backend on ${fallback}`,
|
|
);
|
|
return fallback;
|
|
}
|
|
return DEFAULT_BACKEND_PORT;
|
|
}
|
|
|
|
function startBackendServer() {
|
|
reapOrphanedBackendProcess();
|
|
backendFailure = null;
|
|
backendStopRequested = false;
|
|
backendStderrTail = "";
|
|
return new Promise((resolve) => {
|
|
const { entryPath, backendCwd } = getBackendPaths();
|
|
|
|
logToFile("isDev:", isDev, "appRoot:", appRoot);
|
|
logToFile("app.isPackaged:", app.isPackaged);
|
|
logToFile("process.env.NODE_ENV:", process.env.NODE_ENV);
|
|
|
|
if (!fs.existsSync(entryPath)) {
|
|
logToFile("Backend entry not found:", entryPath);
|
|
backendFailure = { reason: "crashed", port: null };
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
const dataDir = getBackendDataDir();
|
|
logToFile("Starting embedded backend server...");
|
|
logToFile("Backend entry:", entryPath);
|
|
logToFile("Data directory:", dataDir);
|
|
logToFile("Backend cwd:", backendCwd);
|
|
|
|
logToFile("Checking paths...");
|
|
logToFile(" entryPath exists:", fs.existsSync(entryPath));
|
|
logToFile(" dataDir exists:", fs.existsSync(dataDir));
|
|
logToFile(" backendCwd exists:", fs.existsSync(backendCwd));
|
|
|
|
backendProcess = fork(entryPath, [], {
|
|
cwd:
|
|
fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory()
|
|
? backendCwd
|
|
: dataDir,
|
|
env: {
|
|
...process.env,
|
|
DATA_DIR: dataDir,
|
|
NODE_ENV: "production",
|
|
ELECTRON_EMBEDDED: "true",
|
|
PORT: String(backendPort),
|
|
EMBEDDED_BACKEND_PORT: String(backendPort),
|
|
VERSION: app.getVersion(),
|
|
},
|
|
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
});
|
|
|
|
logToFile("Backend process spawned, pid:", backendProcess.pid);
|
|
try {
|
|
fs.writeFileSync(getBackendPidFilePath(), String(backendProcess.pid));
|
|
} catch {
|
|
// Non-fatal: only means a future crash won't self-heal via reap.
|
|
}
|
|
|
|
let resolved = false;
|
|
const readyTimeout = setTimeout(() => {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
logToFile("Backend ready timeout (15s), proceeding anyway...");
|
|
resolve(true);
|
|
}
|
|
}, 15000);
|
|
|
|
backendProcess.stdout.on("data", (data) => {
|
|
const msg = data.toString().trim();
|
|
logToFile("[backend]", msg);
|
|
if (!resolved && msg.includes("started successfully")) {
|
|
resolved = true;
|
|
clearTimeout(readyTimeout);
|
|
logToFile("Backend ready signal received");
|
|
resolve(true);
|
|
}
|
|
});
|
|
|
|
// The reason for a failed start is only ever visible in what the child
|
|
// wrote to stderr before dying, so keep a bounded tail of it. Bounded
|
|
// because a backend that stays up for days can otherwise log without
|
|
// limit into a buffer nothing ever drains.
|
|
backendProcess.stderr.on("data", (data) => {
|
|
const chunk = data.toString();
|
|
backendStderrTail = (backendStderrTail + chunk).slice(
|
|
-BACKEND_STDERR_TAIL_LIMIT,
|
|
);
|
|
logToFile("[backend:stderr]", chunk.trim());
|
|
});
|
|
|
|
backendProcess.on("message", (msg) => {
|
|
if (!resolved && msg?.type === "backend-ready") {
|
|
resolved = true;
|
|
clearTimeout(readyTimeout);
|
|
logToFile("Backend ready signal received via IPC");
|
|
resolve(true);
|
|
}
|
|
void handleBackendRequest(msg);
|
|
});
|
|
|
|
backendProcess.on("exit", (code, signal) => {
|
|
logToFile(`Backend process exited with code ${code}, signal ${signal}`);
|
|
if (!resolved && code !== 0) {
|
|
backendStartFailed = true;
|
|
}
|
|
// Classified whether or not the ready promise already settled: the
|
|
// 15s ready timeout resolves optimistically, so a backend that dies
|
|
// at second 20 still has to be reported rather than silently dropped.
|
|
// backendStopRequested is the only evidence that an exit was asked
|
|
// for, so it is the sole guard here -- past it, every exit means the
|
|
// backend is gone and the renderer must stop waiting for it.
|
|
if (!backendStopRequested) {
|
|
backendFailure = classifyBackendFailure(backendStderrTail);
|
|
logToFile("Backend failure classified as:", backendFailure.reason);
|
|
}
|
|
backendProcess = null;
|
|
clearBackendPidFile();
|
|
if (!resolved) {
|
|
resolved = true;
|
|
clearTimeout(readyTimeout);
|
|
resolve(false);
|
|
}
|
|
});
|
|
|
|
backendProcess.on("error", (err) => {
|
|
logToFile("Failed to start backend process:", err.message);
|
|
backendFailure = { reason: "crashed", port: null };
|
|
backendProcess = null;
|
|
if (!resolved) {
|
|
resolved = true;
|
|
clearTimeout(readyTimeout);
|
|
resolve(false);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function clearBackendPidFile() {
|
|
try {
|
|
fs.unlinkSync(getBackendPidFilePath());
|
|
} catch {
|
|
// already absent
|
|
}
|
|
}
|
|
|
|
function stopBackendServer() {
|
|
if (!backendProcess) return;
|
|
|
|
console.log("Stopping embedded backend server...");
|
|
backendStopRequested = true;
|
|
|
|
try {
|
|
backendProcess.send({ type: "shutdown" });
|
|
} catch {
|
|
// IPC channel may already be closed
|
|
}
|
|
|
|
const forceKillTimeout = setTimeout(() => {
|
|
if (backendProcess) {
|
|
console.log("Force killing backend process...");
|
|
backendProcess.kill("SIGKILL");
|
|
backendProcess = null;
|
|
}
|
|
}, 5000);
|
|
|
|
backendProcess.on("exit", () => {
|
|
clearTimeout(forceKillTimeout);
|
|
backendProcess = null;
|
|
clearBackendPidFile();
|
|
});
|
|
}
|
|
|
|
// App Sandbox denies the singleton socket bind, which reads as a held lock and
|
|
// quits the app on every launch. macOS reactivates bundled apps anyway.
|
|
const gotTheLock = process.mas || app.requestSingleInstanceLock();
|
|
if (!gotTheLock) {
|
|
console.log("Another instance is already running, quitting...");
|
|
app.quit();
|
|
process.exit(0);
|
|
} else {
|
|
app.on("second-instance", (event, commandLine, workingDirectory) => {
|
|
if (mainWindow) {
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.focus();
|
|
mainWindow.show();
|
|
}
|
|
});
|
|
}
|
|
|
|
function createTray() {
|
|
try {
|
|
const { nativeImage } = require("electron");
|
|
|
|
// Native APIs (Tray, nativeImage) can't load files from inside app.asar,
|
|
// use the unpacked path so the OS sees a real file.
|
|
const publicRoot = isDev
|
|
? path.join(appRoot, "public")
|
|
: path.join(
|
|
appRoot.replace(
|
|
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
|
|
"app.asar.unpacked",
|
|
),
|
|
"public",
|
|
);
|
|
|
|
let trayIcon;
|
|
if (process.platform === "darwin") {
|
|
const iconPath = path.join(publicRoot, "icons", "16x16.png");
|
|
trayIcon = nativeImage.createFromPath(iconPath);
|
|
trayIcon.setTemplateImage(true);
|
|
} else if (process.platform === "win32") {
|
|
trayIcon = path.join(publicRoot, "icon.ico");
|
|
} else {
|
|
trayIcon = loadLinuxTrayIcon(nativeImage, publicRoot);
|
|
if (!trayIcon) {
|
|
logToFile("Tray icon not found, skipping the system tray");
|
|
return;
|
|
}
|
|
if (/gnome/i.test(process.env.XDG_CURRENT_DESKTOP || "")) {
|
|
logToFile(
|
|
"GNOME shows tray icons only with the AppIndicator extension installed",
|
|
);
|
|
}
|
|
}
|
|
|
|
tray = new Tray(trayIcon);
|
|
tray.setToolTip("Termix");
|
|
|
|
const contextMenu = Menu.buildFromTemplate([
|
|
{
|
|
label: "Show Window",
|
|
click: () => {
|
|
if (mainWindow) {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: "Quit",
|
|
click: () => {
|
|
isQuitting = true;
|
|
quitApp(app, mainWindow);
|
|
},
|
|
},
|
|
]);
|
|
|
|
tray.setContextMenu(contextMenu);
|
|
|
|
tray.on("click", () => {
|
|
if (mainWindow) {
|
|
if (mainWindow.isVisible()) {
|
|
mainWindow.hide();
|
|
} else {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
}
|
|
});
|
|
|
|
logToFile("System tray created");
|
|
} catch (err) {
|
|
tray = null;
|
|
logToFile("Failed to create system tray:", err?.message || String(err));
|
|
}
|
|
}
|
|
|
|
function loadLinuxTrayIcon(nativeImage, publicRoot) {
|
|
const roots = [
|
|
publicRoot,
|
|
path.join(process.resourcesPath || "", "app.asar.unpacked", "public"),
|
|
];
|
|
for (const root of roots) {
|
|
for (const size of ["32x32", "24x24", "48x48"]) {
|
|
const image = nativeImage.createFromPath(
|
|
path.join(root, "icons", `${size}.png`),
|
|
);
|
|
if (!image.isEmpty()) return image;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function createWindow() {
|
|
const appVersion = app.getVersion();
|
|
const electronVersion = process.versions.electron;
|
|
const platform =
|
|
process.platform === "win32"
|
|
? "Windows"
|
|
: process.platform === "darwin"
|
|
? "macOS"
|
|
: "Linux";
|
|
|
|
mainWindow = new BrowserWindow({
|
|
width: 1200,
|
|
height: 800,
|
|
minWidth: 800,
|
|
minHeight: 600,
|
|
title: "Termix",
|
|
icon: path.join(
|
|
appRoot,
|
|
"public",
|
|
process.platform === "win32" ? "icon.ico" : "icon.png",
|
|
),
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
webSecurity: true,
|
|
preload: path.join(__dirname, "preload.js"),
|
|
additionalArguments: [`--termix-backend-port=${backendPort}`],
|
|
partition: termixSessionPartition,
|
|
allowRunningInsecureContent: false,
|
|
webviewTag: false,
|
|
offscreen: false,
|
|
},
|
|
show: true,
|
|
});
|
|
|
|
mainWindow.webContents.session.setPermissionRequestHandler(
|
|
(webContents, permission, callback) => {
|
|
if (
|
|
permission === "clipboard-read" ||
|
|
permission === "clipboard-write" ||
|
|
permission === "clipboard-sanitized-write"
|
|
) {
|
|
callback(true);
|
|
return;
|
|
}
|
|
callback(false);
|
|
},
|
|
);
|
|
|
|
if (process.platform !== "darwin") {
|
|
mainWindow.setMenuBarVisibility(false);
|
|
}
|
|
|
|
const customUserAgent = `Termix-Desktop/${appVersion} (${platform}; Electron/${electronVersion})`;
|
|
mainWindow.webContents.setUserAgent(customUserAgent);
|
|
|
|
mainWindow.webContents.on("before-input-event", (event, input) => {
|
|
if (process.platform !== "win32" || !isCloseActiveTabInput(input)) return;
|
|
|
|
event.preventDefault();
|
|
mainWindow.webContents.send("close-active-tab");
|
|
});
|
|
|
|
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
|
(details, callback) => {
|
|
details.requestHeaders["X-Electron-App"] = "true";
|
|
|
|
details.requestHeaders["User-Agent"] = customUserAgent;
|
|
|
|
// YouTube refuses embeds without a Referer, which file:// never sends.
|
|
if (
|
|
details.url.startsWith("https://www.youtube-nocookie.com/embed/") &&
|
|
!details.requestHeaders.Referer
|
|
) {
|
|
details.requestHeaders.Referer = "https://termix.site/";
|
|
}
|
|
|
|
const rememberedJwt = getRememberedElectronAuthCookie("jwt", details.url);
|
|
if (rememberedJwt) {
|
|
setCookieHeaderValue(
|
|
details.requestHeaders,
|
|
rememberedJwt.name,
|
|
rememberedJwt.value,
|
|
);
|
|
}
|
|
|
|
callback({
|
|
requestHeaders: linkedServer.applyLinkHeaders(
|
|
details.url,
|
|
details.requestHeaders,
|
|
),
|
|
});
|
|
},
|
|
);
|
|
|
|
if (isDev) {
|
|
mainWindow.loadURL("http://localhost:5173");
|
|
mainWindow.webContents.openDevTools();
|
|
} else {
|
|
const indexPath = path.join(appRoot, "dist", "index.html");
|
|
mainWindow.loadFile(indexPath).catch((err) => {
|
|
console.error("Failed to load file:", err);
|
|
});
|
|
}
|
|
|
|
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
|
(details, callback) => {
|
|
const headers = details.responseHeaders;
|
|
|
|
if (headers) {
|
|
delete headers["x-frame-options"];
|
|
delete headers["X-Frame-Options"];
|
|
|
|
if (headers["content-security-policy"]) {
|
|
headers["content-security-policy"] = headers[
|
|
"content-security-policy"
|
|
]
|
|
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
|
|
.filter((value) => value.trim().length > 0);
|
|
|
|
if (headers["content-security-policy"].length === 0) {
|
|
delete headers["content-security-policy"];
|
|
}
|
|
}
|
|
if (headers["Content-Security-Policy"]) {
|
|
headers["Content-Security-Policy"] = headers[
|
|
"Content-Security-Policy"
|
|
]
|
|
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
|
|
.filter((value) => value.trim().length > 0);
|
|
|
|
if (headers["Content-Security-Policy"].length === 0) {
|
|
delete headers["Content-Security-Policy"];
|
|
}
|
|
}
|
|
|
|
const setCookieHeaderName = getHeaderName(headers, "Set-Cookie");
|
|
if (setCookieHeaderName) {
|
|
const setCookieHeaders = Array.isArray(headers[setCookieHeaderName])
|
|
? headers[setCookieHeaderName]
|
|
: [headers[setCookieHeaderName]];
|
|
|
|
setCookieHeaders.forEach((cookie) => {
|
|
rememberElectronAuthCookieFromHeader(details.url, cookie);
|
|
});
|
|
|
|
headers[setCookieHeaderName] = setCookieHeaders.map((cookie) => {
|
|
let modified = cookie.replace(
|
|
/;\s*SameSite=Strict/gi,
|
|
"; SameSite=None",
|
|
);
|
|
modified = modified.replace(
|
|
/;\s*SameSite=Lax/gi,
|
|
"; SameSite=None",
|
|
);
|
|
if (!modified.includes("SameSite=")) {
|
|
modified += "; SameSite=None";
|
|
}
|
|
if (
|
|
!modified.includes("Secure") &&
|
|
details.url.startsWith("https")
|
|
) {
|
|
modified += "; Secure";
|
|
}
|
|
return modified;
|
|
});
|
|
}
|
|
}
|
|
|
|
callback({ responseHeaders: headers });
|
|
},
|
|
);
|
|
|
|
mainWindow.once("ready-to-show", () => {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
});
|
|
|
|
setTimeout(() => {
|
|
if (mainWindow && !mainWindow.isVisible()) {
|
|
mainWindow.show();
|
|
}
|
|
}, 3000);
|
|
|
|
mainWindow.webContents.on(
|
|
"did-fail-load",
|
|
(event, errorCode, errorDescription, validatedURL) => {
|
|
console.error(
|
|
"Failed to load:",
|
|
errorCode,
|
|
errorDescription,
|
|
validatedURL,
|
|
);
|
|
},
|
|
);
|
|
|
|
mainWindow.webContents.on("did-finish-load", () => {
|
|
console.log("Frontend loaded successfully");
|
|
});
|
|
|
|
mainWindow.webContents.on("will-prevent-unload", (event) => {
|
|
// Explicit Quit must not be vetoed by the renderer's connection guard.
|
|
// In this Electron event, preventDefault allows the unload to proceed.
|
|
if (isQuitting) event.preventDefault();
|
|
});
|
|
|
|
mainWindow.on("close", (event) => {
|
|
if (!isQuitting && tray && !tray.isDestroyed()) {
|
|
event.preventDefault();
|
|
// Stock GNOME has no tray to bring a hidden window back from.
|
|
if (
|
|
process.platform === "linux" &&
|
|
/gnome/i.test(process.env.XDG_CURRENT_DESKTOP || "")
|
|
) {
|
|
mainWindow.minimize();
|
|
} else {
|
|
mainWindow.hide();
|
|
}
|
|
}
|
|
});
|
|
|
|
mainWindow.on("closed", () => {
|
|
mainWindow = null;
|
|
});
|
|
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
shell.openExternal(url);
|
|
}
|
|
} catch {
|
|
// invalid URL, ignore
|
|
}
|
|
return { action: "deny" };
|
|
});
|
|
|
|
mainWindow.webContents.on("will-navigate", (event, url) => {
|
|
const allowedUrl = isDev
|
|
? url.startsWith("http://localhost:5173/")
|
|
: url === pathToFileURL(path.join(appRoot, "dist", "index.html")).href;
|
|
if (!allowedUrl) event.preventDefault();
|
|
});
|
|
}
|
|
|
|
ipcMain.handle("get-app-version", () => {
|
|
return app.getVersion();
|
|
});
|
|
|
|
ipcMain.handle("get-platform", () => {
|
|
return process.platform;
|
|
});
|
|
|
|
ipcMain.handle("get-embedded-server-status", () => {
|
|
return {
|
|
running:
|
|
backendProcess !== null && !backendProcess.killed && !backendStartFailed,
|
|
// A backend that is merely slow to boot reports no failure, so the
|
|
// renderer keeps waiting for it. Only a classified failure means the
|
|
// process is gone for good and waiting is pointless.
|
|
failure: backendFailure,
|
|
dataDir: isDev ? null : getBackendDataDir(),
|
|
};
|
|
});
|
|
|
|
// External login through the system browser (RFC 8252)
|
|
ipcMain.handle(
|
|
"external-browser-login",
|
|
async (_event, authUrl, callbackPort) => {
|
|
if (!isExternalLoginUrl(authUrl)) {
|
|
return { success: false, error: "Invalid authentication URL" };
|
|
}
|
|
if (!isCallbackPort(callbackPort)) {
|
|
return { success: false, error: "Invalid callback port" };
|
|
}
|
|
const http = require("http");
|
|
|
|
return new Promise((resolve) => {
|
|
let timeout;
|
|
let settled = false;
|
|
const server = http.createServer((req, res) => {
|
|
const url = new URL(req.url || "/", `http://localhost:${callbackPort}`);
|
|
if (url.pathname === "/oidc-callback") {
|
|
const success = url.searchParams.get("success");
|
|
const error = url.searchParams.get("error");
|
|
const token = url.searchParams.get("token");
|
|
const secondFactor = url.searchParams.get("second_factor") === "1";
|
|
|
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
res.end(
|
|
`<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);
|
|
});
|
|
|
|
let appUpdater = null;
|
|
|
|
function startAppUpdater() {
|
|
const kind = autoUpdates.getInstallKind({
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
isPackaged: app.isPackaged,
|
|
mas: !!process.mas,
|
|
env: process.env,
|
|
execPath: process.execPath,
|
|
resourcesPath: process.resourcesPath,
|
|
exists: (target) => fs.existsSync(target),
|
|
readFile: (target) => fs.readFileSync(target, "utf8"),
|
|
});
|
|
logToFile("Updater install kind:", kind);
|
|
appUpdater = autoUpdates.createUpdater({
|
|
currentVersion: app.getVersion(),
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
kind,
|
|
getMode: () => desktopSettings.getDesktopSettings().updateMode,
|
|
saveMode: (mode) =>
|
|
desktopSettings.saveDesktopSettings({ updateMode: mode }),
|
|
fetchReleases: async () => {
|
|
const { net } = require("electron");
|
|
const response = await net.fetch(autoUpdates.RELEASES_API, {
|
|
headers: { Accept: "application/vnd.github+json" },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`GitHub releases returned ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
loadAutoUpdater: () => require("electron-updater").autoUpdater,
|
|
send: (state) => {
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send("desktop-update-state", state);
|
|
}
|
|
},
|
|
log: logToFile,
|
|
beforeInstall: () => {
|
|
isQuitting = true;
|
|
},
|
|
});
|
|
appUpdater.start();
|
|
}
|
|
|
|
ipcMain.handle(
|
|
"desktop-update-get-state",
|
|
() => appUpdater?.getState() ?? null,
|
|
);
|
|
ipcMain.handle("desktop-update-check", () => appUpdater?.check() ?? null);
|
|
ipcMain.handle("desktop-update-download", () => appUpdater?.download() ?? null);
|
|
ipcMain.handle("desktop-update-install", () => appUpdater?.install() ?? false);
|
|
ipcMain.handle(
|
|
"desktop-update-set-mode",
|
|
(_event, mode) => appUpdater?.setMode(mode) ?? null,
|
|
);
|
|
|
|
// The server an older version of the app was pointed at, offered again when
|
|
// linking so upgraded installs do not have to look it up.
|
|
ipcMain.handle("get-previous-server-url", () => {
|
|
return { serverUrl: desktopSettings.getPreviousServerUrl() };
|
|
});
|
|
|
|
function getC2STunnelConfigPath() {
|
|
return path.join(app.getPath("userData"), "c2s-tunnels.json");
|
|
}
|
|
|
|
ipcMain.handle("get-c2s-tunnel-config", () => {
|
|
try {
|
|
const configPath = getC2STunnelConfigPath();
|
|
if (!fs.existsSync(configPath)) {
|
|
return [];
|
|
}
|
|
const configData = fs.readFileSync(configPath, "utf8");
|
|
const parsed = JSON.parse(configData);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch (error) {
|
|
console.error("Error reading C2S tunnel config:", error);
|
|
return [];
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("save-c2s-tunnel-config", async (_event, config) => {
|
|
try {
|
|
if (!Array.isArray(config)) {
|
|
return { success: false, error: "C2S tunnel config must be an array" };
|
|
}
|
|
const autoStartListeners = new Set();
|
|
const autoStartRemoteListeners = new Set();
|
|
for (const tunnel of config) {
|
|
if (!tunnel?.autoStart) continue;
|
|
const mode = tunnel.mode || tunnel.tunnelType || "local";
|
|
if (mode === "remote") {
|
|
const sourceHostId = Number(tunnel.sourceHostId);
|
|
const sourcePort = Number(tunnel.sourcePort);
|
|
const remoteAddress = getC2SRemoteAddress(tunnel);
|
|
if (
|
|
!Number.isInteger(sourceHostId) ||
|
|
sourceHostId < 1 ||
|
|
!Number.isInteger(sourcePort) ||
|
|
sourcePort < 1 ||
|
|
sourcePort > 65535
|
|
) {
|
|
return {
|
|
success: false,
|
|
error: "Invalid remote client tunnel endpoint or port",
|
|
};
|
|
}
|
|
const listenerKey = `${sourceHostId}:${remoteAddress}:${sourcePort}`;
|
|
if (autoStartRemoteListeners.has(listenerKey)) {
|
|
return {
|
|
success: false,
|
|
error: `Another auto-start client tunnel already uses remote ${listenerKey}`,
|
|
};
|
|
}
|
|
autoStartRemoteListeners.add(listenerKey);
|
|
continue;
|
|
}
|
|
|
|
const bindHost = getC2SLocalAddress(tunnel);
|
|
const sourcePort = Number(tunnel.sourcePort);
|
|
const listenerKey = `${bindHost}:${sourcePort}`;
|
|
if (autoStartListeners.has(listenerKey)) {
|
|
return {
|
|
success: false,
|
|
error: `Another auto-start client tunnel already uses ${listenerKey}`,
|
|
};
|
|
}
|
|
autoStartListeners.add(listenerKey);
|
|
}
|
|
for (const listenerKey of autoStartListeners) {
|
|
const sourcePortSeparator = listenerKey.lastIndexOf(":");
|
|
const bindHost = listenerKey.slice(0, sourcePortSeparator);
|
|
const sourcePort = listenerKey.slice(sourcePortSeparator + 1);
|
|
const result = await checkLocalPortAvailable(
|
|
bindHost,
|
|
Number(sourcePort),
|
|
);
|
|
const ownedByClientTunnel = Array.from(c2sTunnelRuntimes.values()).some(
|
|
(runtime) =>
|
|
runtime.bindHost === bindHost &&
|
|
runtime.sourcePort === Number(sourcePort),
|
|
);
|
|
if (!result.available && !ownedByClientTunnel) {
|
|
return {
|
|
success: false,
|
|
error: `Cannot auto-start client tunnel on ${listenerKey}: ${result.error || "port is already in use"}`,
|
|
};
|
|
}
|
|
}
|
|
const userDataPath = app.getPath("userData");
|
|
if (!fs.existsSync(userDataPath)) {
|
|
fs.mkdirSync(userDataPath, { recursive: true });
|
|
}
|
|
fs.writeFileSync(getC2STunnelConfigPath(), JSON.stringify(config, null, 2));
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Error saving C2S tunnel config:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
function checkLocalPortAvailable(host, port) {
|
|
return new Promise((resolve) => {
|
|
const server = net.createServer();
|
|
server.once("error", (error) => {
|
|
resolve({ available: false, error: error.message });
|
|
});
|
|
server.once("listening", () => {
|
|
server.close(() => resolve({ available: true }));
|
|
});
|
|
server.listen({ host, port });
|
|
});
|
|
}
|
|
|
|
function checkTcpConnection(host, port) {
|
|
return new Promise((resolve) => {
|
|
const socket = net.createConnection({ host, port });
|
|
const timer = setTimeout(() => {
|
|
socket.destroy();
|
|
resolve({ success: false, error: "Connection timed out" });
|
|
}, 5000);
|
|
|
|
socket.once("connect", () => {
|
|
clearTimeout(timer);
|
|
socket.destroy();
|
|
resolve({ success: true });
|
|
});
|
|
socket.once("error", (error) => {
|
|
clearTimeout(timer);
|
|
socket.destroy();
|
|
resolve({ success: false, error: error.message });
|
|
});
|
|
});
|
|
}
|
|
|
|
function getC2SAddress(value, fallback) {
|
|
const address = typeof value === "string" ? value.trim() : "";
|
|
return address || fallback;
|
|
}
|
|
|
|
function getC2SLocalAddress(tunnel) {
|
|
return getC2SAddress(tunnel?.localAddress || tunnel?.bindHost, "127.0.0.1");
|
|
}
|
|
|
|
function getC2SRemoteAddress(tunnel) {
|
|
return getC2SAddress(
|
|
tunnel?.remoteAddress || tunnel?.targetHost,
|
|
"127.0.0.1",
|
|
);
|
|
}
|
|
|
|
function normalizeC2STunnelAddresses(tunnel) {
|
|
const localAddress = getC2SLocalAddress(tunnel);
|
|
const remoteAddress = getC2SRemoteAddress(tunnel);
|
|
return {
|
|
...tunnel,
|
|
localAddress,
|
|
remoteAddress,
|
|
bindHost: localAddress,
|
|
targetHost: remoteAddress,
|
|
};
|
|
}
|
|
|
|
const c2sTunnelRuntimes = new Map();
|
|
const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
|
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
|
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
|
|
|
// A proxy in front of the server may drop an idle WebSocket, which stopped
|
|
// the tunnel. Pings count as traffic.
|
|
function keepC2SSocketAlive(ws) {
|
|
const timer = setInterval(() => {
|
|
if (ws.readyState === WebSocket.OPEN) ws.ping();
|
|
}, 25_000);
|
|
timer.unref?.();
|
|
ws.once("close", () => clearInterval(timer));
|
|
}
|
|
|
|
// C2S (client-to-server) tunnels relay through the server this desktop is
|
|
// linked to, not the embedded backend.
|
|
function getC2SRemoteBaseUrl() {
|
|
const serverUrl = linkedServer.getLinkedServer()?.serverUrl;
|
|
if (!serverUrl) {
|
|
throw new Error(
|
|
"This device is not linked to a Termix server. Link it in Sync first.",
|
|
);
|
|
}
|
|
return serverUrl.replace(/\/$/, "");
|
|
}
|
|
|
|
// The socket path on the linked server that relays C2S streams. The plugin
|
|
// that owns the relay tells us where it is, so nothing here names it.
|
|
let c2sRelayPath = null;
|
|
const C2S_RELAY_PATH_PATTERN =
|
|
/^\/plugin-ws\/[a-z0-9][a-z0-9-]*\/[A-Za-z0-9/_-]+$/;
|
|
|
|
ipcMain.handle("set-c2s-relay-path", (_event, relayPath) => {
|
|
if (
|
|
typeof relayPath !== "string" ||
|
|
!C2S_RELAY_PATH_PATTERN.test(relayPath)
|
|
) {
|
|
return { success: false, error: "Invalid relay path" };
|
|
}
|
|
c2sRelayPath = relayPath;
|
|
return { success: true };
|
|
});
|
|
|
|
function getC2SRelayUrl() {
|
|
if (!c2sRelayPath) {
|
|
throw new Error("Client tunnels are not available yet. Try again shortly.");
|
|
}
|
|
const relayHttpUrl = `${getC2SRemoteBaseUrl()}${c2sRelayPath}`;
|
|
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
}
|
|
|
|
const C2S_REMOTE_SESSION_EXPIRED_ERROR =
|
|
"This device was signed out of its server. Sign in again from Sync and try again.";
|
|
|
|
function normalizeC2SAuthToken(authToken) {
|
|
return typeof authToken === "string" ? authToken.trim() : "";
|
|
}
|
|
|
|
function isC2SAuthError(message) {
|
|
const value = String(message || "")
|
|
.trim()
|
|
.toLowerCase();
|
|
return (
|
|
value === "authentication required" ||
|
|
value === "missing authentication token" ||
|
|
value === "invalid token" ||
|
|
value === "session expired" ||
|
|
value === "session not found" ||
|
|
// The plugin relay refuses an unauthenticated upgrade before any message.
|
|
value === "unexpected server response: 401"
|
|
);
|
|
}
|
|
|
|
function normalizeC2SErrorMessage(message, fallback = "Client tunnel failed") {
|
|
const value = message || fallback;
|
|
return isC2SAuthError(value) ? C2S_REMOTE_SESSION_EXPIRED_ERROR : value;
|
|
}
|
|
|
|
function createC2SFailure(message, fallback) {
|
|
const error = normalizeC2SErrorMessage(message, fallback);
|
|
return {
|
|
success: false,
|
|
error,
|
|
...(error === C2S_REMOTE_SESSION_EXPIRED_ERROR
|
|
? { code: "REMOTE_SESSION_EXPIRED" }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
async function fetchC2SRemoteJson(pathname) {
|
|
if (!linkedServer.getLinkedServer()?.token) {
|
|
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
|
}
|
|
|
|
const url = `${getC2SRemoteBaseUrl()}${pathname}`;
|
|
const response = await httpFetch(url, {
|
|
headers: linkedServer.linkedRequestHeaders(),
|
|
});
|
|
if (response.status === 401 || response.status === 403) {
|
|
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(`Request failed (${response.status}): ${url}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function resolveC2SRemoteSourceHost(tunnel) {
|
|
const normalized = normalizeC2STunnelAddresses(tunnel);
|
|
const sourceHostSyncId =
|
|
typeof normalized.sourceHostSyncId === "string"
|
|
? normalized.sourceHostSyncId.trim()
|
|
: "";
|
|
if (!sourceHostSyncId) return normalized;
|
|
|
|
let remoteHost = null;
|
|
try {
|
|
remoteHost = await fetchC2SRemoteJson(
|
|
`/sync/v2/hosts/${encodeURIComponent(sourceHostSyncId)}`,
|
|
);
|
|
} catch (error) {
|
|
if (error.message === C2S_REMOTE_SESSION_EXPIRED_ERROR) throw error;
|
|
}
|
|
const remoteHostId = Number(remoteHost?.id);
|
|
if (!Number.isInteger(remoteHostId) || remoteHostId < 1) {
|
|
throw new Error(
|
|
"The intermediate host is not on the server yet. Sync, then try again.",
|
|
);
|
|
}
|
|
|
|
return {
|
|
...normalized,
|
|
sourceHostId: remoteHostId,
|
|
sourceHostSyncId,
|
|
sourceHostName: remoteHost.name || normalized.sourceHostName,
|
|
endpointHost: normalized.endpointHost || remoteHost.name,
|
|
};
|
|
}
|
|
|
|
async function getC2SRelayHeaders(relayUrl) {
|
|
if (linkedServer.getLinkedServer()?.token) {
|
|
return linkedServer.linkedRequestHeaders();
|
|
}
|
|
const headers = { "X-Electron-App": "true" };
|
|
|
|
if (!mainWindow?.webContents?.session) {
|
|
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
|
}
|
|
|
|
const cookieUrl = relayUrl
|
|
.replace(/^ws:/, "http:")
|
|
.replace(/^wss:/, "https:");
|
|
const cookies = await mainWindow.webContents.session.cookies.get({
|
|
url: cookieUrl,
|
|
name: "jwt",
|
|
});
|
|
const jwt = cookies[0]?.value;
|
|
if (!jwt) {
|
|
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
|
}
|
|
|
|
headers.Cookie = `jwt=${encodeURIComponent(jwt)}`;
|
|
return headers;
|
|
}
|
|
|
|
const attachC2SAuth = require("./c2s-auth.cjs").createC2SAuthBridge(
|
|
ipcMain,
|
|
() => mainWindow,
|
|
);
|
|
|
|
function getC2STunnelName(tunnel, index = 0) {
|
|
if (tunnel.name) return tunnel.name;
|
|
const localAddress = getC2SLocalAddress(tunnel);
|
|
const remoteAddress = getC2SRemoteAddress(tunnel);
|
|
return [
|
|
"c2s",
|
|
index,
|
|
tunnel.sourceHostId || 0,
|
|
tunnel.mode || tunnel.tunnelType || "local",
|
|
localAddress,
|
|
remoteAddress,
|
|
tunnel.sourcePort,
|
|
tunnel.endpointPort || 0,
|
|
].join("::");
|
|
}
|
|
|
|
function getC2STunnelStatus(tunnelName) {
|
|
return (
|
|
c2sTunnelRuntimes.get(tunnelName)?.status || {
|
|
connected: false,
|
|
status: "DISCONNECTED",
|
|
}
|
|
);
|
|
}
|
|
|
|
function getAllC2STunnelStatuses() {
|
|
const statuses = {};
|
|
for (const [tunnelName] of c2sTunnelRuntimes.entries()) {
|
|
statuses[tunnelName] = getC2STunnelStatus(tunnelName);
|
|
}
|
|
return statuses;
|
|
}
|
|
|
|
function emitC2STunnelStatuses() {
|
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
mainWindow.webContents.send("c2s-tunnel-statuses", getAllC2STunnelStatuses());
|
|
}
|
|
|
|
function setC2STunnelStatus(tunnelName, status) {
|
|
const runtime = c2sTunnelRuntimes.get(tunnelName);
|
|
if (runtime) {
|
|
runtime.status = status;
|
|
emitC2STunnelStatuses();
|
|
}
|
|
}
|
|
|
|
function setC2STunnelError(tunnelName, message) {
|
|
logToFile(`[c2s] ${tunnelName} failed:`, message);
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: "ERROR",
|
|
reason: message,
|
|
});
|
|
}
|
|
|
|
function parseSocks5Target(buffer) {
|
|
if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) {
|
|
return null;
|
|
}
|
|
|
|
const addressType = buffer[3];
|
|
let offset = 4;
|
|
let host;
|
|
|
|
if (addressType === 0x01) {
|
|
if (buffer.length < offset + 4 + 2) return null;
|
|
host = Array.from(buffer.subarray(offset, offset + 4)).join(".");
|
|
offset += 4;
|
|
} else if (addressType === 0x03) {
|
|
const length = buffer[offset];
|
|
offset += 1;
|
|
if (buffer.length < offset + length + 2) return null;
|
|
host = buffer.subarray(offset, offset + length).toString("utf8");
|
|
offset += length;
|
|
} else if (addressType === 0x04) {
|
|
if (buffer.length < offset + 16 + 2) return null;
|
|
const parts = [];
|
|
for (let i = 0; i < 16; i += 2) {
|
|
parts.push(buffer.readUInt16BE(offset + i).toString(16));
|
|
}
|
|
host = parts.join(":");
|
|
offset += 16;
|
|
} else {
|
|
throw new Error("Unsupported SOCKS5 address type");
|
|
}
|
|
|
|
const port = buffer.readUInt16BE(offset);
|
|
return { host, port, bytesRead: offset + 2 };
|
|
}
|
|
|
|
async function openC2SRelay(
|
|
tunnel,
|
|
targetHost,
|
|
targetPort,
|
|
socket,
|
|
initialData,
|
|
authToken,
|
|
) {
|
|
const tunnelConfig = normalizeC2STunnelAddresses(tunnel);
|
|
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
|
|
const relayUrl = getC2SRelayUrl();
|
|
const headers = await getC2SRelayHeaders(relayUrl, authToken);
|
|
logToFile(`[c2s] opening relay for ${tunnelName}`, {
|
|
relayUrl,
|
|
targetHost,
|
|
targetPort,
|
|
});
|
|
const ws = new WebSocket(
|
|
relayUrl,
|
|
getWebSocketOptions(relayUrl, { headers }),
|
|
);
|
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
|
keepC2SSocketAlive(ws);
|
|
const pendingChunks = [];
|
|
let ready = false;
|
|
let closed = false;
|
|
|
|
const cleanup = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
try {
|
|
socket.destroy();
|
|
} catch {
|
|
// expected during shutdown
|
|
}
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// expected during shutdown
|
|
}
|
|
};
|
|
|
|
const sendChunk = (chunk) => {
|
|
if (ready && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(chunk);
|
|
} else {
|
|
pendingChunks.push(chunk);
|
|
}
|
|
};
|
|
|
|
socket.on("data", sendChunk);
|
|
socket.on("close", cleanup);
|
|
// One connection failing must not stop the tunnel: the listener refuses
|
|
// new connections while the tunnel is not connected, and a browser opens
|
|
// many at once.
|
|
socket.on("error", (error) => {
|
|
logToFile(`[c2s] ${tunnelName} local socket error:`, error.message);
|
|
cleanup();
|
|
});
|
|
ws.on("close", cleanup);
|
|
ws.on("error", (error) => {
|
|
logToFile(`[c2s] ${tunnelName} relay error:`, error.message);
|
|
cleanup();
|
|
});
|
|
|
|
ws.on("open", () => {
|
|
logToFile(`[c2s] relay connected for ${tunnelName}`);
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "open",
|
|
tunnelConfig,
|
|
targetHost,
|
|
targetPort,
|
|
}),
|
|
);
|
|
});
|
|
|
|
ws.on("message", (data, isBinary) => {
|
|
if (isBinary) {
|
|
socket.write(Buffer.isBuffer(data) ? data : Buffer.from(data));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const message = JSON.parse(data.toString());
|
|
if (message.type === "ready") {
|
|
ready = true;
|
|
logToFile(`[c2s] relay ready for ${tunnelName}`);
|
|
if (initialData?.length) {
|
|
ws.send(initialData);
|
|
}
|
|
while (pendingChunks.length > 0) {
|
|
ws.send(pendingChunks.shift());
|
|
}
|
|
} else if (message.type === "error") {
|
|
const relayError = normalizeC2SErrorMessage(
|
|
message.error,
|
|
"Relay rejected the client tunnel",
|
|
);
|
|
logToFile("[c2s] relay error:", relayError);
|
|
if (relayError === C2S_REMOTE_SESSION_EXPIRED_ERROR) {
|
|
setC2STunnelError(tunnelName, relayError);
|
|
}
|
|
cleanup();
|
|
}
|
|
} catch (error) {
|
|
logToFile("[c2s] invalid relay message:", error.message);
|
|
cleanup();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function testC2SRelay(
|
|
tunnel,
|
|
targetHost,
|
|
targetPort,
|
|
authToken,
|
|
runtime,
|
|
) {
|
|
const tunnelConfig = normalizeC2STunnelAddresses(tunnel);
|
|
const relayUrl = getC2SRelayUrl();
|
|
const headers = await getC2SRelayHeaders(relayUrl, authToken);
|
|
if (runtime && c2sTunnelRuntimes.get(tunnel.name) !== runtime) {
|
|
return { success: false, error: "Tunnel stopped" };
|
|
}
|
|
const ws = new WebSocket(
|
|
relayUrl,
|
|
getWebSocketOptions(relayUrl, { headers }),
|
|
);
|
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
|
keepC2SSocketAlive(ws);
|
|
|
|
if (runtime) runtime.authSocket = ws;
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
// The relay sends its reason and then closes; keep the reason.
|
|
let relayError = null;
|
|
const settle = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try {
|
|
if (!runtime || !result.success) ws.close();
|
|
} catch {
|
|
// expected during shutdown
|
|
}
|
|
resolve(result);
|
|
};
|
|
|
|
const timer = setTimeout(() => {
|
|
settle({ success: false, error: "Tunnel test timed out" });
|
|
}, 180_000);
|
|
|
|
ws.on("open", () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "test",
|
|
keepAlive: !!runtime,
|
|
tunnelConfig,
|
|
targetHost,
|
|
targetPort,
|
|
}),
|
|
);
|
|
});
|
|
ws.on("message", (data, isBinary) => {
|
|
if (isBinary) return;
|
|
|
|
try {
|
|
const message = JSON.parse(data.toString());
|
|
if (message.type === "ready") {
|
|
clearTimeout(timer);
|
|
settle({ success: true });
|
|
} else if (message.type === "error") {
|
|
clearTimeout(timer);
|
|
relayError = normalizeC2SErrorMessage(
|
|
message.error,
|
|
"Tunnel test failed",
|
|
);
|
|
settle(createC2SFailure(message.error, "Tunnel test failed"));
|
|
}
|
|
} catch (error) {
|
|
clearTimeout(timer);
|
|
settle(createC2SFailure(error.message, "Tunnel test failed"));
|
|
}
|
|
});
|
|
ws.on("error", (error) => {
|
|
clearTimeout(timer);
|
|
settle(createC2SFailure(error.message, "Tunnel test failed"));
|
|
});
|
|
ws.on("close", () => {
|
|
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
|
|
setC2STunnelError(
|
|
tunnel.name,
|
|
relayError || "Endpoint SSH connection closed",
|
|
);
|
|
}
|
|
clearTimeout(timer);
|
|
settle({ success: false, error: "Tunnel test connection closed" });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function testC2STunnel(tunnel, index = 0, authToken) {
|
|
const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel);
|
|
const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local";
|
|
const testTunnel = {
|
|
...normalizeC2STunnelAddresses(resolvedTunnel),
|
|
name: `${getC2STunnelName(resolvedTunnel, index)}::test`,
|
|
mode,
|
|
};
|
|
const localAddress = getC2SLocalAddress(testTunnel);
|
|
const remoteAddress = getC2SRemoteAddress(testTunnel);
|
|
const sourcePort = Number(resolvedTunnel.sourcePort);
|
|
const endpointPort = Number(resolvedTunnel.endpointPort);
|
|
|
|
if (!resolvedTunnel.sourceHostId) {
|
|
return { success: false, error: "Endpoint SSH host is required" };
|
|
}
|
|
|
|
if (mode === "remote") {
|
|
const localTarget = await checkTcpConnection(localAddress, endpointPort);
|
|
if (!localTarget.success) {
|
|
return {
|
|
success: false,
|
|
error: `Local target ${localAddress}:${endpointPort} is not reachable: ${localTarget.error}`,
|
|
};
|
|
}
|
|
|
|
return testC2SRelay(testTunnel, undefined, undefined, authToken);
|
|
}
|
|
|
|
if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) {
|
|
return { success: false, error: "Invalid local port" };
|
|
}
|
|
|
|
const runtime = c2sTunnelRuntimes.get(
|
|
getC2STunnelName(resolvedTunnel, index),
|
|
);
|
|
if (!runtime) {
|
|
const availability = await checkLocalPortAvailable(
|
|
localAddress,
|
|
sourcePort,
|
|
);
|
|
if (!availability.available) {
|
|
return {
|
|
success: false,
|
|
error: `Local listener ${localAddress}:${sourcePort} is not available: ${availability.error}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (mode === "dynamic") {
|
|
return testC2SRelay(testTunnel, undefined, undefined, authToken);
|
|
}
|
|
|
|
if (!Number.isInteger(endpointPort) || endpointPort < 1) {
|
|
return { success: false, error: "Invalid remote port" };
|
|
}
|
|
|
|
return testC2SRelay(testTunnel, remoteAddress, endpointPort, authToken);
|
|
}
|
|
|
|
function handleC2SDynamicConnection(tunnel, socket, authToken) {
|
|
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
|
|
let buffer = Buffer.alloc(0);
|
|
let stage = "greeting";
|
|
|
|
const fail = (code = 0x01, message = "SOCKS5 request failed") => {
|
|
setC2STunnelError(tunnelName, message);
|
|
if (!socket.destroyed) {
|
|
socket.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
|
socket.destroy();
|
|
}
|
|
};
|
|
|
|
const onData = (chunk) => {
|
|
buffer = Buffer.concat([buffer, chunk]);
|
|
|
|
try {
|
|
if (stage === "greeting") {
|
|
if (buffer.length < 2) return;
|
|
if (buffer[0] !== 0x05) {
|
|
fail(0x01, "Invalid SOCKS5 greeting");
|
|
return;
|
|
}
|
|
const methodsLength = buffer[1];
|
|
if (buffer.length < 2 + methodsLength) return;
|
|
socket.write(Buffer.from([0x05, 0x00]));
|
|
buffer = buffer.subarray(2 + methodsLength);
|
|
stage = "connect";
|
|
}
|
|
|
|
if (stage === "connect") {
|
|
const target = parseSocks5Target(buffer);
|
|
if (!target) return;
|
|
|
|
stage = "piping";
|
|
socket.off("data", onData);
|
|
const remainder = buffer.subarray(target.bytesRead);
|
|
socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
|
openC2SRelay(
|
|
tunnel,
|
|
target.host,
|
|
target.port,
|
|
socket,
|
|
remainder,
|
|
authToken,
|
|
).catch((error) => {
|
|
const message = normalizeC2SErrorMessage(
|
|
error.message,
|
|
"Dynamic relay failed",
|
|
);
|
|
logToFile("[c2s] dynamic relay failed:", message);
|
|
fail(0x05, message);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logToFile("[c2s] SOCKS5 parse failed:", error.message);
|
|
fail(0x01, error.message || "SOCKS5 parse failed");
|
|
}
|
|
};
|
|
|
|
socket.on("data", onData);
|
|
socket.on("error", () => socket.destroy());
|
|
}
|
|
|
|
function handleC2SLocalConnection(tunnel, socket, authToken) {
|
|
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
|
|
const targetHost = getC2SRemoteAddress(tunnel);
|
|
const targetPort = Number(tunnel.endpointPort);
|
|
openC2SRelay(
|
|
tunnel,
|
|
targetHost,
|
|
targetPort,
|
|
socket,
|
|
undefined,
|
|
authToken,
|
|
).catch((error) => {
|
|
const message = normalizeC2SErrorMessage(
|
|
error.message,
|
|
"Local relay failed",
|
|
);
|
|
logToFile("[c2s] local relay failed:", message);
|
|
if (message === C2S_REMOTE_SESSION_EXPIRED_ERROR) {
|
|
setC2STunnelError(tunnelName, message);
|
|
}
|
|
socket.destroy();
|
|
});
|
|
}
|
|
|
|
function pauseSourceForC2SWebSocket(ws, source) {
|
|
if (!source?.pause || !source?.resume) return;
|
|
if (ws.bufferedAmount <= C2S_WS_HIGH_WATERMARK) return;
|
|
|
|
source.pause();
|
|
const resumeTimer = setInterval(() => {
|
|
if (
|
|
ws.readyState !== WebSocket.OPEN ||
|
|
source.destroyed ||
|
|
ws.bufferedAmount <= C2S_WS_LOW_WATERMARK
|
|
) {
|
|
clearInterval(resumeTimer);
|
|
if (ws.readyState === WebSocket.OPEN && !source.destroyed) {
|
|
source.resume();
|
|
}
|
|
}
|
|
}, 25);
|
|
}
|
|
|
|
function sendC2SRemoteMessage(ws, message, source) {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify(message), (error) => {
|
|
if (error && source?.destroy) {
|
|
source.destroy(error);
|
|
}
|
|
});
|
|
pauseSourceForC2SWebSocket(ws, source);
|
|
}
|
|
}
|
|
|
|
function writeC2SRemoteChunk(target, chunk, ws, closeTarget) {
|
|
if (!target || target.destroyed) return;
|
|
|
|
if (target.writableLength > C2S_STREAM_WRITE_LIMIT) {
|
|
closeTarget();
|
|
return;
|
|
}
|
|
|
|
const canContinue = target.write(chunk);
|
|
if (!canContinue && typeof ws.pause === "function") {
|
|
ws.pause();
|
|
target.once("drain", () => {
|
|
if (ws.readyState === WebSocket.OPEN && typeof ws.resume === "function") {
|
|
ws.resume();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async function startC2SRemoteTunnel(tunnel, index = 0, authToken) {
|
|
const tunnelName = getC2STunnelName(tunnel, index);
|
|
const tunnelConfig = normalizeC2STunnelAddresses(tunnel);
|
|
const localHost = getC2SLocalAddress(tunnelConfig);
|
|
const remoteHost = getC2SRemoteAddress(tunnelConfig);
|
|
const localPort = Number(tunnel.endpointPort);
|
|
const remotePort = Number(tunnel.sourcePort);
|
|
|
|
if (!tunnel.sourceHostId) {
|
|
return { success: false, error: "Endpoint SSH host is required" };
|
|
}
|
|
if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65535) {
|
|
return { success: false, error: "Invalid remote port" };
|
|
}
|
|
if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
|
|
return { success: false, error: "Invalid local port" };
|
|
}
|
|
|
|
const localTarget = await checkTcpConnection(localHost, localPort);
|
|
if (!localTarget.success) {
|
|
return {
|
|
success: false,
|
|
error: `Local target ${localHost}:${localPort} is not reachable: ${localTarget.error}`,
|
|
};
|
|
}
|
|
|
|
const existing = c2sTunnelRuntimes.get(tunnelName);
|
|
if (existing) {
|
|
return { success: true, tunnelName };
|
|
}
|
|
|
|
for (const runtime of c2sTunnelRuntimes.values()) {
|
|
if (
|
|
runtime.mode === "remote" &&
|
|
runtime.sourceHostId === Number(tunnel.sourceHostId) &&
|
|
runtime.remoteAddress === remoteHost &&
|
|
runtime.sourcePort === remotePort
|
|
) {
|
|
return {
|
|
success: false,
|
|
error: `Another client remote tunnel already uses ${remoteHost}:${remotePort} on this endpoint`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const relayUrl = getC2SRelayUrl();
|
|
const headers = await getC2SRelayHeaders(relayUrl, authToken);
|
|
const ws = new WebSocket(
|
|
relayUrl,
|
|
getWebSocketOptions(relayUrl, { headers }),
|
|
);
|
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
|
keepC2SSocketAlive(ws);
|
|
const sockets = new Map();
|
|
let closed = false;
|
|
|
|
const cleanup = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
for (const socket of sockets.values()) {
|
|
socket.destroy();
|
|
}
|
|
sockets.clear();
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// expected during shutdown
|
|
}
|
|
};
|
|
|
|
c2sTunnelRuntimes.set(tunnelName, {
|
|
ws,
|
|
sockets,
|
|
mode: "remote",
|
|
sourceHostId: Number(tunnel.sourceHostId),
|
|
sourcePort: remotePort,
|
|
bindHost: localHost,
|
|
remoteAddress: remoteHost,
|
|
status: { connected: false, status: "CONNECTING" },
|
|
close: cleanup,
|
|
});
|
|
emitC2STunnelStatuses();
|
|
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const settle = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(result);
|
|
};
|
|
|
|
ws.on("open", () => {
|
|
logToFile(`[c2s] opening remote tunnel ${tunnelName}`, {
|
|
relayUrl,
|
|
remoteHost,
|
|
remotePort,
|
|
localHost,
|
|
localPort,
|
|
});
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "open",
|
|
tunnelConfig: { ...tunnelConfig, name: tunnelName, mode: "remote" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
ws.on("message", (data, isBinary) => {
|
|
if (isBinary) return;
|
|
|
|
let message;
|
|
try {
|
|
message = JSON.parse(data.toString());
|
|
} catch (error) {
|
|
const message = normalizeC2SErrorMessage(
|
|
error.message,
|
|
"Invalid relay message",
|
|
);
|
|
setC2STunnelError(tunnelName, message);
|
|
cleanup();
|
|
settle(createC2SFailure(message, "Invalid relay message"));
|
|
return;
|
|
}
|
|
|
|
if (message.type === "ready") {
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: true,
|
|
status: "CONNECTED",
|
|
});
|
|
settle({ success: true, tunnelName });
|
|
return;
|
|
}
|
|
|
|
if (message.type === "error") {
|
|
const error = normalizeC2SErrorMessage(
|
|
message.error,
|
|
"Relay rejected the client tunnel",
|
|
);
|
|
setC2STunnelError(tunnelName, error);
|
|
cleanup();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
settle(createC2SFailure(error, "Relay rejected the client tunnel"));
|
|
return;
|
|
}
|
|
|
|
if (message.type === "connection" && message.streamId) {
|
|
const socket = net.createConnection(
|
|
{ host: localHost, port: localPort },
|
|
() => {
|
|
logToFile(`[c2s] remote stream ${message.streamId} connected`, {
|
|
tunnelName,
|
|
localHost,
|
|
localPort,
|
|
});
|
|
},
|
|
);
|
|
sockets.set(message.streamId, socket);
|
|
socket.on("data", (chunk) => {
|
|
sendC2SRemoteMessage(
|
|
ws,
|
|
{
|
|
type: "data",
|
|
streamId: message.streamId,
|
|
data: chunk.toString("base64"),
|
|
},
|
|
socket,
|
|
);
|
|
});
|
|
socket.on("close", () => {
|
|
sockets.delete(message.streamId);
|
|
sendC2SRemoteMessage(ws, {
|
|
type: "close",
|
|
streamId: message.streamId,
|
|
});
|
|
});
|
|
socket.on("error", (error) => {
|
|
logToFile(`[c2s] remote stream ${message.streamId} failed:`, {
|
|
tunnelName,
|
|
error: error.message,
|
|
});
|
|
sockets.delete(message.streamId);
|
|
sendC2SRemoteMessage(ws, {
|
|
type: "close",
|
|
streamId: message.streamId,
|
|
error: error.message,
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (message.type === "data" && message.streamId && message.data) {
|
|
const socket = sockets.get(message.streamId);
|
|
writeC2SRemoteChunk(
|
|
socket,
|
|
Buffer.from(message.data, "base64"),
|
|
ws,
|
|
() => {
|
|
if (socket) {
|
|
sockets.delete(message.streamId);
|
|
socket.destroy();
|
|
}
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (message.type === "close" && message.streamId) {
|
|
const socket = sockets.get(message.streamId);
|
|
if (socket) {
|
|
sockets.delete(message.streamId);
|
|
socket.destroy();
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on("close", () => {
|
|
cleanup();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
settle({ success: false, error: "Remote tunnel relay closed" });
|
|
});
|
|
|
|
ws.on("error", (error) => {
|
|
const message = normalizeC2SErrorMessage(
|
|
error.message,
|
|
"Relay connection failed",
|
|
);
|
|
setC2STunnelError(tunnelName, message);
|
|
cleanup();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
settle(createC2SFailure(message, "Relay connection failed"));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function startC2STunnel(tunnel, index = 0, authToken) {
|
|
const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel);
|
|
const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local";
|
|
const tunnelName = getC2STunnelName(resolvedTunnel, index);
|
|
const tunnelConfig = {
|
|
...normalizeC2STunnelAddresses(resolvedTunnel),
|
|
sessionId: crypto.randomUUID(),
|
|
};
|
|
const bindHost = getC2SLocalAddress(tunnelConfig);
|
|
const sourcePort = Number(resolvedTunnel.sourcePort);
|
|
logToFile(`[c2s] starting tunnel ${tunnelName}`, {
|
|
mode,
|
|
bindHost,
|
|
remoteAddress: getC2SRemoteAddress(tunnelConfig),
|
|
sourcePort,
|
|
sourceHostId: resolvedTunnel.sourceHostId,
|
|
sourceHostSyncId: resolvedTunnel.sourceHostSyncId,
|
|
endpointPort: resolvedTunnel.endpointPort,
|
|
});
|
|
|
|
if (mode === "remote") {
|
|
return startC2SRemoteTunnel(resolvedTunnel, index, authToken);
|
|
}
|
|
if (!resolvedTunnel.sourceHostId) {
|
|
return { success: false, error: "Endpoint SSH host is required" };
|
|
}
|
|
if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) {
|
|
return { success: false, error: "Invalid local port" };
|
|
}
|
|
|
|
const existing = c2sTunnelRuntimes.get(tunnelName);
|
|
if (existing) {
|
|
return { success: true, tunnelName };
|
|
}
|
|
|
|
for (const runtime of c2sTunnelRuntimes.values()) {
|
|
if (
|
|
runtime.mode !== "remote" &&
|
|
runtime.bindHost === bindHost &&
|
|
runtime.sourcePort === sourcePort
|
|
) {
|
|
return {
|
|
success: false,
|
|
error: `Another client tunnel already uses ${bindHost}:${sourcePort}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const availability = await checkLocalPortAvailable(bindHost, sourcePort);
|
|
if (!availability.available) {
|
|
return {
|
|
success: false,
|
|
error: availability.error || "Port is already in use",
|
|
};
|
|
}
|
|
|
|
const sockets = new Set();
|
|
const server = net.createServer((socket) => {
|
|
if (!c2sTunnelRuntimes.get(tunnelName)?.status.connected) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
sockets.add(socket);
|
|
socket.on("close", () => sockets.delete(socket));
|
|
if (mode === "dynamic") {
|
|
handleC2SDynamicConnection(
|
|
{ ...tunnelConfig, name: tunnelName, mode },
|
|
socket,
|
|
authToken,
|
|
);
|
|
} else {
|
|
handleC2SLocalConnection(
|
|
{ ...tunnelConfig, name: tunnelName, mode },
|
|
socket,
|
|
authToken,
|
|
);
|
|
}
|
|
});
|
|
|
|
c2sTunnelRuntimes.set(tunnelName, {
|
|
server,
|
|
sockets,
|
|
bindHost,
|
|
sourcePort,
|
|
status: { connected: false, status: "CONNECTING" },
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
server.once("error", (error) => {
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
logToFile(`[c2s] failed to listen for ${tunnelName}:`, error.message);
|
|
emitC2STunnelStatuses();
|
|
resolve({ success: false, error: error.message });
|
|
});
|
|
server.listen({ host: bindHost, port: sourcePort }, () => {
|
|
logToFile(
|
|
`[c2s] listening for ${tunnelName} on ${bindHost}:${sourcePort}`,
|
|
);
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: "CONNECTING",
|
|
reason: "Verifying endpoint SSH connection",
|
|
});
|
|
|
|
const runtime = c2sTunnelRuntimes.get(tunnelName);
|
|
const verifyTunnel = testC2SRelay(
|
|
{ ...tunnelConfig, name: tunnelName, mode },
|
|
mode === "dynamic" ? undefined : getC2SRemoteAddress(tunnelConfig),
|
|
mode === "dynamic" ? undefined : Number(tunnelConfig.endpointPort),
|
|
authToken,
|
|
runtime,
|
|
);
|
|
|
|
verifyTunnel
|
|
.then((result) => {
|
|
if (c2sTunnelRuntimes.get(tunnelName) !== runtime) return;
|
|
if (result.success) {
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: true,
|
|
status: "CONNECTED",
|
|
});
|
|
} else {
|
|
setC2STunnelError(
|
|
tunnelName,
|
|
result.error || "Endpoint SSH connection failed",
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
if (c2sTunnelRuntimes.get(tunnelName) === runtime) {
|
|
setC2STunnelError(
|
|
tunnelName,
|
|
error.message || "Endpoint SSH connection failed",
|
|
);
|
|
}
|
|
});
|
|
|
|
resolve({ success: true, tunnelName });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function stopC2STunnel(tunnelName) {
|
|
const runtime = c2sTunnelRuntimes.get(tunnelName);
|
|
if (!runtime) {
|
|
return { success: true };
|
|
}
|
|
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: "DISCONNECTING",
|
|
});
|
|
runtime.authSocket?.close();
|
|
|
|
return new Promise((resolve) => {
|
|
if (typeof runtime.close === "function") {
|
|
runtime.close();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
resolve({ success: true });
|
|
return;
|
|
}
|
|
|
|
for (const socket of runtime.sockets || []) {
|
|
socket.destroy();
|
|
}
|
|
runtime.server?.close(() => {
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
resolve({ success: true });
|
|
});
|
|
});
|
|
}
|
|
|
|
function stopAllC2STunnels() {
|
|
for (const [tunnelName, runtime] of c2sTunnelRuntimes.entries()) {
|
|
try {
|
|
runtime.authSocket?.close();
|
|
if (typeof runtime.close === "function") {
|
|
runtime.close();
|
|
} else {
|
|
for (const socket of runtime.sockets || []) {
|
|
socket.destroy();
|
|
}
|
|
runtime.server?.close();
|
|
}
|
|
} catch (error) {
|
|
logToFile(`[c2s] failed to stop tunnel ${tunnelName}:`, error.message);
|
|
}
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
}
|
|
emitC2STunnelStatuses();
|
|
}
|
|
|
|
async function startC2SAutoStartTunnels() {
|
|
const configPath = getC2STunnelConfigPath();
|
|
if (!fs.existsSync(configPath)) {
|
|
return { success: true, started: 0, errors: [] };
|
|
}
|
|
|
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
const tunnels = Array.isArray(config) ? config : [];
|
|
const errors = [];
|
|
let started = 0;
|
|
|
|
for (let index = 0; index < tunnels.length; index += 1) {
|
|
const tunnel = tunnels[index];
|
|
if (!tunnel?.autoStart) continue;
|
|
const result = await startC2STunnel(tunnel, index);
|
|
if (result.success) {
|
|
started += 1;
|
|
} else {
|
|
errors.push(result.error || "Failed to start client tunnel");
|
|
}
|
|
}
|
|
|
|
return { success: errors.length === 0, started, errors };
|
|
}
|
|
|
|
ipcMain.handle("check-local-port-available", async (_event, host, port) => {
|
|
const sourcePort = Number(port);
|
|
if (
|
|
!host ||
|
|
!Number.isInteger(sourcePort) ||
|
|
sourcePort < 1 ||
|
|
sourcePort > 65535
|
|
) {
|
|
return { available: false, error: "Invalid local bind address or port" };
|
|
}
|
|
return checkLocalPortAvailable(host, sourcePort);
|
|
});
|
|
|
|
ipcMain.handle("start-c2s-tunnel", async (_event, tunnel, index, authToken) => {
|
|
try {
|
|
return await startC2STunnel(
|
|
tunnel,
|
|
Number(index) || 0,
|
|
normalizeC2SAuthToken(authToken),
|
|
);
|
|
} catch (error) {
|
|
return createC2SFailure(error.message, "Failed to start client tunnel");
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("test-c2s-tunnel", async (_event, tunnel, index, authToken) => {
|
|
try {
|
|
return await testC2STunnel(
|
|
tunnel,
|
|
Number(index) || 0,
|
|
normalizeC2SAuthToken(authToken),
|
|
);
|
|
} catch (error) {
|
|
return createC2SFailure(error.message, "Failed to test client tunnel");
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("stop-c2s-tunnel", async (_event, tunnelName) => {
|
|
try {
|
|
return await stopC2STunnel(tunnelName);
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("get-c2s-tunnel-statuses", () => {
|
|
return getAllC2STunnelStatuses();
|
|
});
|
|
|
|
ipcMain.handle("start-c2s-autostart-tunnels", async () => {
|
|
try {
|
|
return await startC2SAutoStartTunnels();
|
|
} catch (error) {
|
|
return { success: false, started: 0, errors: [error.message] };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("get-c2s-tunnel-preset-default-name", () => {
|
|
const now = new Date();
|
|
const date = now.toISOString().slice(0, 10);
|
|
const platform =
|
|
process.platform === "darwin"
|
|
? "macOS"
|
|
: process.platform === "win32"
|
|
? "Windows"
|
|
: "Linux";
|
|
const release = os.release();
|
|
const computerName = os.hostname();
|
|
return `[${date}] ${computerName} (${platform} ${release})`;
|
|
});
|
|
|
|
function readSetting(key) {
|
|
const settingsPath = path.join(app.getPath("userData"), "settings.json");
|
|
if (!fs.existsSync(settingsPath)) return null;
|
|
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
return settings[key] !== undefined ? settings[key] : null;
|
|
}
|
|
|
|
ipcMain.handle("get-setting", (event, key) => {
|
|
try {
|
|
return readSetting(key);
|
|
} catch (error) {
|
|
console.error("Error reading setting:", error);
|
|
return null;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("set-setting", (event, key, value) => {
|
|
if (typeof key !== "string") {
|
|
return { success: false, error: "Invalid setting key" };
|
|
}
|
|
if (
|
|
key === EXTERNAL_EDITOR_SETTING &&
|
|
value &&
|
|
!dialogChosenPaths.has(value)
|
|
) {
|
|
return { success: false, error: "Choose the editor in the file dialog" };
|
|
}
|
|
try {
|
|
const userDataPath = app.getPath("userData");
|
|
const settingsPath = path.join(userDataPath, "settings.json");
|
|
|
|
if (!fs.existsSync(userDataPath)) {
|
|
fs.mkdirSync(userDataPath, { recursive: true });
|
|
}
|
|
|
|
let settings = {};
|
|
if (fs.existsSync(settingsPath)) {
|
|
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
|
settings = JSON.parse(settingsData);
|
|
}
|
|
|
|
settings[key] = value;
|
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Error saving setting:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("clear-session-cookies", async () => {
|
|
try {
|
|
clearPersistedElectronAuthCookies();
|
|
const ses = mainWindow?.webContents?.session;
|
|
if (ses) {
|
|
const cookies = await ses.cookies.get({});
|
|
for (const cookie of cookies) {
|
|
await ses.cookies.remove(getCookieRemovalUrl(cookie), cookie.name);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to clear session cookies:", error);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("clipboard-write-text", (_event, text) => {
|
|
clipboard.writeText(typeof text === "string" ? text : String(text ?? ""));
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("clipboard-read-text", () => clipboard.readText());
|
|
|
|
ipcMain.handle("local-terminal-start", (event, dimensions = {}) => {
|
|
const cols = Math.min(500, Math.max(2, Number(dimensions.cols) || 80));
|
|
const rows = Math.min(300, Math.max(1, Number(dimensions.rows) || 24));
|
|
const sessionId = crypto.randomUUID();
|
|
const shellConfig = resolveLocalShell(process.platform, dimensions.shell);
|
|
const spawnShell = () =>
|
|
pty.spawn(shellConfig.file, shellConfig.args, {
|
|
name: "xterm-256color",
|
|
cols,
|
|
rows,
|
|
cwd: os.homedir(),
|
|
env: {
|
|
...process.env,
|
|
TERM: "xterm-256color",
|
|
COLORTERM: "truecolor",
|
|
},
|
|
});
|
|
let child;
|
|
try {
|
|
child = spawnShell();
|
|
} catch (error) {
|
|
const ptyDir = path.dirname(require.resolve("node-pty/package.json"));
|
|
if (
|
|
!String(error?.message).includes("posix_spawnp") ||
|
|
repairSpawnHelpers(ptyDir) === 0
|
|
) {
|
|
throw error;
|
|
}
|
|
logToFile("[local-terminal] restored the spawn-helper execute bit");
|
|
child = spawnShell();
|
|
}
|
|
const ownerId = event.sender.id;
|
|
const session = { ownerId, process: child, ready: false, buffered: "" };
|
|
localTerminalSessions.set(sessionId, session);
|
|
child.onData((data) => {
|
|
if (!session.ready) {
|
|
session.buffered = (session.buffered + data).slice(-1024 * 1024);
|
|
return;
|
|
}
|
|
if (!event.sender.isDestroyed()) {
|
|
event.sender.send(`local-terminal:data:${sessionId}`, data);
|
|
}
|
|
});
|
|
child.onExit(({ exitCode }) => {
|
|
localTerminalSessions.delete(sessionId);
|
|
if (!event.sender.isDestroyed()) {
|
|
event.sender.send(`local-terminal:exit:${sessionId}`, exitCode);
|
|
}
|
|
});
|
|
event.sender.once("destroyed", () => closeLocalTerminalsFor(ownerId));
|
|
return { sessionId, shell: shellConfig.file };
|
|
});
|
|
|
|
ipcMain.handle("local-terminal-ready", (event, sessionId) => {
|
|
const session = ownedLocalTerminal(event, sessionId);
|
|
if (!session) return false;
|
|
session.ready = true;
|
|
if (session.buffered && !event.sender.isDestroyed()) {
|
|
event.sender.send(`local-terminal:data:${sessionId}`, session.buffered);
|
|
session.buffered = "";
|
|
}
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("local-terminal-write", (event, sessionId, data) => {
|
|
const session = ownedLocalTerminal(event, sessionId);
|
|
if (!session || typeof data !== "string" || data.length > 64 * 1024) {
|
|
return false;
|
|
}
|
|
session.process.write(data);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("local-terminal-resize", (event, sessionId, cols, rows) => {
|
|
const session = ownedLocalTerminal(event, sessionId);
|
|
const width = Number(cols);
|
|
const height = Number(rows);
|
|
if (
|
|
!session ||
|
|
!Number.isInteger(width) ||
|
|
!Number.isInteger(height) ||
|
|
width < 2 ||
|
|
width > 500 ||
|
|
height < 1 ||
|
|
height > 300
|
|
) {
|
|
return false;
|
|
}
|
|
session.process.resize(width, height);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("local-terminal-close", (event, sessionId) => {
|
|
const session = ownedLocalTerminal(event, sessionId);
|
|
if (!session) return false;
|
|
localTerminalSessions.delete(sessionId);
|
|
session.process.kill();
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("show-save-dialog", async (_event, options) => {
|
|
return dialog.showSaveDialog(mainWindow, options || {});
|
|
});
|
|
|
|
ipcMain.handle("show-open-dialog", async (_event, options) => {
|
|
const result = await dialog.showOpenDialog(mainWindow, options || {});
|
|
for (const filePath of result.filePaths) dialogChosenPaths.add(filePath);
|
|
return result;
|
|
});
|
|
|
|
function toLocalFileType(stat) {
|
|
if (stat.isDirectory()) return "directory";
|
|
if (stat.isFile()) return "file";
|
|
if (stat.isSymbolicLink()) return "link";
|
|
return "other";
|
|
}
|
|
|
|
function getLocalEntry(entryPath, name = path.basename(entryPath)) {
|
|
const stat = fs.lstatSync(entryPath);
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
type: toLocalFileType(stat),
|
|
size: stat.isFile() ? stat.size : 0,
|
|
created: stat.birthtime.toISOString(),
|
|
modified: stat.mtime.toISOString(),
|
|
modifiedTimestamp: stat.mtimeMs,
|
|
hidden: name.startsWith("."),
|
|
permissions: (stat.mode & 0o777).toString(8).padStart(3, "0"),
|
|
owner: String(stat.uid),
|
|
group: String(stat.gid),
|
|
};
|
|
}
|
|
|
|
function normalizeRelativeLocalPath(relativePath) {
|
|
return relativePath.split(path.sep).join("/");
|
|
}
|
|
|
|
function validateLocalName(name) {
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
throw new Error("Missing name");
|
|
}
|
|
const trimmed = name.trim();
|
|
if (
|
|
trimmed.includes("/") ||
|
|
trimmed.includes("\\") ||
|
|
trimmed === "." ||
|
|
trimmed === ".."
|
|
) {
|
|
throw new Error("Name cannot contain path separators");
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function ensureLocalDirectory(dirPath) {
|
|
if (typeof dirPath !== "string" || !dirPath) {
|
|
throw new Error("Missing directory path");
|
|
}
|
|
const stat = fs.statSync(dirPath);
|
|
if (!stat.isDirectory()) {
|
|
throw new Error("Path is not a directory");
|
|
}
|
|
}
|
|
|
|
function collectLocalFilesFromPath(rootPath, rootName, files, limit) {
|
|
if (files.length >= limit) return;
|
|
|
|
const stat = fs.lstatSync(rootPath);
|
|
if (stat.isSymbolicLink()) return;
|
|
|
|
if (stat.isFile()) {
|
|
files.push({
|
|
path: rootPath,
|
|
name: path.basename(rootPath),
|
|
relativePath: normalizeRelativeLocalPath(rootName),
|
|
size: stat.size,
|
|
created: stat.birthtime.toISOString(),
|
|
modified: stat.mtime.toISOString(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!stat.isDirectory()) return;
|
|
|
|
for (const childName of fs.readdirSync(rootPath)) {
|
|
if (files.length >= limit) return;
|
|
const childPath = path.join(rootPath, childName);
|
|
collectLocalFilesFromPath(
|
|
childPath,
|
|
path.join(rootName, childName),
|
|
files,
|
|
limit,
|
|
);
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("get-local-home-directory", () => os.homedir());
|
|
|
|
ipcMain.handle("list-local-directory", (_event, dirPath) => {
|
|
try {
|
|
const requestedPath =
|
|
typeof dirPath === "string" && dirPath.trim() ? dirPath : os.homedir();
|
|
const stat = fs.statSync(requestedPath);
|
|
if (!stat.isDirectory()) {
|
|
return {
|
|
success: false,
|
|
path: requestedPath,
|
|
entries: [],
|
|
error: "Path is not a directory",
|
|
};
|
|
}
|
|
|
|
const entries = fs
|
|
.readdirSync(requestedPath, { withFileTypes: true })
|
|
.map((entry) => {
|
|
try {
|
|
return getLocalEntry(
|
|
path.join(requestedPath, entry.name),
|
|
entry.name,
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => {
|
|
if (a.type === "directory" && b.type !== "directory") return -1;
|
|
if (a.type !== "directory" && b.type === "directory") return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
path: requestedPath,
|
|
parent: path.dirname(requestedPath),
|
|
entries,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
path: typeof dirPath === "string" ? dirPath : "",
|
|
entries: [],
|
|
error: error instanceof Error ? error.message : "Failed to list path",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("stat-local-paths", (_event, paths) => {
|
|
const pathList = Array.isArray(paths) ? paths : [];
|
|
return pathList.map((entryPath) => {
|
|
try {
|
|
return { success: true, ...getLocalEntry(entryPath) };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
path: entryPath,
|
|
error: error instanceof Error ? error.message : "Failed to stat path",
|
|
};
|
|
}
|
|
});
|
|
});
|
|
|
|
ipcMain.handle("collect-local-files", (_event, paths) => {
|
|
try {
|
|
const pathList = Array.isArray(paths) ? paths : [];
|
|
const files = [];
|
|
const limit = 10000;
|
|
|
|
for (const entryPath of pathList) {
|
|
if (typeof entryPath !== "string" || !entryPath) continue;
|
|
collectLocalFilesFromPath(
|
|
entryPath,
|
|
path.basename(entryPath),
|
|
files,
|
|
limit + 1,
|
|
);
|
|
if (files.length > limit) break;
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
files: files.slice(0, limit),
|
|
truncated: files.length > limit,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
files: [],
|
|
error: error instanceof Error ? error.message : "Failed to collect files",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("create-local-folder", (_event, parentPath, folderName) => {
|
|
try {
|
|
ensureLocalDirectory(parentPath);
|
|
const safeName = validateLocalName(folderName);
|
|
const targetPath = path.join(parentPath, safeName);
|
|
fs.mkdirSync(targetPath);
|
|
return { success: true, ...getLocalEntry(targetPath, safeName) };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to create folder",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("rename-local-path", (_event, entryPath, newName) => {
|
|
try {
|
|
if (typeof entryPath !== "string" || !entryPath) {
|
|
throw new Error("Missing path");
|
|
}
|
|
const safeName = validateLocalName(newName);
|
|
const targetPath = path.join(path.dirname(entryPath), safeName);
|
|
renameLocalPath(entryPath, targetPath);
|
|
return { success: true, ...getLocalEntry(targetPath, safeName) };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to rename path",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("trash-local-path", async (_event, entryPath) => {
|
|
try {
|
|
if (typeof entryPath !== "string" || !entryPath) {
|
|
throw new Error("Missing path");
|
|
}
|
|
await shell.trashItem(entryPath);
|
|
return { success: true };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to delete path",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("chmod-local-path", (_event, entryPath, permissions) => {
|
|
try {
|
|
if (typeof entryPath !== "string" || !entryPath) {
|
|
throw new Error("Missing path");
|
|
}
|
|
if (typeof permissions !== "string" || !/^[0-7]{3,4}$/.test(permissions)) {
|
|
throw new Error("Permissions must be an octal value");
|
|
}
|
|
fs.chmodSync(entryPath, parseInt(permissions, 8));
|
|
return { success: true, ...getLocalEntry(entryPath) };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to update permissions",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("create-temp-file", async (_event, fileData) => {
|
|
try {
|
|
const result = createManagedTempFile(
|
|
fileData?.fileName,
|
|
fileData?.content,
|
|
fileData?.encoding,
|
|
);
|
|
return { success: true, ...result };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("create-temp-folder", async (_event, folderData) => {
|
|
try {
|
|
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`));
|
|
const folderPath = path.join(
|
|
tempDir,
|
|
sanitizeFileName(folderData?.folderName || "files"),
|
|
);
|
|
fs.mkdirSync(folderPath, { recursive: true });
|
|
|
|
for (const file of folderData?.files || []) {
|
|
const relativePath = String(file.relativePath || "")
|
|
.split(/[\\/]+/)
|
|
.map(sanitizeFileName)
|
|
.filter(Boolean)
|
|
.join(path.sep);
|
|
if (!relativePath) continue;
|
|
const targetPath = path.join(folderPath, relativePath);
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
targetPath,
|
|
decodeFileContent(file.content, file.encoding),
|
|
);
|
|
}
|
|
|
|
tempFiles.set(tempId, { path: folderPath, dir: tempDir });
|
|
return { success: true, tempId, path: folderPath };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("start-drag-to-desktop", (event, dragData) => {
|
|
try {
|
|
const temp = tempFiles.get(dragData?.tempId);
|
|
if (!temp) return { success: false, error: "Temporary file not found" };
|
|
|
|
event.sender.startDrag({
|
|
file: temp.path,
|
|
icon: nativeImage.createEmpty(),
|
|
});
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("cleanup-temp-file", (_event, tempId) => {
|
|
try {
|
|
cleanupManagedTempFile(tempId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
|
try {
|
|
// The editor comes from the saved setting, never from the caller.
|
|
const editorPath = readSetting(EXTERNAL_EDITOR_SETTING) || null;
|
|
if (!editorPath && isLaunchableFileName(fileData?.fileName)) {
|
|
return {
|
|
success: false,
|
|
error: "Choose an external editor to open this file type",
|
|
};
|
|
}
|
|
|
|
const result = createManagedTempFile(
|
|
fileData?.fileName,
|
|
fileData?.content,
|
|
fileData?.encoding,
|
|
);
|
|
const editId = result.tempId;
|
|
const stat = fs.statSync(result.path);
|
|
const watcher = fs.watch(result.path, { persistent: false }, () => {
|
|
const session = externalEditorSessions.get(editId);
|
|
if (!session) return;
|
|
if (session.timer) clearTimeout(session.timer);
|
|
session.timer = setTimeout(() => notifyExternalEditorSaved(editId), 500);
|
|
});
|
|
|
|
externalEditorSessions.set(editId, {
|
|
path: result.path,
|
|
watcher,
|
|
timer: null,
|
|
lastMtimeMs: stat.mtimeMs,
|
|
});
|
|
|
|
const openError = await openPathWithEditor(result.path, editorPath);
|
|
if (openError) {
|
|
closeExternalEditorSession(editId);
|
|
return { success: false, error: openError };
|
|
}
|
|
|
|
return { success: true, editId, path: result.path };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("close-external-editor", (_event, editId) => {
|
|
try {
|
|
closeExternalEditorSession(editId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
// Local disk browsing + streamed local<->remote transfers for the file
|
|
// manager's dual-pane mode (see electron/local-files.cjs).
|
|
registerLocalFileHandlers({ ipcMain, shell });
|
|
|
|
function createMenu() {
|
|
if (process.platform === "darwin") {
|
|
const template = [
|
|
{
|
|
label: app.name,
|
|
submenu: [
|
|
{ role: "about" },
|
|
{ type: "separator" },
|
|
{ role: "services" },
|
|
{ type: "separator" },
|
|
{ role: "hide" },
|
|
{ role: "hideOthers" },
|
|
{ role: "unhide" },
|
|
{ type: "separator" },
|
|
{ role: "quit" },
|
|
],
|
|
},
|
|
{
|
|
label: "Edit",
|
|
submenu: [
|
|
{ role: "undo" },
|
|
{ role: "redo" },
|
|
{ type: "separator" },
|
|
{ role: "cut" },
|
|
{ role: "copy" },
|
|
{ role: "paste" },
|
|
{ role: "selectAll" },
|
|
],
|
|
},
|
|
{
|
|
label: "View",
|
|
submenu: [
|
|
{ role: "reload" },
|
|
{ role: "forceReload" },
|
|
{ role: "toggleDevTools" },
|
|
{ type: "separator" },
|
|
{ role: "resetZoom" },
|
|
{ role: "zoomIn" },
|
|
{ role: "zoomOut" },
|
|
{ type: "separator" },
|
|
{ role: "togglefullscreen" },
|
|
],
|
|
},
|
|
{
|
|
label: "Window",
|
|
submenu: [
|
|
{ role: "minimize" },
|
|
{ role: "zoom" },
|
|
{ type: "separator" },
|
|
{ role: "front" },
|
|
{ type: "separator" },
|
|
{ role: "window" },
|
|
],
|
|
},
|
|
];
|
|
const menu = Menu.buildFromTemplate(template);
|
|
Menu.setApplicationMenu(menu);
|
|
}
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
logToFile("=== App ready ===");
|
|
logToFile(
|
|
"isDev:",
|
|
isDev,
|
|
"platform:",
|
|
process.platform,
|
|
"arch:",
|
|
process.arch,
|
|
);
|
|
if (process.platform === "win32") {
|
|
app.setAppUserModelId(windowsAppUserModelId);
|
|
}
|
|
createMenu();
|
|
await clearElectronClientCacheIfBuildChanged();
|
|
await clearElectronJwtCookiesAtStartup();
|
|
|
|
if (!isDev) {
|
|
backendPort = await findBackendPort();
|
|
setLocalBackendPort(backendPort);
|
|
const result = await startBackendServer();
|
|
logToFile("startBackendServer result:", result);
|
|
} else {
|
|
logToFile(
|
|
"Skipping embedded backend (isDev=true) - expecting the dev runner's backend",
|
|
);
|
|
if (isDevRunner()) {
|
|
process.on("message", (msg) => void handleBackendRequest(msg));
|
|
}
|
|
}
|
|
|
|
createTray();
|
|
createWindow();
|
|
startAppUpdater();
|
|
desktopSettings.removeOldSyncFiles();
|
|
logToFile("=== Startup complete ===");
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
if (!tray || tray.isDestroyed()) {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
app.on("activate", () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createWindow();
|
|
} else if (mainWindow) {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
});
|
|
|
|
app.on("before-quit", () => {
|
|
isQuitting = true;
|
|
});
|
|
|
|
app.on("will-quit", () => {
|
|
console.log("App will quit...");
|
|
for (const editId of externalEditorSessions.keys()) {
|
|
closeExternalEditorSession(editId);
|
|
}
|
|
for (const tempId of tempFiles.keys()) {
|
|
cleanupManagedTempFile(tempId);
|
|
}
|
|
stopAllC2STunnels();
|
|
stopBackendServer();
|
|
});
|
|
|
|
process.on("uncaughtException", (error) => {
|
|
console.error("Uncaught Exception:", error);
|
|
});
|
|
|
|
process.on("unhandledRejection", (reason, promise) => {
|
|
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
|
});
|