feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

This commit is contained in:
LukeGus committed 2026-09-26 00:41:25 -05:00
1 parent 7c02daccf4
commit 6cd9a2846d
242 files changed
+39127 -8558

No files matched your search

+62 -356
View File
@@ -27,7 +27,8 @@ const { URL, pathToFileURL } = require("url");
const { fork, spawn } = require("child_process");
const pty = require("node-pty");
const WebSocket = require("ws");
const remoteSync = require("./remote-sync.cjs");
const linkedServer = require("./linked-server.cjs");
const desktopSettings = require("./desktop-settings.cjs");
const { launchNativeRdp } = require("./native-rdp.cjs");
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs");
@@ -54,7 +55,7 @@ function closeLocalTerminalsFor(ownerId) {
}
// The main process's Node.js networking (the `https`/`http` modules used by
// httpFetch below, and the global `fetch` used by remote-sync.cjs) only
// 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 --
@@ -432,20 +433,6 @@ function isInsecureModeEnabled() {
);
}
function getServerConfigPath() {
return path.join(app.getPath("userData"), "server-config.json");
}
function getServerConfigSync() {
try {
const configPath = getServerConfigPath();
if (!fs.existsSync(configPath)) return null;
return JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch {
return null;
}
}
function getOrigin(url) {
try {
return new URL(url).origin;
@@ -493,13 +480,11 @@ function isInvalidCertificateAllowedForUrl(url) {
// fall through
}
// The only remaining "connected remote server" a self-signed/invalid
// certificate could legitimately apply to is the Remote Sync server
// (also used for C2S tunnel relaying, see getC2SRelayUrl).
const config = remoteSync.getRemoteSyncConfig();
if (!config?.allowInvalidCertificate || !config?.serverUrl) return false;
return getOrigin(url) === getOrigin(config.serverUrl);
// Otherwise only the server this desktop is linked to, when its link
// allows a self-signed certificate.
const linked = linkedServer.getLinkedServer();
if (!linked?.allowInvalidCertificate) return false;
return getOrigin(url) === linked.origin;
}
function getTlsVerificationOptions(url) {
@@ -646,6 +631,10 @@ const isolatedWindows = createIsolatedWindows({
const BACKEND_REQUEST_HANDLERS = {
"open-isolated-window": (payload) => isolatedWindows.open(payload),
"launch-native-rdp": (payload) => launchNativeRdp(payload),
"sync-proxy-config": (payload) => {
linkedServer.setLinkedServer(payload);
return { success: true };
},
};
async function handleBackendRequest(msg) {
@@ -888,6 +877,16 @@ app.on(
},
);
// 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");
@@ -1420,7 +1419,12 @@ function createWindow() {
);
}
callback({ requestHeaders: details.requestHeaders });
callback({
requestHeaders: linkedServer.applyLinkHeaders(
details.url,
details.requestHeaders,
),
});
},
);
@@ -1564,111 +1568,6 @@ ipcMain.handle("get-app-version", () => {
return app.getVersion();
});
const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = "Termix-SSH";
const REPO_NAME = "Termix";
const githubCache = new Map();
const CACHE_DURATION = 30 * 60 * 1000;
async function fetchGitHubAPI(endpoint, cacheKey) {
const cached = githubCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
return {
data: cached.data,
cached: true,
cache_age: Date.now() - cached.timestamp,
};
}
try {
const response = await httpFetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "TermixElectronUpdateChecker/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout: 10000,
});
if (!response.ok) {
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
githubCache.set(cacheKey, {
data,
timestamp: Date.now(),
});
return {
data: data,
cached: false,
};
} catch (error) {
console.error("Failed to fetch from GitHub API:", error);
throw error;
}
}
ipcMain.handle("check-electron-update", async () => {
try {
const localVersion = app.getVersion();
const releaseData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
"latest_release_electron",
);
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
return {
success: false,
error: "Remote version not found",
localVersion,
};
}
const versionComparison = compareSemver(localVersion, remoteVersion);
const status =
versionComparison === null || versionComparison === 0
? "up_to_date"
: versionComparison > 0
? "beta"
: "requires_update";
const result = {
success: true,
status,
localVersion: localVersion,
remoteVersion: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url,
body: releaseData.data.body,
},
cached: releaseData.cached,
cache_age: releaseData.cache_age,
};
return result;
} catch (error) {
return {
success: false,
error: error.message,
localVersion: app.getVersion(),
};
}
});
ipcMain.handle("get-platform", () => {
return process.platform;
});
@@ -1769,109 +1668,20 @@ ipcMain.handle(
},
);
ipcMain.handle("get-server-config", () => {
try {
return getServerConfigSync();
} catch (error) {
console.error("Error reading server config:", error);
return null;
}
});
ipcMain.handle("save-server-config", (event, config) => {
try {
const userDataPath = app.getPath("userData");
const configPath = getServerConfigPath();
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { success: true };
} catch (error) {
console.error("Error saving server config:", error);
return { success: false, error: error.message };
}
});
// --- Remote sync (optional desktop <-> self-hosted server sync) ---
// Surfaces the pre-standalone-rework server-config.json (if a serverUrl was
// ever set in it) so the renderer can prompt upgraded installs to set up
// Remote Sync -- their hosts live on that old server and won't appear
// locally until sync is enabled. A fresh install never had this file, so
// this is naturally false for anyone who never used the old architecture.
ipcMain.handle("get-legacy-server-config", () => {
const config = getServerConfigSync();
return { serverUrl: config?.serverUrl || null };
});
// Desktop-only settings, such as whether hosts connect from this device or
// through the linked server by default.
ipcMain.handle("get-desktop-settings", () => {
return remoteSync.getDesktopSettings();
return desktopSettings.getDesktopSettings();
});
ipcMain.handle("save-desktop-settings", (_event, settings) => {
return remoteSync.saveDesktopSettings(settings);
return desktopSettings.saveDesktopSettings(settings);
});
ipcMain.handle("get-remote-sync-config", () => {
return remoteSync.getRemoteSyncConfig();
});
ipcMain.handle("save-remote-sync-config", (_event, config) => {
return remoteSync.saveRemoteSyncConfig(config);
});
ipcMain.handle("clear-remote-sync-config", async () => {
const result = remoteSync.clearRemoteSyncConfig();
remoteSync.clearRemoteSyncJwt();
remoteSync.getRemoteSyncEngine()?.updateStatus({
connected: false,
syncing: false,
needsReauth: false,
lastError: null,
});
return result;
});
ipcMain.handle("save-remote-sync-jwt", async (_event, token) => {
const result = remoteSync.saveRemoteSyncJwt(token);
if (result.success) {
remoteSync.getRemoteSyncEngine()?.updateStatus({
connected: true,
needsReauth: false,
lastError: null,
});
const status = await remoteSync.getRemoteSyncEngine()?.syncNow();
return { ...result, status: status || null };
}
return result;
});
ipcMain.handle("get-remote-sync-jwt", () => {
return remoteSync.getRemoteSyncJwt();
});
ipcMain.handle("clear-remote-sync-jwt", () => {
return remoteSync.clearRemoteSyncJwt();
});
ipcMain.handle("get-remote-sync-status", () => {
return remoteSync.getRemoteSyncEngine()?.status || null;
});
ipcMain.handle("get-remote-sync-user-info", () => {
return remoteSync.getRemoteSyncUserInfo();
});
ipcMain.handle("remote-sync-now", async () => {
return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null;
});
ipcMain.handle("notify-local-login", (_event, token) => {
remoteSync.getRemoteSyncEngine()?.setLocalJwt(token);
return { success: true };
// 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() {
@@ -2040,40 +1850,25 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
const C2S_WS_LOW_WATERMARK = 256 * 1024;
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
// C2S (client-to-server) tunnels relay through a connected, self-hosted
// Termix server -- the same "remote server" concept Remote Sync connects
// to, not the always-local embedded backend. There's no separate C2S
// server-URL setting in the UI; it has always shared whatever remote
// server the rest of the app was pointed at. Before the standalone-first
// rework that was server-config.json; now it's remote-sync-config.json,
// since that's the only remaining notion of "a connected remote server."
function getC2SRelayUrl() {
const config = remoteSync.getRemoteSyncConfig();
const serverUrl = config?.serverUrl;
if (!serverUrl) {
throw new Error(
"No remote Termix server connected -- enable Remote Sync first",
);
}
const base = serverUrl.replace(/\/$/, "");
const relayHttpUrl = `${base}/plugin-ws/tunnels/c2s/stream`;
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
}
// C2S (client-to-server) tunnels relay through the server this desktop is
// linked to, not the embedded backend.
function getC2SRemoteBaseUrl() {
const config = remoteSync.getRemoteSyncConfig();
const serverUrl = config?.serverUrl;
const serverUrl = linkedServer.getLinkedServer()?.serverUrl;
if (!serverUrl) {
throw new Error(
"No remote Termix server connected -- enable Remote Sync first",
"This device is not linked to a Termix server. Link it in Sync first.",
);
}
return serverUrl.replace(/\/$/, "");
}
function getC2SRelayUrl() {
const relayHttpUrl = `${getC2SRemoteBaseUrl()}/plugin-ws/tunnels/c2s/stream`;
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
}
const C2S_REMOTE_SESSION_EXPIRED_ERROR =
"Remote Termix session expired. Reconnect Remote Sync and try again.";
"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() : "";
@@ -2111,17 +1906,13 @@ function createC2SFailure(message, fallback) {
}
async function fetchC2SRemoteJson(pathname) {
const remoteSyncJwt = remoteSync.getRemoteSyncJwt();
if (!remoteSyncJwt || remoteSync.isJwtExpiredOrExpiringSoon(remoteSyncJwt)) {
if (!linkedServer.getLinkedServer()?.token) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
}
const url = `${getC2SRemoteBaseUrl()}${pathname}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${remoteSyncJwt}`,
"X-Electron-App": "true",
},
const response = await httpFetch(url, {
headers: linkedServer.linkedRequestHeaders(),
});
if (response.status === 401 || response.status === 403) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
@@ -2140,14 +1931,18 @@ async function resolveC2SRemoteSourceHost(tunnel) {
: "";
if (!sourceHostSyncId) return normalized;
const data = await fetchC2SRemoteJson("/sync/hosts");
const remoteHost = (Array.isArray(data?.rows) ? data.rows : []).find(
(row) => row?.syncId === sourceHostSyncId,
);
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(
"Intermediate Host is not available on the remote Termix server yet. Run Remote Sync for hosts, then try again.",
"The intermediate host is not on the server yet. Sync, then try again.",
);
}
@@ -2161,18 +1956,11 @@ async function resolveC2SRemoteSourceHost(tunnel) {
}
async function getC2SRelayHeaders(relayUrl) {
if (linkedServer.getLinkedServer()?.token) {
return linkedServer.linkedRequestHeaders();
}
const headers = { "X-Electron-App": "true" };
const remoteSyncJwt = remoteSync.getRemoteSyncJwt();
if (remoteSyncJwt && !remoteSync.isJwtExpiredOrExpiringSoon(remoteSyncJwt)) {
headers.Authorization = `Bearer ${remoteSyncJwt}`;
return headers;
}
if (remoteSyncJwt) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
}
if (!mainWindow?.webContents?.session) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
}
@@ -3779,88 +3567,6 @@ ipcMain.handle("close-external-editor", (_event, editId) => {
}
});
async function testServerConnection(
_event,
serverUrl,
allowInvalidCertificate = false,
) {
try {
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
const healthUrl = `${normalizedServerUrl}/health`;
// This is a best-effort reachability probe, not a hard gate: a reverse
// proxy doing SSO in front of the real server (Pangolin, Authelia,
// Cloudflare Access, etc.) intercepts this unauthenticated request
// before it ever reaches Termix's own /health route, and returns its
// own login page (HTML, or a redirect) instead of {"status":"ok"}.
// That's a legitimate, working setup -- the login iframe shown right
// after this check is what actually proves the server is real, by
// completing an authenticated round-trip. So any response at all here
// (any status code, any body) means "something is there, let the user
// proceed"; only a network-level failure (nothing answered at all)
// blocks continuing.
try {
const response = await httpFetch(healthUrl, {
method: "GET",
timeout: 10000,
allowInvalidCertificate,
});
const data = await response.text();
const looksLikeHtml =
data.includes("<html") ||
data.includes("<!DOCTYPE") ||
data.includes("<head>") ||
data.includes("<body>");
if (response.ok && !looksLikeHtml) {
try {
const healthData = JSON.parse(data);
if (
healthData &&
(healthData.status === "ok" ||
healthData.status === "healthy" ||
healthData.healthy === true ||
healthData.database === "connected")
) {
return {
success: true,
status: response.status,
testedUrl: healthUrl,
};
}
} catch (parseError) {
console.log("Health endpoint did not return valid JSON");
}
}
// Reachable, but not a recognized Termix health response -- likely a
// proxy/SSO login page in front of the real server. Let the user
// proceed; the login step next will fail clearly if this really
// isn't a Termix server.
return {
success: true,
status: response.status,
testedUrl: healthUrl,
warning: looksLikeHtml
? "Could not confirm this is a Termix server (the response looked like an HTML page, which can happen behind a login-protected reverse proxy). You can continue, and the next step will fail clearly if this isn't actually a Termix server."
: "Server responded, but not with the expected health check format. Continuing anyway.",
};
} catch (urlError) {
console.error("Health check failed:", urlError);
return {
success: false,
error:
"Server is not responding. Please ensure the server is running and accessible.",
};
}
} catch (error) {
return { success: false, error: error.message };
}
}
ipcMain.handle("test-server-connection", testServerConnection);
// Local disk browsing + streamed local<->remote transfers for the file
// manager's dual-pane mode (see electron/local-files.cjs).
registerLocalFileHandlers({ ipcMain, shell });
@@ -3953,7 +3659,7 @@ app.whenReady().then(async () => {
createTray();
createWindow();
remoteSync.initRemoteSync(() => mainWindow);
desktopSettings.removeOldSyncFiles();
logToFile("=== Startup complete ===");
});