From 08825c256da5fb55933ad85437e083461f72121b Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 21 Jul 2026 01:27:03 -0500 Subject: [PATCH] feat: rework Electron desktop app to run standalone-first with optional two-way sync to a remote Termix server --- docker/nginx-https.conf | 9 + docker/nginx.conf | 9 + electron/main.cjs | 68 ++- electron/preload.js | 7 + electron/remote-sync.cjs | 473 ++++++++++++++++++ scripts/patch-nan.cjs | 43 +- scripts/patch-xterm-android-ime.cjs | 4 +- src/backend/database/database.ts | 2 + src/backend/database/db/index.ts | 115 ++++- src/backend/database/db/schema.ts | 38 ++ .../repositories/credential-repository.ts | 18 +- .../dashboard-service-link-repository.ts | 20 +- src/backend/database/repositories/factory.ts | 8 + .../repositories/homepage-item-repository.ts | 17 +- .../repositories/host-folder-repository.ts | 18 +- .../database/repositories/host-repository.ts | 18 +- .../repositories/snippet-repository.ts | 15 +- .../repositories/sync-tombstone-repository.ts | 70 +++ .../repositories/vault-profile-repository.ts | 14 +- .../database/routes/acme-ssl-routes.ts | 9 +- src/backend/database/routes/credentials.ts | 8 + .../routes/dashboard-service-links-routes.ts | 21 +- .../database/routes/homepage-items-routes.ts | 14 +- .../database/routes/host-folder-routes.ts | 13 +- src/backend/database/routes/host.ts | 18 + src/backend/database/routes/snippets.ts | 21 +- src/backend/database/routes/sync.ts | 415 +++++++++++++++ src/backend/database/routes/users.ts | 96 ++++ src/backend/database/routes/vault.ts | 10 +- src/backend/starter.ts | 58 +++ .../dashboard-service-link-repository.test.ts | 10 +- .../homepage-item-repository.test.ts | 7 +- .../host-credential-repositories.test.ts | 17 +- .../host-folder-repository.test.ts | 4 + .../host-resolution-repository.test.ts | 4 + .../repositories/snippet-repository.test.ts | 2 + .../sync-tombstone-repository.test.ts | 138 +++++ .../user-data-export-repository.test.ts | 3 + .../vault-profile-repository.test.ts | 5 +- .../tests/database/routes/sync.test.ts | 65 +++ src/backend/tests/utils/analytics.test.ts | 28 +- src/backend/utils/analytics.ts | 12 +- src/main.tsx | 51 +- src/types/electron.d.ts | 10 +- src/types/ui-types.ts | 1 + src/ui/AppShell.tsx | 363 +++++++------- src/ui/api/host-metrics-api.ts | 9 + src/ui/api/host-metrics-status-api.ts | 50 +- src/ui/api/session-sharing-api.ts | 13 +- src/ui/api/ssh-file-operations-api.ts | 203 ++++---- src/ui/api/tunnel-api.ts | 78 ++- src/ui/auth/Auth.tsx | 209 ++------ src/ui/auth/ElectronLoginForm.tsx | 15 +- src/ui/auth/ElectronServerConfig.tsx | 4 +- src/ui/auth/LoginPage.tsx | 45 +- src/ui/components/RemoteSyncBanner.tsx | 50 ++ src/ui/dashboard/DashboardTab.tsx | 9 +- .../docker/components/ConsoleTerminal.tsx | 44 +- src/ui/features/guacamole/GuacamoleApp.tsx | 35 +- .../features/guacamole/GuacamoleDisplay.tsx | 42 +- src/ui/features/serial/Serial.tsx | 27 +- .../session-sharing/SharedSessionView.tsx | 15 +- src/ui/features/terminal/Terminal.tsx | 64 +-- src/ui/lib/connection-origin.ts | 112 +++++ src/ui/locales/en.json | 39 +- src/ui/main-axios.ts | 282 +++++++---- src/ui/settings/RemoteSyncPanel.tsx | 269 ++++++++++ src/ui/settings/RemoteSyncServerPicker.tsx | 259 ++++++++++ src/ui/shell/tabUtils.tsx | 1 + src/ui/sidebar/AdminSettingsPanel.tsx | 5 +- src/ui/sidebar/AppRail.tsx | 39 +- src/ui/sidebar/HostEditorData.ts | 5 + src/ui/sidebar/HostEditorGeneralTab.tsx | 27 +- src/ui/sidebar/HostEditorGuacamoleTabs.tsx | 8 +- src/ui/sidebar/SerialPanel.tsx | 26 +- src/ui/sidebar/UserProfilePanel.tsx | 146 +++--- src/ui/tests/lib/connection-origin.test.ts | 88 ++++ 77 files changed, 3658 insertions(+), 929 deletions(-) create mode 100644 electron/remote-sync.cjs create mode 100644 src/backend/database/repositories/sync-tombstone-repository.ts create mode 100644 src/backend/database/routes/sync.ts create mode 100644 src/backend/tests/database/repositories/sync-tombstone-repository.test.ts create mode 100644 src/backend/tests/database/routes/sync.test.ts create mode 100644 src/ui/components/RemoteSyncBanner.tsx create mode 100644 src/ui/lib/connection-origin.ts create mode 100644 src/ui/settings/RemoteSyncPanel.tsx create mode 100644 src/ui/settings/RemoteSyncServerPicker.tsx create mode 100644 src/ui/tests/lib/connection-origin.test.ts diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 2825d86c..ce22530d 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -226,6 +226,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/sync(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + location ~ ^/termix-id(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index 77d6374d..235218b9 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -215,6 +215,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/sync(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + location ~ ^/termix-id(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/electron/main.cjs b/electron/main.cjs index f6e8e03e..106f1174 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -20,6 +20,7 @@ const net = require("net"); const { URL } = require("url"); const { fork, spawn } = require("child_process"); const WebSocket = require("ws"); +const remoteSync = require("./remote-sync.cjs"); // Portable mode: if a `.portable` marker exists next to the executable, // store all data in a `data` folder beside the exe instead of %APPDATA%. @@ -852,6 +853,7 @@ function startBackendServer() { NODE_ENV: "production", ELECTRON_EMBEDDED: "true", PORT: "30001", + VERSION: app.getVersion(), }, stdio: ["pipe", "pipe", "pipe", "ipc"], }); @@ -1335,7 +1337,6 @@ ipcMain.handle("get-embedded-server-status", () => { return { running: backendProcess !== null && !backendProcess.killed && !backendStartFailed, - embedded: !isDev, dataDir: isDev ? null : getBackendDataDir(), }; }); @@ -1442,6 +1443,70 @@ ipcMain.handle("save-server-config", (event, config) => { } }); +// --- Remote sync (optional desktop <-> self-hosted server sync) --- + +ipcMain.handle("get-desktop-settings", () => { + return remoteSync.getDesktopSettings(); +}); + +ipcMain.handle("save-desktop-settings", (_event, settings) => { + return remoteSync.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", (_event, token) => { + const result = remoteSync.saveRemoteSyncJwt(token); + if (result.success) { + remoteSync.getRemoteSyncEngine()?.updateStatus({ + connected: true, + needsReauth: false, + lastError: null, + }); + remoteSync.getRemoteSyncEngine()?.syncNow(); + } + 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("remote-sync-now", async () => { + return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null; +}); + +ipcMain.handle("notify-local-login", (_event, token) => { + remoteSync.getRemoteSyncEngine()?.setLocalJwt(token); + return { success: true }; +}); + function getC2STunnelConfigPath() { return path.join(app.getPath("userData"), "c2s-tunnels.json"); } @@ -2974,6 +3039,7 @@ app.whenReady().then(async () => { createTray(); createWindow(); + remoteSync.initRemoteSync(() => mainWindow); logToFile("=== Startup complete ==="); }); diff --git a/electron/preload.js b/electron/preload.js index 6b9cf1c0..43d84eff 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -31,6 +31,13 @@ contextBridge.exposeInMainWorld("electronAPI", { startC2SAutoStartTunnels: () => ipcRenderer.invoke("start-c2s-autostart-tunnels"), + onRemoteSyncStatusChanged: (callback) => { + const listener = (_event, status) => callback(status); + ipcRenderer.on("remote-sync-status-changed", listener); + return () => + ipcRenderer.removeListener("remote-sync-status-changed", listener); + }, + clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"), getSessionCookie: (name, targetUrl) => ipcRenderer.invoke("get-session-cookie", name, targetUrl), diff --git a/electron/remote-sync.cjs b/electron/remote-sync.cjs new file mode 100644 index 00000000..8e8f2e74 --- /dev/null +++ b/electron/remote-sync.cjs @@ -0,0 +1,473 @@ +// Remote sync engine for the desktop app's optional connection to a +// self-hosted Termix server. Runs entirely in the Electron main process: +// - Holds the remote JWT (safeStorage-encrypted on disk, never exposed to +// the renderer's localStorage) and the local embedded backend's JWT +// (cached in memory only, handed over by the renderer at local-login +// time via notify-local-login). +// - On a timer, pulls + pushes each synced entity type between the +// embedded backend (always localhost:30001) and the configured remote +// server, reconciling by syncId with last-write-wins on updatedAt, and +// propagating tombstones (deletions) in both directions. +// - Pushes connection/sync status to the renderer via IPC so the Settings +// UI and a global banner can reflect it without polling. + +const { app, safeStorage } = require("electron"); +const fs = require("fs"); +const path = require("path"); + +const SYNCED_ENTITY_TYPES = [ + "hosts", + "sshCredentials", + "sshFolders", + "snippets", + "snippetFolders", + "vaultProfiles", + "dashboardServiceLinks", + "homepageItems", +]; + +const SYNC_INTERVAL_MS = 90 * 1000; +const EMBEDDED_BASE_URL = "http://127.0.0.1:30001"; + +function dataPath(filename) { + return path.join(app.getPath("userData"), filename); +} + +function readJson(filePath, fallback) { + try { + if (!fs.existsSync(filePath)) return fallback; + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return fallback; + } +} + +function writeJson(filePath, value) { + const userDataPath = app.getPath("userData"); + if (!fs.existsSync(userDataPath)) { + fs.mkdirSync(userDataPath, { recursive: true }); + } + fs.writeFileSync(filePath, JSON.stringify(value, null, 2)); +} + +function getDesktopSettingsPath() { + return dataPath("desktop-settings.json"); +} + +function getRemoteSyncConfigPath() { + return dataPath("remote-sync-config.json"); +} + +function getRemoteSyncCredentialPath() { + return dataPath("remote-sync-credential.json"); +} + +function getRemoteSyncStatePath() { + return dataPath("remote-sync-state.json"); +} + +function getDesktopSettings() { + return readJson(getDesktopSettingsPath(), { + defaultConnectionOrigin: "local", + }); +} + +function saveDesktopSettings(settings) { + writeJson(getDesktopSettingsPath(), settings); + return { success: true }; +} + +function getRemoteSyncConfig() { + return readJson(getRemoteSyncConfigPath(), null); +} + +function saveRemoteSyncConfig(config) { + writeJson(getRemoteSyncConfigPath(), config); + return { success: true }; +} + +function clearRemoteSyncConfig() { + try { + fs.unlinkSync(getRemoteSyncConfigPath()); + } catch { + // already absent + } + return { success: true }; +} + +function getSafeStorageAvailable() { + try { + return safeStorage.isEncryptionAvailable(); + } catch { + return false; + } +} + +function saveRemoteSyncJwt(token) { + if (!getSafeStorageAvailable()) { + return { success: false, error: "Encryption unavailable on this system" }; + } + writeJson(getRemoteSyncCredentialPath(), { + encrypted: true, + value: safeStorage.encryptString(token).toString("base64"), + obtainedAt: new Date().toISOString(), + }); + return { success: true }; +} + +function getRemoteSyncJwt() { + const record = readJson(getRemoteSyncCredentialPath(), null); + if (!record?.encrypted || !getSafeStorageAvailable()) return null; + try { + return safeStorage.decryptString(Buffer.from(record.value, "base64")); + } catch { + return null; + } +} + +function clearRemoteSyncJwt() { + try { + fs.unlinkSync(getRemoteSyncCredentialPath()); + } catch { + // already absent + } + return { success: true }; +} + +function decodeJwtExpiry(token) { + try { + const payloadB64 = token.split(".")[1]; + const payload = JSON.parse( + Buffer.from(payloadB64, "base64").toString("utf8"), + ); + return typeof payload.exp === "number" ? payload.exp * 1000 : null; + } catch { + return null; + } +} + +function isJwtExpiredOrExpiringSoon(token, marginMs = 60 * 1000) { + const expiresAt = decodeJwtExpiry(token); + if (expiresAt === null) return false; + return Date.now() + marginMs >= expiresAt; +} + +class RemoteSyncEngine { + constructor(getMainWindow) { + this.getMainWindow = getMainWindow; + this.localJwt = null; + this.timer = null; + this.syncing = false; + this.status = { + connected: false, + syncing: false, + lastSyncedAt: null, + lastError: null, + needsReauth: false, + }; + } + + setLocalJwt(token) { + this.localJwt = token || null; + } + + emitStatus() { + const win = this.getMainWindow?.(); + if (!win || win.isDestroyed()) return; + win.webContents.send("remote-sync-status-changed", this.status); + } + + updateStatus(patch) { + this.status = { ...this.status, ...patch }; + this.emitStatus(); + } + + start() { + const config = getRemoteSyncConfig(); + this.status.connected = !!config?.serverUrl; + if (this.timer) clearInterval(this.timer); + this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS); + if (config?.serverUrl) { + // Fire an initial sync shortly after startup rather than waiting a + // full interval, but don't block app boot on it. + setTimeout(() => this.syncNow(), 5000); + } + } + + stop() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + async syncNow() { + if (this.syncing) return this.status; + const config = getRemoteSyncConfig(); + if (!config?.serverUrl) { + this.updateStatus({ connected: false, syncing: false }); + return this.status; + } + + const remoteJwt = getRemoteSyncJwt(); + if (!remoteJwt) { + this.updateStatus({ + connected: true, + syncing: false, + needsReauth: true, + lastError: "Not signed in to remote server", + }); + return this.status; + } + if (isJwtExpiredOrExpiringSoon(remoteJwt)) { + this.updateStatus({ + connected: true, + syncing: false, + needsReauth: true, + lastError: "Remote session expired", + }); + return this.status; + } + if (!this.localJwt) { + // Local login hasn't handed us a token yet (e.g. very early after + // boot) -- skip this tick rather than fail loudly. + return this.status; + } + + this.syncing = true; + this.updateStatus({ connected: true, syncing: true, lastError: null }); + + try { + const state = readJson(getRemoteSyncStatePath(), { entities: {} }); + let sawAuthFailure = false; + + for (const entityType of SYNCED_ENTITY_TYPES) { + const entityState = state.entities[entityType] || { + lastPulledAt: null, + lastPushedAt: null, + }; + + const result = await this.syncEntity({ + entityType, + remoteBaseUrl: config.serverUrl.replace(/\/$/, ""), + remoteJwt, + since: entityState.lastPulledAt, + }); + + if (result.authFailure) { + sawAuthFailure = true; + break; + } + + state.entities[entityType] = { + lastPulledAt: result.syncedAt, + lastPushedAt: result.syncedAt, + }; + } + + if (sawAuthFailure) { + this.updateStatus({ + syncing: false, + needsReauth: true, + lastError: "Remote server rejected the session", + }); + return this.status; + } + + writeJson(getRemoteSyncStatePath(), state); + writeJson(getRemoteSyncConfigPath(), { + ...config, + lastSyncedAt: new Date().toISOString(), + lastSyncStatus: "ok", + lastSyncError: null, + }); + + this.updateStatus({ + connected: true, + syncing: false, + needsReauth: false, + lastSyncedAt: new Date().toISOString(), + lastError: null, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeJson(getRemoteSyncConfigPath(), { + ...config, + lastSyncStatus: "error", + lastSyncError: message, + }); + this.updateStatus({ syncing: false, lastError: message }); + } finally { + this.syncing = false; + } + + return this.status; + } + + async fetchJson(url, token, options = {}) { + const res = await fetch(url, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...(options.headers || {}), + }, + }); + if (res.status === 401 || res.status === 403) { + const err = new Error(`Auth failed (${res.status})`); + err.authFailure = true; + throw err; + } + if (!res.ok) { + throw new Error(`Request failed (${res.status}): ${url}`); + } + return res.json(); + } + + async pullSide(baseUrl, token, entityType, since) { + const url = `${baseUrl}/sync/${entityType}${since ? `?since=${encodeURIComponent(since)}` : ""}`; + const data = await this.fetchJson(url, token); + return data.rows || []; + } + + async pullTombstones(baseUrl, token, entityType, since) { + const url = `${baseUrl}/sync/${entityType}/tombstones${since ? `?since=${encodeURIComponent(since)}` : ""}`; + const data = await this.fetchJson(url, token); + return data.tombstones || []; + } + + async pushRow(baseUrl, token, entityType, row) { + await this.fetchJson(`${baseUrl}/sync/${entityType}`, token, { + method: "POST", + body: JSON.stringify({ row }), + }); + } + + async pushTombstone(baseUrl, token, entityType, syncId) { + await this.fetchJson(`${baseUrl}/sync/tombstones`, token, { + method: "POST", + body: JSON.stringify({ entityType, syncId }), + }); + } + + async syncEntity({ entityType, remoteBaseUrl, remoteJwt, since }) { + const syncedAt = new Date().toISOString(); + try { + const [localRows, remoteRows, localTombstones, remoteTombstones] = + await Promise.all([ + this.pullSide(EMBEDDED_BASE_URL, this.localJwt, entityType, since), + this.pullSide(remoteBaseUrl, remoteJwt, entityType, since), + this.pullTombstones( + EMBEDDED_BASE_URL, + this.localJwt, + entityType, + since, + ), + this.pullTombstones(remoteBaseUrl, remoteJwt, entityType, since), + ]); + + const tombstonedSyncIds = new Set([ + ...localTombstones.map((t) => t.syncId), + ...remoteTombstones.map((t) => t.syncId), + ]); + + const localBySyncId = new Map( + localRows.filter((r) => r.syncId).map((r) => [r.syncId, r]), + ); + const remoteBySyncId = new Map( + remoteRows.filter((r) => r.syncId).map((r) => [r.syncId, r]), + ); + const allSyncIds = new Set([ + ...localBySyncId.keys(), + ...remoteBySyncId.keys(), + ]); + + for (const syncId of allSyncIds) { + if (tombstonedSyncIds.has(syncId)) continue; + + const localRow = localBySyncId.get(syncId); + const remoteRow = remoteBySyncId.get(syncId); + + if (localRow && !remoteRow) { + await this.pushRow(remoteBaseUrl, remoteJwt, entityType, localRow); + } else if (remoteRow && !localRow) { + await this.pushRow( + EMBEDDED_BASE_URL, + this.localJwt, + entityType, + remoteRow, + ); + } else if (localRow && remoteRow) { + const localUpdatedAt = new Date(localRow.updatedAt || 0).getTime(); + const remoteUpdatedAt = new Date(remoteRow.updatedAt || 0).getTime(); + if (localUpdatedAt > remoteUpdatedAt) { + await this.pushRow(remoteBaseUrl, remoteJwt, entityType, localRow); + } else if (remoteUpdatedAt > localUpdatedAt) { + await this.pushRow( + EMBEDDED_BASE_URL, + this.localJwt, + entityType, + remoteRow, + ); + } + } + } + + // Apply tombstones to whichever side hasn't already deleted the row. + for (const tombstone of localTombstones) { + if (remoteBySyncId.has(tombstone.syncId)) { + await this.pushTombstone( + remoteBaseUrl, + remoteJwt, + entityType, + tombstone.syncId, + ); + } + } + for (const tombstone of remoteTombstones) { + if (localBySyncId.has(tombstone.syncId)) { + await this.pushTombstone( + EMBEDDED_BASE_URL, + this.localJwt, + entityType, + tombstone.syncId, + ); + } + } + + return { syncedAt }; + } catch (error) { + if (error?.authFailure) { + return { syncedAt, authFailure: true }; + } + throw error; + } + } +} + +let engine = null; + +function initRemoteSync(getMainWindow) { + engine = new RemoteSyncEngine(getMainWindow); + engine.start(); + return engine; +} + +function getRemoteSyncEngine() { + return engine; +} + +module.exports = { + initRemoteSync, + getRemoteSyncEngine, + getDesktopSettings, + saveDesktopSettings, + getRemoteSyncConfig, + saveRemoteSyncConfig, + clearRemoteSyncConfig, + saveRemoteSyncJwt, + getRemoteSyncJwt, + clearRemoteSyncJwt, + isJwtExpiredOrExpiringSoon, + decodeJwtExpiry, +}; diff --git a/scripts/patch-nan.cjs b/scripts/patch-nan.cjs index 5fec17f9..43aad385 100644 --- a/scripts/patch-nan.cjs +++ b/scripts/patch-nan.cjs @@ -39,6 +39,19 @@ const nanHeaderPatched = patchFile(path.join(nanDir, "nan.h"), [ # define __builtin_frame_address(level) _AddressOfReturnAddress() #endif +// v8::External::New()/->Value() gained a mandatory ExternalPointerTypeTag +// argument in V8 15 (Electron 43+). Plain Node (V8 <= 13.x as of Node 24) +// still uses the old 2-arg signatures, so this must be conditional rather +// than assumed - a build can target either header set. +#include +#if defined(V8_MAJOR_VERSION) && V8_MAJOR_VERSION >= 15 +# define NAN_EXTERNAL_TAG_ARG , static_cast(0) +# define NAN_EXTERNAL_TAG_PARAM static_cast(0) +#else +# define NAN_EXTERNAL_TAG_ARG +# define NAN_EXTERNAL_TAG_PARAM +#endif + #define NODE_0_10_MODULE_VERSION 11`, }, ]); @@ -63,23 +76,24 @@ const bindingPatched = patchFile(bindingPath, [ }, ]); -// 2. nan_implementation_12_inl.h: replace v8::External::New() with the 3-arg form. -// Electron 42 / V8 13+ requires an ExternalPointerTypeTag as the third argument. +// 2. nan_implementation_12_inl.h: replace v8::External::New() with a form that +// passes NAN_EXTERNAL_TAG_ARG - a macro (defined in the nan.h patch above) +// that expands to the ExternalPointerTypeTag argument only when the target +// V8 headers actually declare it (V8 15+ / Electron 43+). const implPath = path.join(nanDir, "nan_implementation_12_inl.h"); let implPatched = false; if (fs.existsSync(implPath)) { let src = fs.readFileSync(implPath, "utf8"); const before = src; - const TAG = "static_cast(0)"; - if (!src.includes(TAG)) { + if (!src.includes("NAN_EXTERNAL_TAG_ARG")) { src = src.replace( - /v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value\)/g, - `v8::External::New(v8::Isolate::GetCurrent(), value, ${TAG})`, + /v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value(?:,\s*static_cast\(0\))?\)/g, + `v8::External::New(v8::Isolate::GetCurrent(), value NAN_EXTERNAL_TAG_ARG)`, ); src = src.replace( - /v8::External::New\(isolate,\s*reinterpret_cast\(callback\)\)/g, - `v8::External::New(isolate, reinterpret_cast(callback), ${TAG})`, + /v8::External::New\(isolate,\s*reinterpret_cast\(callback\)(?:,\s*static_cast\(0\))?\)/g, + `v8::External::New(isolate, reinterpret_cast(callback) NAN_EXTERNAL_TAG_ARG)`, ); } @@ -89,20 +103,19 @@ if (fs.existsSync(implPath)) { } } -// 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(tag) on v8::External. -// The new API requires an ExternalPointerTypeTag argument. +// 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(NAN_EXTERNAL_TAG_PARAM) +// on v8::External, same conditional-tag reasoning as above. const callbacksPath = path.join(nanDir, "nan_callbacks_12_inl.h"); let callbacksPatched = false; if (fs.existsSync(callbacksPath)) { let src = fs.readFileSync(callbacksPath, "utf8"); const before = src; - const TAG = "static_cast(0)"; - if (!src.includes(TAG)) { - // Pattern: .As()->Value()) — always followed by )) + if (!src.includes("NAN_EXTERNAL_TAG_PARAM")) { + // Pattern: .As()->Value()) or ->Value()) src = src.replace( - /\.As\(\)->Value\(\)\)/g, - `.As()->Value(${TAG}))`, + /\.As\(\)->Value\((?:static_cast\(0\))?\)\)/g, + `.As()->Value(NAN_EXTERNAL_TAG_PARAM))`, ); } diff --git a/scripts/patch-xterm-android-ime.cjs b/scripts/patch-xterm-android-ime.cjs index 60660a99..4bbff4a3 100644 --- a/scripts/patch-xterm-android-ime.cjs +++ b/scripts/patch-xterm-android-ime.cjs @@ -47,7 +47,7 @@ const patches = [ ], [ '_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}},0)}', + "_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}},0)}", ], ], }, @@ -72,7 +72,7 @@ const patches = [ ], [ '_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}}),0)}', + "_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}}),0)}", ], ], }, diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index c60b204d..28e5d051 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -23,6 +23,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js"; import { registerTailscaleRoutes } from "./routes/tailscale-routes.js"; import vaultRoutes from "./routes/vault.js"; import alertRulesRoutes from "./routes/alert-rules-routes.js"; +import syncRoutes from "./routes/sync.js"; import { createCorsMiddleware } from "../utils/cors-config.js"; import fs from "fs"; import path from "path"; @@ -1749,6 +1750,7 @@ registerAuditLogRoutes(app, authenticateJWT); registerTailscaleRoutes(app, authenticateJWT); app.use("/vault", vaultRoutes); app.use("/", alertRulesRoutes); +app.use("/sync", syncRoutes); const frontendDistPaths = [ path.join(__dirname, "../../../dist"), diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 979c05dc..3b253d99 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -726,12 +726,16 @@ const addColumnIfNotExists = ( sqlite.exec(`ALTER TABLE ${table} ADD COLUMN "${column}" ${definition};`); } catch (alterError) { - databaseLogger.warn(`Failed to add column ${column} to ${table}`, { - operation: "schema_migration", - table, - column, - error: alterError, - }); + const message = + alterError instanceof Error ? alterError.message : String(alterError); + databaseLogger.warn( + `Failed to add column ${column} to ${table}: ${message}`, + { + operation: "schema_migration", + table, + column, + }, + ); } } }; @@ -1489,6 +1493,7 @@ const migrateSchema = () => { { column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" }, { column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" }, { column: "allow_session_sharing", sql: "ALTER TABLE ssh_data ADD COLUMN allow_session_sharing INTEGER NOT NULL DEFAULT 1" }, + { column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" }, ]; for (const migration of sshDataMigrations) { @@ -2393,6 +2398,104 @@ const migrateSchema = () => { } } + // --- sync begin --- + // Stable per-row identity used to match rows across two independently- + // seeded databases (the embedded desktop backend and a connected remote + // server) during sync. Local autoincrement ids collide across instances, + // so a randomly-generated id is the join key instead. SQLite refuses a + // non-constant DEFAULT (e.g. randomblob()) on ALTER TABLE ADD COLUMN for + // tables with existing constraints ("Cannot add a column with + // non-constant default"), so the column is added as plain nullable TEXT; + // repositories set syncId explicitly on insert going forward, and + // existing rows are backfilled by the UPDATE loop below. + addColumnIfNotExists("ssh_data", "sync_id", "TEXT"); + addColumnIfNotExists("ssh_credentials", "sync_id", "TEXT"); + addColumnIfNotExists("ssh_folders", "sync_id", "TEXT"); + addColumnIfNotExists("snippets", "sync_id", "TEXT"); + addColumnIfNotExists("snippet_folders", "sync_id", "TEXT"); + addColumnIfNotExists("vault_profiles", "sync_id", "TEXT"); + addColumnIfNotExists("dashboard_service_links", "sync_id", "TEXT"); + // SQLite also rejects NOT NULL DEFAULT CURRENT_TIMESTAMP here for the same + // "non-constant default" reason -- add nullable, then backfill from + // created_at below and rely on the repository layer to keep it current. + addColumnIfNotExists("dashboard_service_links", "updated_at", "TEXT"); + try { + sqlite.exec( + "UPDATE dashboard_service_links SET updated_at = created_at WHERE updated_at IS NULL", + ); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + databaseLogger.warn( + `Failed to backfill dashboard_service_links.updated_at: ${message}`, + { operation: "schema_migration", table: "dashboard_service_links" }, + ); + } + addColumnIfNotExists("homepage_items", "sync_id", "TEXT"); + + const syncIdTables = [ + "ssh_data", + "ssh_credentials", + "ssh_folders", + "snippets", + "snippet_folders", + "vault_profiles", + "dashboard_service_links", + "homepage_items", + ]; + + for (const table of syncIdTables) { + try { + const result = sqlite + .prepare( + `UPDATE ${table} SET sync_id = lower(hex(randomblob(16))) WHERE sync_id IS NULL`, + ) + .run(); + if (result.changes > 0) { + databaseLogger.info( + `Backfilled sync_id for ${result.changes} row(s) in ${table}`, + { operation: "sync_id_backfill", table }, + ); + } + sqlite.exec( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_${table}_sync_id ON ${table}(sync_id)`, + ); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + databaseLogger.warn( + `Failed to backfill sync_id for ${table}: ${message}`, + { + operation: "sync_id_backfill", + table, + }, + ); + } + } + + try { + sqlite.prepare("SELECT id FROM sync_tombstones LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS sync_tombstones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entity_type TEXT NOT NULL, + sync_id TEXT NOT NULL, + deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + sqlite.exec( + "CREATE INDEX IF NOT EXISTS idx_sync_tombstones_user_entity ON sync_tombstones(user_id, entity_type)", + ); + } catch (createError) { + databaseLogger.warn("Failed to create sync_tombstones table", { + operation: "schema_migration", + error: createError, + }); + } + } + // --- sync end --- + databaseLogger.success("Schema migration completed", { operation: "schema_migration", }); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index b22e975b..4dcfb72b 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -240,6 +240,12 @@ export const hosts = sqliteTable("ssh_data", { socks5Password: text("socks5_password"), socks5ProxyChain: text("socks5_proxy_chain"), + // null = use the desktop app's global default; "local" | "remote" pins + // this specific host's SSH/Docker-console/Serial connections to originate + // from the embedded local backend or a connected remote sync server. + // Ignored for rdp/vnc/telnet, which always require the remote server. + connectionOrigin: text("connection_origin"), + macAddress: text("mac_address"), wolBroadcastAddress: text("wol_broadcast_address"), portKnockSequence: text("port_knock_sequence"), @@ -251,6 +257,11 @@ export const hosts = sqliteTable("ssh_data", { hostKeyLastVerified: text("host_key_last_verified"), hostKeyChangedCount: integer("host_key_changed_count").default(0), + // Stable identity used to match this row across two independently-seeded + // databases (the embedded backend and a connected remote server) during + // sync -- local autoincrement ids collide across instances. + syncId: text("sync_id").unique(), + createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -357,6 +368,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", { usageCount: integer("usage_count").notNull().default(0), lastUsed: text("last_used"), + syncId: text("sync_id").unique(), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -391,6 +403,7 @@ export const snippets = sqliteTable("snippets", { description: text("description"), folder: text("folder"), order: integer("order").notNull().default(0), + syncId: text("sync_id").unique(), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -408,6 +421,7 @@ export const snippetFolders = sqliteTable("snippet_folders", { name: text("name").notNull(), color: text("color"), icon: text("icon"), + syncId: text("sync_id").unique(), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -468,6 +482,7 @@ export const sshFolders = sqliteTable("ssh_folders", { credentialId: integer("credential_id").references(() => sshCredentials.id, { onDelete: "set null", }), + syncId: text("sync_id").unique(), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -786,6 +801,7 @@ export const vaultProfiles = sqliteTable("vault_profiles", { keyType: text("key_type"), // When true the profile is visible/usable by all users on the server shared: integer("shared", { mode: "boolean" }).notNull().default(false), + syncId: text("sync_id").unique(), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -942,9 +958,13 @@ export const dashboardServiceLinks = sqliteTable("dashboard_service_links", { label: text("label").notNull(), url: text("url").notNull(), order: integer("order").notNull().default(0), + syncId: text("sync_id").unique(), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), }); // --- termix-id begin --- @@ -1130,6 +1150,7 @@ export const homepageItems = sqliteTable("homepage_items", { title: text("title"), config: text("config").notNull().default("{}"), folderId: integer("folder_id"), + syncId: text("sync_id").unique(), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -1151,3 +1172,20 @@ export const homepageLayouts = sqliteTable("homepage_layouts", { .default(sql`CURRENT_TIMESTAMP`), }); // --- homepage end --- + +// --- sync begin --- +// Records a delete for a synced entity type so the other side of a sync +// pair (embedded desktop backend <-> connected remote server) learns about +// the deletion instead of re-creating the row on its next pull. +export const syncTombstones = sqliteTable("sync_tombstones", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + syncId: text("sync_id").notNull(), + deletedAt: text("deleted_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// --- sync end --- diff --git a/src/backend/database/repositories/credential-repository.ts b/src/backend/database/repositories/credential-repository.ts index 82d0af36..ee4850af 100644 --- a/src/backend/database/repositories/credential-repository.ts +++ b/src/backend/database/repositories/credential-repository.ts @@ -1,4 +1,5 @@ import { and, desc, eq, sql } from "drizzle-orm"; +import { randomUUID } from "crypto"; import { sshCredentials, sshCredentialUsage } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { DataCrypto } from "../../utils/data-crypto.js"; @@ -18,7 +19,7 @@ export class CredentialRepository { async create(credential: NewCredentialRecord): Promise { const rows = await this.context.drizzle .insert(sshCredentials) - .values(credential) + .values({ syncId: randomUUID(), ...credential }) .returning(); await this.afterWrite(); return rows[0]; @@ -30,7 +31,11 @@ export class CredentialRepository { ): Promise { const userDataKey = DataCrypto.validateUserAccess(userId); const tempId = credential.id ?? Date.now(); - const dataWithTempId = { ...credential, id: tempId }; + const dataWithTempId = { + syncId: randomUUID(), + ...credential, + id: tempId, + }; const encryptedCredential = this.encryptCredentialRecordForWrite( dataWithTempId, userId, @@ -203,7 +208,10 @@ export class CredentialRepository { return this.decryptOne(rows[0] ?? null, userId); } - async deleteForUser(userId: string, credentialId: number): Promise { + async deleteForUser( + userId: string, + credentialId: number, + ): Promise<{ syncId: string | null } | null> { const rows = await this.context.drizzle .delete(sshCredentials) .where( @@ -212,10 +220,10 @@ export class CredentialRepository { eq(sshCredentials.userId, userId), ), ) - .returning({ id: sshCredentials.id }); + .returning({ syncId: sshCredentials.syncId }); await this.afterWrite(); - return rows.length > 0; + return rows[0] ?? null; } async deleteByUserId(userId: string): Promise { diff --git a/src/backend/database/repositories/dashboard-service-link-repository.ts b/src/backend/database/repositories/dashboard-service-link-repository.ts index a12079e0..b06c2100 100644 --- a/src/backend/database/repositories/dashboard-service-link-repository.ts +++ b/src/backend/database/repositories/dashboard-service-link-repository.ts @@ -1,4 +1,5 @@ import { and, asc, eq } from "drizzle-orm"; +import { randomUUID } from "crypto"; import { dashboardServiceLinks } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; @@ -40,11 +41,13 @@ export class DashboardServiceLinkRepository { const [created] = await this.context.drizzle .insert(dashboardServiceLinks) .values({ + syncId: randomUUID(), userId, label: input.label, url: input.url, order: nextOrder, createdAt, + updatedAt: createdAt, }) .returning(); await this.afterWrite(); @@ -76,7 +79,7 @@ export class DashboardServiceLinkRepository { ): Promise { const [updated] = await this.context.drizzle .update(dashboardServiceLinks) - .set(updates) + .set({ ...updates, updatedAt: new Date().toISOString() }) .where( and( eq(dashboardServiceLinks.id, id), @@ -92,7 +95,10 @@ export class DashboardServiceLinkRepository { return updated ?? null; } - async deleteForUser(userId: string, id: number): Promise { + async deleteForUser( + userId: string, + id: number, + ): Promise<{ syncId: string | null } | null> { const rows = await this.context.drizzle .delete(dashboardServiceLinks) .where( @@ -101,13 +107,11 @@ export class DashboardServiceLinkRepository { eq(dashboardServiceLinks.userId, userId), ), ) - .returning({ id: dashboardServiceLinks.id }); + .returning({ syncId: dashboardServiceLinks.syncId }); - if (rows.length > 0) { - await this.afterWrite(); - } - - return rows.length > 0; + if (rows.length === 0) return null; + await this.afterWrite(); + return rows[0]; } async deleteByUserId(userId: string): Promise { diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index 51cd953c..42dd08d8 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -32,6 +32,7 @@ import { SettingsRepository } from "./settings-repository.js"; import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js"; import { SnippetRepository } from "./snippet-repository.js"; import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js"; +import { SyncTombstoneRepository } from "./sync-tombstone-repository.js"; import { SsoProviderRepository } from "./sso-provider-repository.js"; import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js"; import { TermixIdentityRepository } from "./termix-identity-repository.js"; @@ -126,6 +127,13 @@ export function createCurrentDashboardServiceLinkRepository(): DashboardServiceL ); } +export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository { + return new SyncTombstoneRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("sync_tombstone_repository_write"), + ); +} + export function createCurrentDismissedAlertRepository(): DismissedAlertRepository { return new DismissedAlertRepository( createCurrentRepositoryContext(), diff --git a/src/backend/database/repositories/homepage-item-repository.ts b/src/backend/database/repositories/homepage-item-repository.ts index 1dc8efea..2be7ebed 100644 --- a/src/backend/database/repositories/homepage-item-repository.ts +++ b/src/backend/database/repositories/homepage-item-repository.ts @@ -1,4 +1,5 @@ import { and, asc, eq } from "drizzle-orm"; +import { randomUUID } from "crypto"; import { homepageItems } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; @@ -37,6 +38,7 @@ export class HomepageItemRepository { const [created] = await this.context.drizzle .insert(homepageItems) .values({ + syncId: randomUUID(), userId, typeId: input.typeId, title: input.title, @@ -82,17 +84,18 @@ export class HomepageItemRepository { return updated ?? null; } - async deleteForUser(userId: string, id: number): Promise { + async deleteForUser( + userId: string, + id: number, + ): Promise<{ syncId: string | null } | null> { const rows = await this.context.drizzle .delete(homepageItems) .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))) - .returning({ id: homepageItems.id }); + .returning({ syncId: homepageItems.syncId }); - if (rows.length > 0) { - await this.afterWrite(); - } - - return rows.length > 0; + if (rows.length === 0) return null; + await this.afterWrite(); + return rows[0]; } async deleteByUserId(userId: string): Promise { diff --git a/src/backend/database/repositories/host-folder-repository.ts b/src/backend/database/repositories/host-folder-repository.ts index 287a5679..d83df5a3 100644 --- a/src/backend/database/repositories/host-folder-repository.ts +++ b/src/backend/database/repositories/host-folder-repository.ts @@ -1,4 +1,5 @@ import { and, eq, like, or, sql } from "drizzle-orm"; +import { randomUUID } from "crypto"; import type { SQLiteColumn } from "drizzle-orm/sqlite-core"; import { hosts, sshCredentials, sshFolders } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; @@ -96,6 +97,7 @@ export class HostFolderRepository { const [created] = await this.context.drizzle .insert(sshFolders) .values({ + syncId: randomUUID(), userId, name, color, @@ -126,7 +128,7 @@ export class HostFolderRepository { async deleteHostsAndFolderRecords( userId: string, folderName: string, - ): Promise { + ): Promise<{ hostSyncIds: string[]; folderSyncIds: string[] }> { const folderMatch = (col: SQLiteColumn) => or(eq(col, folderName), like(col, `${folderName} / %`)); @@ -137,11 +139,21 @@ export class HostFolderRepository { .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); } - await this.context.drizzle + const deletedFolders = await this.context.drizzle .delete(sshFolders) - .where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name))); + .where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name))) + .returning({ syncId: sshFolders.syncId }); await this.afterWrite(); + + return { + hostSyncIds: hostsToDelete + .map((h) => h.syncId) + .filter((id): id is string => !!id), + folderSyncIds: deletedFolders + .map((f) => f.syncId) + .filter((id): id is string => !!id), + }; } async deleteByUserId(userId: string): Promise { diff --git a/src/backend/database/repositories/host-repository.ts b/src/backend/database/repositories/host-repository.ts index 0cb7a4db..833e0706 100644 --- a/src/backend/database/repositories/host-repository.ts +++ b/src/backend/database/repositories/host-repository.ts @@ -1,4 +1,5 @@ import { and, eq, inArray } from "drizzle-orm"; +import { randomUUID } from "crypto"; import { hostAccess, hosts } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { DataCrypto } from "../../utils/data-crypto.js"; @@ -22,7 +23,7 @@ export class HostRepository { async create(host: NewHostRecord): Promise { const rows = await this.context.drizzle .insert(hosts) - .values(host) + .values({ syncId: randomUUID(), ...host }) .returning(); await this.afterWrite(); return rows[0]; @@ -34,7 +35,11 @@ export class HostRepository { ): Promise { const userDataKey = DataCrypto.validateUserAccess(userId); const tempId = host.id ?? Date.now(); - const dataWithTempId = { ...host, id: tempId }; + const dataWithTempId = { + syncId: randomUUID(), + ...host, + id: tempId, + }; const encryptedHost = DataCrypto.encryptRecord( "ssh_data", dataWithTempId, @@ -221,16 +226,19 @@ export class HostRepository { return rows.length; } - async deleteForUser(userId: string, hostId: number): Promise { + async deleteForUser( + userId: string, + hostId: number, + ): Promise<{ syncId: string | null } | null> { await this.deleteAccessForHost(hostId); const rows = await this.context.drizzle .delete(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .returning({ id: hosts.id }); + .returning({ syncId: hosts.syncId }); await this.afterWrite(); - return rows.length > 0; + return rows[0] ?? null; } async deleteByUserId(userId: string): Promise { diff --git a/src/backend/database/repositories/snippet-repository.ts b/src/backend/database/repositories/snippet-repository.ts index 5196ff38..60e6bf17 100644 --- a/src/backend/database/repositories/snippet-repository.ts +++ b/src/backend/database/repositories/snippet-repository.ts @@ -1,4 +1,5 @@ import { and, asc, eq, sql } from "drizzle-orm"; +import { randomUUID } from "crypto"; import { snippetFolders, snippets } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; @@ -151,6 +152,7 @@ export class SnippetRepository { const rows = await this.context.drizzle .insert(snippets) .values({ + syncId: randomUUID(), userId, name: input.name.trim(), content: input.content.trim(), @@ -343,6 +345,7 @@ export class SnippetRepository { const maxOrder = await this.maxOrderForFolder(userId, folderVal); await this.context.drizzle.insert(snippets).values({ + syncId: randomUUID(), userId, name: snippet.name.trim(), content: snippet.content.trim(), @@ -377,6 +380,7 @@ export class SnippetRepository { const rows = await this.context.drizzle .insert(snippetFolders) .values({ + syncId: randomUUID(), userId, name: name.trim(), color: color?.trim() || null, @@ -452,19 +456,24 @@ export class SnippetRepository { return { status: "renamed" }; } - async deleteFolder(userId: string, name: string): Promise { + async deleteFolder( + userId: string, + name: string, + ): Promise<{ syncId: string | null } | null> { await this.context.drizzle .update(snippets) .set({ folder: null }) .where(and(eq(snippets.userId, userId), eq(snippets.folder, name))); - await this.context.drizzle + const rows = await this.context.drizzle .delete(snippetFolders) .where( and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), - ); + ) + .returning({ syncId: snippetFolders.syncId }); await this.afterWrite(); + return rows[0] ?? null; } private async findFolderByName( diff --git a/src/backend/database/repositories/sync-tombstone-repository.ts b/src/backend/database/repositories/sync-tombstone-repository.ts new file mode 100644 index 00000000..1fe4953f --- /dev/null +++ b/src/backend/database/repositories/sync-tombstone-repository.ts @@ -0,0 +1,70 @@ +import { and, eq, gt } from "drizzle-orm"; +import { syncTombstones } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect; + +export type SyncEntityType = + | "hosts" + | "sshCredentials" + | "sshFolders" + | "snippets" + | "snippetFolders" + | "vaultProfiles" + | "dashboardServiceLinks" + | "homepageItems"; + +export class SyncTombstoneRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async record( + userId: string, + entityType: SyncEntityType, + syncId: string, + ): Promise { + if (!syncId) return; + await this.context.drizzle.insert(syncTombstones).values({ + userId, + entityType, + syncId, + }); + await this.afterWrite(); + } + + async recordMany( + userId: string, + entityType: SyncEntityType, + syncIds: string[], + ): Promise { + const rows = syncIds.filter(Boolean); + if (rows.length === 0) return; + await this.context.drizzle + .insert(syncTombstones) + .values(rows.map((syncId) => ({ userId, entityType, syncId }))); + await this.afterWrite(); + } + + async listSince( + userId: string, + entityType: SyncEntityType, + since: string | null, + ): Promise { + const conditions = [ + eq(syncTombstones.userId, userId), + eq(syncTombstones.entityType, entityType), + ]; + if (since) conditions.push(gt(syncTombstones.deletedAt, since)); + + return this.context.drizzle + .select() + .from(syncTombstones) + .where(and(...conditions)); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/vault-profile-repository.ts b/src/backend/database/repositories/vault-profile-repository.ts index 319a7b4d..64ff3706 100644 --- a/src/backend/database/repositories/vault-profile-repository.ts +++ b/src/backend/database/repositories/vault-profile-repository.ts @@ -1,4 +1,5 @@ import { desc, eq, or } from "drizzle-orm"; +import { randomUUID } from "crypto"; import { vaultProfiles } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; @@ -47,6 +48,7 @@ export class VaultProfileRepository { const [created] = await this.context.drizzle .insert(vaultProfiles) .values({ + syncId: randomUUID(), userId: input.userId, name: input.name, description: input.description, @@ -98,17 +100,15 @@ export class VaultProfileRepository { return updated ?? null; } - async deleteById(id: number): Promise { + async deleteById(id: number): Promise<{ syncId: string | null } | null> { const rows = await this.context.drizzle .delete(vaultProfiles) .where(eq(vaultProfiles.id, id)) - .returning({ id: vaultProfiles.id }); + .returning({ syncId: vaultProfiles.syncId }); - if (rows.length > 0) { - await this.afterWrite(); - } - - return rows.length > 0; + if (rows.length === 0) return null; + await this.afterWrite(); + return rows[0]; } async deleteByUserId(userId: string): Promise { diff --git a/src/backend/database/routes/acme-ssl-routes.ts b/src/backend/database/routes/acme-ssl-routes.ts index 904d5af8..5f3fa681 100644 --- a/src/backend/database/routes/acme-ssl-routes.ts +++ b/src/backend/database/routes/acme-ssl-routes.ts @@ -460,9 +460,9 @@ export function registerAcmeSSLRoutes( !certificate.includes("BEGIN CERTIFICATE") || !privateKey.includes("PRIVATE KEY") ) { - return res - .status(400) - .json({ error: "A valid PEM certificate and private key are required" }); + return res.status(400).json({ + error: "A valid PEM certificate and private key are required", + }); } await fs.mkdir(SSL_DIR, { recursive: true }); @@ -485,7 +485,8 @@ export function registerAcmeSSLRoutes( ); } catch { return res.status(400).json({ - error: "The provided certificate or private key is not valid PEM data", + error: + "The provided certificate or private key is not valid PEM data", }); } diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index a467426a..bd5d49b3 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -12,6 +12,7 @@ import { createCurrentHostResolutionRepository, createCurrentHostRepository, createCurrentUserRepository, + createCurrentSyncTombstoneRepository, } from "../repositories/factory.js"; const router = express.Router(); @@ -642,6 +643,13 @@ router.delete( userId, credentialId, ); + if (credentialToDelete.syncId) { + await createCurrentSyncTombstoneRepository().record( + userId, + "sshCredentials", + credentialToDelete.syncId, + ); + } // Shares stay in place; re-snapshot so recipients fall back to whatever // auth the host still has (or lose the stale credential copy). diff --git a/src/backend/database/routes/dashboard-service-links-routes.ts b/src/backend/database/routes/dashboard-service-links-routes.ts index 34829356..9e0c2123 100644 --- a/src/backend/database/routes/dashboard-service-links-routes.ts +++ b/src/backend/database/routes/dashboard-service-links-routes.ts @@ -4,7 +4,10 @@ import { dashboardLogger } from "../../utils/logger.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { isNonEmptyString } from "./host-normalizers.js"; import express from "express"; -import { createCurrentDashboardServiceLinkRepository } from "../repositories/factory.js"; +import { + createCurrentDashboardServiceLinkRepository, + createCurrentSyncTombstoneRepository, +} from "../repositories/factory.js"; export const dashboardServiceLinksRouter = express.Router(); @@ -152,10 +155,18 @@ dashboardServiceLinksRouter.delete( return res.status(404).json({ error: "Not found" }); } - await createCurrentDashboardServiceLinkRepository().deleteForUser( - userId, - id, - ); + const deleted = + await createCurrentDashboardServiceLinkRepository().deleteForUser( + userId, + id, + ); + if (deleted?.syncId) { + await createCurrentSyncTombstoneRepository().record( + userId, + "dashboardServiceLinks", + deleted.syncId, + ); + } DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted"); res.json({ message: "Service link deleted" }); diff --git a/src/backend/database/routes/homepage-items-routes.ts b/src/backend/database/routes/homepage-items-routes.ts index 5ea49791..e953e63a 100644 --- a/src/backend/database/routes/homepage-items-routes.ts +++ b/src/backend/database/routes/homepage-items-routes.ts @@ -1,7 +1,10 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, Response } from "express"; import { homepageLogger } from "../../utils/logger.js"; -import { createCurrentHomepageItemRepository } from "../repositories/factory.js"; +import { + createCurrentHomepageItemRepository, + createCurrentSyncTombstoneRepository, +} from "../repositories/factory.js"; import express from "express"; export const homepageItemsRouter = express.Router(); @@ -184,7 +187,14 @@ homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => { return res.status(404).json({ error: "Not found" }); } - await itemRepository.deleteForUser(userId, id); + const deleted = await itemRepository.deleteForUser(userId, id); + if (deleted?.syncId) { + await createCurrentSyncTombstoneRepository().record( + userId, + "homepageItems", + deleted.syncId, + ); + } res.json({ message: "Homepage item deleted" }); } catch (err) { homepageLogger.error("Failed to delete homepage item", err); diff --git a/src/backend/database/routes/host-folder-routes.ts b/src/backend/database/routes/host-folder-routes.ts index d2225f62..933fda99 100644 --- a/src/backend/database/routes/host-folder-routes.ts +++ b/src/backend/database/routes/host-folder-routes.ts @@ -11,6 +11,7 @@ import { createCurrentSshCredentialUsageRepository, createCurrentSessionRecordingRepository, createCurrentTransferRecentRepository, + createCurrentSyncTombstoneRepository, } from "../repositories/factory.js"; import { isNonEmptyString } from "./host-normalizers.js"; @@ -318,9 +319,17 @@ export function registerHostFolderRoutes( ); } - await hostFolderRepository.deleteHostsAndFolderRecords( + const { hostSyncIds, folderSyncIds } = + await hostFolderRepository.deleteHostsAndFolderRecords( + userId, + folderName, + ); + const tombstoneRepository = createCurrentSyncTombstoneRepository(); + await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds); + await tombstoneRepository.recordMany( userId, - folderName, + "sshFolders", + folderSyncIds, ); try { diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 18848fc1..fae4dd7e 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -26,6 +26,7 @@ import { createCurrentHostResolutionRepository, createCurrentHostRepository, createCurrentUserRepository, + createCurrentSyncTombstoneRepository, } from "../repositories/factory.js"; import { isNonEmptyString, @@ -201,6 +202,7 @@ router.post( socks5Username, socks5Password, socks5ProxyChain, + connectionOrigin, portKnockSequence, overrideCredentialUsername, macAddress, @@ -331,6 +333,10 @@ router.post( socks5ProxyChain: socks5ProxyChain ? JSON.stringify(socks5ProxyChain) : null, + connectionOrigin: + connectionOrigin === "local" || connectionOrigin === "remote" + ? connectionOrigin + : null, macAddress: macAddress || null, wolBroadcastAddress: wolBroadcastAddress || null, portKnockSequence: portKnockSequence @@ -843,6 +849,7 @@ router.put( socks5Username, socks5Password, socks5ProxyChain, + connectionOrigin, portKnockSequence, overrideCredentialUsername, macAddress, @@ -970,6 +977,10 @@ router.put( socks5ProxyChain: socks5ProxyChain ? JSON.stringify(socks5ProxyChain) : null, + connectionOrigin: + connectionOrigin === "local" || connectionOrigin === "remote" + ? connectionOrigin + : null, macAddress: macAddress || null, wolBroadcastAddress: wolBroadcastAddress || null, portKnockSequence: portKnockSequence @@ -2054,6 +2065,13 @@ router.delete( ); await createCurrentHostRepository().deleteForUser(userId, numericHostId); + if (hostToDelete.syncId) { + await createCurrentSyncTombstoneRepository().record( + userId, + "hosts", + hostToDelete.syncId, + ); + } databaseLogger.success("SSH host deleted", { operation: "host_delete_success", diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index 2fbc4068..0680ebc7 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -12,6 +12,7 @@ import { createCurrentRoleRepository, createCurrentSnippetRepository, createCurrentUserRepository, + createCurrentSyncTombstoneRepository, } from "../repositories/factory.js"; const router = express.Router(); @@ -400,7 +401,17 @@ router.delete( try { const folderName = decodeURIComponent(name); - await createCurrentSnippetRepository().deleteFolder(userId, folderName); + const deletedFolder = await createCurrentSnippetRepository().deleteFolder( + userId, + folderName, + ); + if (deletedFolder?.syncId) { + await createCurrentSyncTombstoneRepository().record( + userId, + "snippetFolders", + deletedFolder.syncId, + ); + } authLogger.success( `Snippet folder deleted: ${folderName} by user ${userId}`, @@ -1241,6 +1252,14 @@ router.delete( return res.status(404).json({ error: "Snippet not found" }); } + if (existing.syncId) { + await createCurrentSyncTombstoneRepository().record( + userId, + "snippets", + existing.syncId, + ); + } + databaseLogger.info("Command snippet deleted", { operation: "snippet_delete", userId, diff --git a/src/backend/database/routes/sync.ts b/src/backend/database/routes/sync.ts new file mode 100644 index 00000000..3f1198c4 --- /dev/null +++ b/src/backend/database/routes/sync.ts @@ -0,0 +1,415 @@ +import type { Request, Response } from "express"; +import express from "express"; +import { and, eq, gt } from "drizzle-orm"; +import { + hosts, + sshCredentials, + sshFolders, + snippets, + snippetFolders, + vaultProfiles, + dashboardServiceLinks, + homepageItems, +} from "../db/schema.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; +import { databaseLogger } from "../../utils/logger.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { + createCurrentRepositoryContext, + createCurrentSyncTombstoneRepository, +} from "../repositories/factory.js"; +import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js"; + +const router = express.Router(); +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); + +// Encrypted tables need DataCrypto to translate between the wire payload +// (plaintext) and the stored row (encrypted). Everything else is stored +// and synced as-is. +const ENCRYPTED_ENTITY_TABLES: Partial> = { + hosts: "ssh_data", + sshCredentials: "ssh_credentials", +}; + +interface EntityConfig { + table: + | typeof hosts + | typeof sshCredentials + | typeof sshFolders + | typeof snippets + | typeof snippetFolders + | typeof vaultProfiles + | typeof dashboardServiceLinks + | typeof homepageItems; + // Fields that only make sense on the device that created the row, or + // that are managed elsewhere and must never be overwritten by a sync + // payload from the other side. + readOnlyFields: string[]; +} + +const ENTITY_CONFIG: Record = { + hosts: { + table: hosts, + readOnlyFields: ["connectionOrigin"], + }, + sshCredentials: { table: sshCredentials, readOnlyFields: [] }, + sshFolders: { table: sshFolders, readOnlyFields: [] }, + snippets: { table: snippets, readOnlyFields: [] }, + snippetFolders: { table: snippetFolders, readOnlyFields: [] }, + vaultProfiles: { table: vaultProfiles, readOnlyFields: [] }, + dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] }, + homepageItems: { table: homepageItems, readOnlyFields: [] }, +}; + +const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG)); + +export function isValidEntityType(value: unknown): value is SyncEntityType { + return typeof value === "string" && VALID_ENTITY_TYPES.has(value); +} + +function requireUserDataKey(userId: string): Buffer { + return DataCrypto.validateUserAccess(userId); +} + +function decryptIfNeeded( + entityType: SyncEntityType, + row: Record, + userId: string, +): Record { + const tableName = ENCRYPTED_ENTITY_TABLES[entityType]; + if (!tableName) return row; + const userDataKey = DataCrypto.getUserDataKey(userId); + if (!userDataKey) return row; + return DataCrypto.decryptRecord( + tableName, + row, + userId, + userDataKey, + ) as Record; +} + +function encryptIfNeeded( + entityType: SyncEntityType, + row: Record, + userId: string, +): Record { + const tableName = ENCRYPTED_ENTITY_TABLES[entityType]; + if (!tableName) return row; + const userDataKey = requireUserDataKey(userId); + return DataCrypto.encryptRecord( + tableName, + row, + userId, + userDataKey, + ) as Record; +} + +export function stripWritePayload( + entityType: SyncEntityType, + payload: Record, +): Record { + const { readOnlyFields } = ENTITY_CONFIG[entityType]; + const clean = { ...payload }; + delete clean.id; + delete clean.userId; + delete clean.syncId; + for (const field of readOnlyFields) delete clean[field]; + return clean; +} + +/** + * @openapi + * /sync/{entityType}: + * get: + * summary: Pull synced rows for an entity type + * description: Returns rows owned by the authenticated user whose updatedAt is newer than `since` (or all rows if omitted). Used by the desktop app's remote sync engine to reconcile the embedded backend against a connected remote server. + * tags: + * - Sync + * parameters: + * - in: path + * name: entityType + * required: true + * schema: + * type: string + * - in: query + * name: since + * schema: + * type: string + * responses: + * 200: + * description: Rows updated since the given timestamp. + * 400: + * description: Unknown entity type. + * 500: + * description: Failed to fetch rows. + */ +router.get( + "/:entityType", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const entityType = req.params.entityType; + if (!isValidEntityType(entityType)) { + return res.status(400).json({ error: "Unknown entity type" }); + } + const since = + typeof req.query.since === "string" && req.query.since + ? req.query.since + : null; + + try { + const { table } = ENTITY_CONFIG[entityType]; + const context = createCurrentRepositoryContext(); + const conditions = [eq(table.userId, userId)]; + if (since && "updatedAt" in table) { + conditions.push(gt((table as typeof hosts).updatedAt, since)); + } + + const rows = await context.drizzle + .select() + .from(table as typeof hosts) + .where(and(...conditions)); + + const decrypted = rows.map((row) => + decryptIfNeeded(entityType, row as Record, userId), + ); + + res.json({ rows: decrypted }); + } catch (err) { + databaseLogger.error(`Failed to pull sync rows for ${entityType}`, err, { + operation: "sync_pull", + entityType, + userId, + }); + res.status(500).json({ error: "Failed to fetch rows" }); + } + }, +); + +/** + * @openapi + * /sync/{entityType}: + * post: + * summary: Upsert a synced row by syncId + * description: Creates or updates a row by its syncId. Used by the desktop app's remote sync engine to push local-only or newer rows to the other side of a sync pair. + * tags: + * - Sync + * parameters: + * - in: path + * name: entityType + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Row upserted. + * 400: + * description: Unknown entity type or missing syncId. + * 500: + * description: Failed to upsert row. + */ +router.post( + "/:entityType", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const entityType = req.params.entityType; + if (!isValidEntityType(entityType)) { + return res.status(400).json({ error: "Unknown entity type" }); + } + const payload = req.body?.row; + const syncId = payload?.syncId; + if (!payload || typeof syncId !== "string" || !syncId) { + return res.status(400).json({ error: "Missing row.syncId" }); + } + + try { + const { table } = ENTITY_CONFIG[entityType]; + const context = createCurrentRepositoryContext(); + + const existingRows = await context.drizzle + .select() + .from(table as typeof hosts) + .where( + and( + eq((table as typeof hosts).syncId, syncId), + eq(table.userId, userId), + ), + ) + .limit(1); + const existing = existingRows[0] as Record | undefined; + + const writePayload = stripWritePayload(entityType, payload); + const encryptedPayload = encryptIfNeeded( + entityType, + writePayload, + userId, + ); + + let resultRow: Record; + if (existing) { + const updatedRows = await context.drizzle + .update(table as typeof hosts) + .set(encryptedPayload) + .where( + and( + eq((table as typeof hosts).id, existing.id as number), + eq(table.userId, userId), + ), + ) + .returning(); + resultRow = updatedRows[0] as Record; + } else { + const insertedRows = await context.drizzle + .insert(table as typeof hosts) + .values({ + ...encryptedPayload, + userId, + syncId, + } as typeof hosts.$inferInsert) + .returning(); + resultRow = insertedRows[0] as Record; + } + + await DatabaseSaveTrigger.forceSave("sync_upsert"); + + res.json({ + row: decryptIfNeeded(entityType, resultRow, userId), + created: !existing, + }); + } catch (err) { + databaseLogger.error(`Failed to upsert sync row for ${entityType}`, err, { + operation: "sync_upsert", + entityType, + userId, + }); + res.status(500).json({ error: "Failed to upsert row" }); + } + }, +); + +/** + * @openapi + * /sync/{entityType}/tombstones: + * get: + * summary: Pull deletion tombstones for an entity type + * description: Returns tombstones recorded since `since` so the other side of a sync pair can apply the same deletions. + * tags: + * - Sync + * parameters: + * - in: path + * name: entityType + * required: true + * schema: + * type: string + * - in: query + * name: since + * schema: + * type: string + * responses: + * 200: + * description: Tombstones recorded since the given timestamp. + * 400: + * description: Unknown entity type. + * 500: + * description: Failed to fetch tombstones. + */ +router.get( + "/:entityType/tombstones", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const entityType = req.params.entityType; + if (!isValidEntityType(entityType)) { + return res.status(400).json({ error: "Unknown entity type" }); + } + const since = + typeof req.query.since === "string" && req.query.since + ? req.query.since + : null; + + try { + const tombstones = await createCurrentSyncTombstoneRepository().listSince( + userId, + entityType, + since, + ); + res.json({ tombstones }); + } catch (err) { + databaseLogger.error( + `Failed to fetch sync tombstones for ${entityType}`, + err, + { operation: "sync_tombstones_pull", entityType, userId }, + ); + res.status(500).json({ error: "Failed to fetch tombstones" }); + } + }, +); + +/** + * @openapi + * /sync/tombstones: + * post: + * summary: Report a deletion from the other side of a sync pair + * description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent. + * tags: + * - Sync + * responses: + * 200: + * description: Deletion applied (or row already absent). + * 400: + * description: Unknown entity type or missing syncId. + * 500: + * description: Failed to apply deletion. + */ +router.post( + "/tombstones", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const entityType = req.body?.entityType; + const syncId = req.body?.syncId; + if ( + !isValidEntityType(entityType) || + typeof syncId !== "string" || + !syncId + ) { + return res.status(400).json({ error: "Missing entityType or syncId" }); + } + + try { + const { table } = ENTITY_CONFIG[entityType]; + const context = createCurrentRepositoryContext(); + + await context.drizzle + .delete(table as typeof hosts) + .where( + and( + eq((table as typeof hosts).syncId, syncId), + eq(table.userId, userId), + ), + ); + + await createCurrentSyncTombstoneRepository().record( + userId, + entityType, + syncId, + ); + await DatabaseSaveTrigger.forceSave("sync_tombstone_applied"); + + res.json({ success: true }); + } catch (err) { + databaseLogger.error("Failed to apply sync tombstone", err, { + operation: "sync_tombstone_apply", + entityType, + userId, + }); + res.status(500).json({ error: "Failed to apply deletion" }); + } + }, +); + +export default router; diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index f58f34b8..f1202fdc 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1880,6 +1880,102 @@ router.get("/setup-required", async (req, res) => { } }); +function isLoopbackRequest(req: Request): boolean { + const ip = req.ip || req.socket?.remoteAddress || ""; + return ( + ip === "127.0.0.1" || + ip === "::1" || + ip === "::ffff:127.0.0.1" || + ip.endsWith(":127.0.0.1") + ); +} + +/** + * @openapi + * /users/internal/auto-session: + * post: + * summary: Mint a session for the auto-provisioned local desktop user + * description: Used by the Electron desktop app to skip the login form when running standalone with a single auto-provisioned local user. Only available over loopback and only when exactly one user exists -- a real multi-user or synced install never satisfies this, so no further secret is required. + * tags: + * - Users + * responses: + * 200: + * description: Session created. + * 403: + * description: Forbidden, or more than one user exists. + * 500: + * description: Failed to create session. + */ +router.post("/internal/auto-session", async (req, res) => { + try { + if (!isLoopbackRequest(req)) { + authLogger.warn( + "Rejected non-loopback attempt to access auto-session endpoint", + { source: req.ip }, + ); + return res.status(403).json({ error: "Forbidden" }); + } + + const userRepository = createCurrentUserRepository(); + const allUsers = await userRepository.listAll(); + if (allUsers.length !== 1) { + return res.status(403).json({ + error: "Auto-session is only available for a single local user", + }); + } + + const userRecord = allUsers[0]; + + // If the caller already holds a still-valid session for this same + // user (e.g. a duplicate call racing the first one, such as React + // StrictMode's double-invoke of effects in dev), reuse it instead of + // minting a fresh one. Minting unconditionally here would set a new + // `jwt` cookie on every call; since the auth middleware prefers the + // cookie over the Authorization header, a second mint silently + // invalidates whatever token the app already started using. + const existingToken = + (req as Request & { cookies?: Record }).cookies?.jwt || + (req.headers["authorization"]?.startsWith("Bearer ") + ? req.headers["authorization"].slice("Bearer ".length) + : undefined); + if (existingToken) { + const existingPayload = await authManager.verifyJWTToken(existingToken); + if (existingPayload?.userId === userRecord.id) { + return res.json({ + success: true, + is_admin: !!userRecord.isAdmin, + username: userRecord.username, + token: existingToken, + }); + } + } + + const token = await authManager.generateJWTToken(userRecord.id, { + deviceType: "desktop", + deviceInfo: "Termix Desktop (local)", + rememberMe: true, + }); + + const response = { + success: true, + is_admin: !!userRecord.isAdmin, + username: userRecord.username, + token, + }; + + return res + .cookie( + "jwt", + token, + authManager.getSecureCookieOptions(req, 30 * 24 * 60 * 60 * 1000), + ) + .json(response); + } catch (err) { + authLogger.error("Failed to create auto-session", err); + res.status(500).json({ error: "Failed to create auto-session" }); + } +}); + /** * @openapi * /users/count: diff --git a/src/backend/database/routes/vault.ts b/src/backend/database/routes/vault.ts index d70b3050..0616b73f 100644 --- a/src/backend/database/routes/vault.ts +++ b/src/backend/database/routes/vault.ts @@ -3,6 +3,7 @@ import type { Request, Response } from "express"; import { createCurrentVaultProfileRepository, createCurrentUserRepository, + createCurrentSyncTombstoneRepository, } from "../repositories/factory.js"; import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; @@ -421,7 +422,14 @@ router.delete( .status(403) .json({ error: "Only the owner can delete this profile" }); } - await repository.deleteById(id); + const deleted = await repository.deleteById(id); + if (deleted?.syncId) { + await createCurrentSyncTombstoneRepository().record( + userId, + "vaultProfiles", + deleted.syncId, + ); + } res.json({ success: true }); } catch (err) { authLogger.error("Failed to delete vault profile", err); diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 851d32a1..0058aced 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -15,6 +15,60 @@ import { setGlobalLogLevel, } from "./utils/logger.js"; +async function provisionLocalDesktopUserIfNeeded(): Promise { + const { createCurrentUserRepository, createCurrentRoleRepository } = + await import("./database/repositories/factory.js"); + const { AuthManager } = await import("./utils/auth-manager.js"); + const crypto = await import("crypto"); + + const userRepository = createCurrentUserRepository(); + const existingCount = await userRepository.countAll(); + if (existingCount > 0) return; + + const id = crypto.randomUUID(); + const { isFirstUser } = await userRepository.createFirstLocalUser({ + id, + username: "local", + passwordHash: "", + isOidc: false, + clientId: "", + clientSecret: "", + issuerUrl: "", + authorizationUrl: "", + tokenUrl: "", + identifierPath: "", + namePath: "", + scopes: "openid email profile", + totpSecret: null, + totpEnabled: false, + totpBackupCodes: null, + }); + + try { + await createCurrentRoleRepository().assignRoleNameToUser({ + userId: id, + roleName: isFirstUser ? "admin" : "user", + grantedBy: id, + }); + } catch (roleError) { + systemLogger.error( + "Failed to assign default role to auto-provisioned local user", + roleError, + { operation: "desktop_auto_provision_role" }, + ); + } + + await AuthManager.getInstance().registerUser( + id, + crypto.randomBytes(32).toString("hex"), + ); + + systemLogger.success("Auto-provisioned local desktop user", { + operation: "desktop_auto_provision", + userId: id, + }); +} + (async () => { const initStartTime = Date.now(); try { @@ -107,6 +161,10 @@ import { await import("./utils/crypto-migration/shared-host-secrets-migration.js"); await runSharedHostSecretsMigration(); + if (process.env.ELECTRON_EMBEDDED === "true") { + await provisionLocalDesktopUserIfNeeded(); + } + import("./utils/opkssh-binary-manager.js").then( ({ OPKSSHBinaryManager }) => { OPKSSHBinaryManager.ensureBinary().catch((error) => { diff --git a/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts b/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts index e3bf7a67..a68bb268 100644 --- a/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts +++ b/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts @@ -32,7 +32,9 @@ describe("DashboardServiceLinkRepository", () => { label TEXT NOT NULL, url TEXT NOT NULL, "order" INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + sync_id TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); INSERT INTO users (id, username, password_hash) @@ -99,8 +101,10 @@ describe("DashboardServiceLinkRepository", () => { ); expect(writeCount).toBe(2); - expect(await repo.deleteForUser("user-2", link.id)).toBe(false); - expect(await repo.deleteForUser("user-1", link.id)).toBe(true); + expect(await repo.deleteForUser("user-2", link.id)).toBeNull(); + expect(await repo.deleteForUser("user-1", link.id)).toEqual({ + syncId: expect.any(String), + }); expect(writeCount).toBe(3); }); diff --git a/src/backend/tests/database/repositories/homepage-item-repository.test.ts b/src/backend/tests/database/repositories/homepage-item-repository.test.ts index 09a5dc27..3ea1525e 100644 --- a/src/backend/tests/database/repositories/homepage-item-repository.test.ts +++ b/src/backend/tests/database/repositories/homepage-item-repository.test.ts @@ -31,6 +31,7 @@ describe("HomepageItemRepository", () => { title TEXT, config TEXT NOT NULL DEFAULT '{}', folder_id INTEGER, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -107,8 +108,10 @@ describe("HomepageItemRepository", () => { ).toBeNull(); expect(writeCount).toBe(2); - expect(await repo.deleteForUser("user-2", item.id)).toBe(false); - expect(await repo.deleteForUser("user-1", item.id)).toBe(true); + expect(await repo.deleteForUser("user-2", item.id)).toBeNull(); + expect(await repo.deleteForUser("user-1", item.id)).toEqual({ + syncId: expect.any(String), + }); expect(writeCount).toBe(3); }); diff --git a/src/backend/tests/database/repositories/host-credential-repositories.test.ts b/src/backend/tests/database/repositories/host-credential-repositories.test.ts index a1901746..1f320198 100644 --- a/src/backend/tests/database/repositories/host-credential-repositories.test.ts +++ b/src/backend/tests/database/repositories/host-credential-repositories.test.ts @@ -55,6 +55,7 @@ describe("HostRepository and CredentialRepository", () => { cert_public_key TEXT, usage_count INTEGER NOT NULL DEFAULT 0, last_used TEXT, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE @@ -151,6 +152,8 @@ describe("HostRepository and CredentialRepository", () => { host_key_first_seen TEXT, host_key_last_verified TEXT, host_key_changed_count INTEGER DEFAULT 0, + connection_origin TEXT, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, @@ -226,9 +229,9 @@ describe("HostRepository and CredentialRepository", () => { expect( await repo.credentials.findByIdForUser("user-2", created.id), ).toBeNull(); - expect(await repo.credentials.deleteForUser("user-1", created.id)).toBe( - true, - ); + expect(await repo.credentials.deleteForUser("user-1", created.id)).toEqual({ + syncId: expect.any(String), + }); expect( await repo.credentials.findByIdForUser("user-1", created.id), ).toBeNull(); @@ -449,7 +452,9 @@ describe("HostRepository and CredentialRepository", () => { expect(updated?.name).toBe("web-1-renamed"); expect(await repo.hosts.findByIdForUser("user-2", host.id)).toBeNull(); - expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true); + expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({ + syncId: expect.any(String), + }); expect(await repo.hosts.findById(host.id)).toBeNull(); }); @@ -687,6 +692,8 @@ describe("HostRepository and CredentialRepository", () => { .run(host.id, "user-2", "user-1"); expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1); - expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true); + expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({ + syncId: expect.any(String), + }); }); }); diff --git a/src/backend/tests/database/repositories/host-folder-repository.test.ts b/src/backend/tests/database/repositories/host-folder-repository.test.ts index e34130cd..7b94bea4 100644 --- a/src/backend/tests/database/repositories/host-folder-repository.test.ts +++ b/src/backend/tests/database/repositories/host-folder-repository.test.ts @@ -35,6 +35,7 @@ describe("HostFolderRepository", () => { name TEXT NOT NULL, folder TEXT, auth_type TEXT NOT NULL, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -130,6 +131,8 @@ describe("HostFolderRepository", () => { host_key_first_seen TEXT, host_key_last_verified TEXT, host_key_changed_count INTEGER DEFAULT 0, + connection_origin TEXT, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -141,6 +144,7 @@ describe("HostFolderRepository", () => { color TEXT, icon TEXT, credential_id INTEGER, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/src/backend/tests/database/repositories/host-resolution-repository.test.ts b/src/backend/tests/database/repositories/host-resolution-repository.test.ts index 2c30162f..ad9d0351 100644 --- a/src/backend/tests/database/repositories/host-resolution-repository.test.ts +++ b/src/backend/tests/database/repositories/host-resolution-repository.test.ts @@ -125,6 +125,8 @@ describe("HostResolutionRepository", () => { host_key_first_seen TEXT, host_key_last_verified TEXT, host_key_changed_count INTEGER DEFAULT 0, + connection_origin TEXT, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -148,6 +150,7 @@ describe("HostResolutionRepository", () => { cert_public_key TEXT, usage_count INTEGER NOT NULL DEFAULT 0, last_used TEXT, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -173,6 +176,7 @@ describe("HostResolutionRepository", () => { color TEXT, icon TEXT, credential_id INTEGER, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/src/backend/tests/database/repositories/snippet-repository.test.ts b/src/backend/tests/database/repositories/snippet-repository.test.ts index 8dc1d4cf..f4d51377 100644 --- a/src/backend/tests/database/repositories/snippet-repository.test.ts +++ b/src/backend/tests/database/repositories/snippet-repository.test.ts @@ -29,6 +29,7 @@ describe("SnippetRepository", () => { description TEXT, folder TEXT, "order" INTEGER NOT NULL DEFAULT 0, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, host_filter TEXT @@ -40,6 +41,7 @@ describe("SnippetRepository", () => { name TEXT NOT NULL, color TEXT, icon TEXT, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts b/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts new file mode 100644 index 00000000..cd85f2d9 --- /dev/null +++ b/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js"; + +describe("SyncTombstoneRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE sync_tombstones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + entity_type TEXT NOT NULL, + sync_id TEXT NOT NULL, + deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new SyncTombstoneRepository(context, onWrite); + } + + it("records a tombstone and lists it back for the owning user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.record("user-1", "hosts", "sync-abc"); + expect(writeCount).toBe(1); + + const rows = await repo.listSince("user-1", "hosts", null); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + userId: "user-1", + entityType: "hosts", + syncId: "sync-abc", + }); + }); + + it("does not record a tombstone for an empty syncId", async () => { + const repo = await createRepository(); + await repo.record("user-1", "hosts", ""); + const rows = await repo.listSince("user-1", "hosts", null); + expect(rows).toHaveLength(0); + }); + + it("recordMany writes multiple tombstones and filters out falsy ids", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.recordMany("user-1", "hosts", ["a", "", "b", "c"]); + expect(writeCount).toBe(1); + + const rows = await repo.listSince("user-1", "hosts", null); + expect(rows.map((r) => r.syncId).sort()).toEqual(["a", "b", "c"]); + }); + + it("recordMany is a no-op when given no syncIds", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.recordMany("user-1", "hosts", []); + expect(writeCount).toBe(0); + }); + + it("scopes listSince by userId and entityType", async () => { + const repo = await createRepository(); + await repo.record("user-1", "hosts", "sync-1"); + await repo.record("user-1", "snippets", "sync-2"); + await repo.record("user-2", "hosts", "sync-3"); + + const rows = await repo.listSince("user-1", "hosts", null); + expect(rows).toHaveLength(1); + expect(rows[0].syncId).toBe("sync-1"); + }); + + it("filters listSince by the since timestamp", async () => { + const adapterLocal = new TestSqliteDatabase(); + adapter = adapterLocal; + const context = await adapterLocal.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE sync_tombstones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + entity_type TEXT NOT NULL, + sync_id TEXT NOT NULL, + deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'); + + INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at) + VALUES + ('user-1', 'hosts', 'old', '2026-01-01T00:00:00.000Z'), + ('user-1', 'hosts', 'new', '2026-06-01T00:00:00.000Z'); + `); + const repo = new SyncTombstoneRepository(context); + + const rows = await repo.listSince( + "user-1", + "hosts", + "2026-03-01T00:00:00.000Z", + ); + expect(rows).toHaveLength(1); + expect(rows[0].syncId).toBe("new"); + }); +}); diff --git a/src/backend/tests/database/repositories/user-data-export-repository.test.ts b/src/backend/tests/database/repositories/user-data-export-repository.test.ts index 58cba98b..2a2796f3 100644 --- a/src/backend/tests/database/repositories/user-data-export-repository.test.ts +++ b/src/backend/tests/database/repositories/user-data-export-repository.test.ts @@ -113,6 +113,8 @@ describe("UserDataExportRepository", () => { host_key_first_seen TEXT, host_key_last_verified TEXT, host_key_changed_count INTEGER DEFAULT 0, + connection_origin TEXT, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -136,6 +138,7 @@ describe("UserDataExportRepository", () => { cert_public_key TEXT, usage_count INTEGER NOT NULL DEFAULT 0, last_used TEXT, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/src/backend/tests/database/repositories/vault-profile-repository.test.ts b/src/backend/tests/database/repositories/vault-profile-repository.test.ts index 2c6cdfc9..c0ade51e 100644 --- a/src/backend/tests/database/repositories/vault-profile-repository.test.ts +++ b/src/backend/tests/database/repositories/vault-profile-repository.test.ts @@ -40,6 +40,7 @@ describe("VaultProfileRepository", () => { valid_principals TEXT, key_type TEXT, shared INTEGER NOT NULL DEFAULT 0, + sync_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -119,8 +120,8 @@ describe("VaultProfileRepository", () => { }); expect(await repo.updateById(999, { name: "missing" })).toBeNull(); - expect(await repo.deleteById(1)).toBe(true); - expect(await repo.deleteById(1)).toBe(false); + expect(await repo.deleteById(1)).toEqual({ syncId: null }); + expect(await repo.deleteById(1)).toBeNull(); expect(await repo.findById(1)).toBeNull(); expect(writeCount).toBe(2); }); diff --git a/src/backend/tests/database/routes/sync.test.ts b/src/backend/tests/database/routes/sync.test.ts new file mode 100644 index 00000000..55d27bb2 --- /dev/null +++ b/src/backend/tests/database/routes/sync.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + isValidEntityType, + stripWritePayload, +} from "../../../database/routes/sync.js"; + +describe("isValidEntityType", () => { + it("accepts every whitelisted sync entity type", () => { + for (const type of [ + "hosts", + "sshCredentials", + "sshFolders", + "snippets", + "snippetFolders", + "vaultProfiles", + "dashboardServiceLinks", + "homepageItems", + ]) { + expect(isValidEntityType(type)).toBe(true); + } + }); + + it("rejects unknown or non-string entity types", () => { + expect(isValidEntityType("hostAccess")).toBe(false); + expect(isValidEntityType("")).toBe(false); + expect(isValidEntityType(undefined)).toBe(false); + expect(isValidEntityType(42)).toBe(false); + }); +}); + +describe("stripWritePayload", () => { + it("strips id, userId, and syncId from every entity type", () => { + const payload = { + id: 1, + userId: "user-1", + syncId: "abc", + name: "prod-db", + }; + expect(stripWritePayload("sshFolders", payload)).toEqual({ + name: "prod-db", + }); + }); + + it("also strips desktop-only fields flagged read-only for hosts", () => { + const payload = { + id: 1, + userId: "user-1", + syncId: "abc", + name: "web", + connectionOrigin: "remote", + }; + expect(stripWritePayload("hosts", payload)).toEqual({ name: "web" }); + }); + + it("does not mutate the original payload object", () => { + const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" }; + stripWritePayload("snippets", payload); + expect(payload).toEqual({ + id: 1, + userId: "user-1", + syncId: "abc", + name: "x", + }); + }); +}); diff --git a/src/backend/tests/utils/analytics.test.ts b/src/backend/tests/utils/analytics.test.ts index 83c8f8de..a64b3bdd 100644 --- a/src/backend/tests/utils/analytics.test.ts +++ b/src/backend/tests/utils/analytics.test.ts @@ -11,9 +11,8 @@ function makeChain(resolveValue: unknown) { for (const m of methods) { chain[m] = vi.fn(() => chain); } - (chain as unknown as Promise).then = ( - cb: (v: unknown) => unknown, - ) => Promise.resolve(resolveValue).then(cb); + (chain as unknown as Promise).then = (cb: (v: unknown) => unknown) => + Promise.resolve(resolveValue).then(cb); return chain; } @@ -72,9 +71,7 @@ describe("analytics", () => { it("getOrCreateInstanceId returns the existing id without generating one", async () => { mockGet.mockResolvedValue("existing-id"); - const { getOrCreateInstanceId } = await import( - "../../utils/analytics.js" - ); + const { getOrCreateInstanceId } = await import("../../utils/analytics.js"); const id = await getOrCreateInstanceId(); @@ -84,9 +81,7 @@ describe("analytics", () => { it("getOrCreateInstanceId generates and persists a new id when absent", async () => { mockGet.mockResolvedValue(null); - const { getOrCreateInstanceId } = await import( - "../../utils/analytics.js" - ); + const { getOrCreateInstanceId } = await import("../../utils/analytics.js"); const id = await getOrCreateInstanceId(); @@ -96,9 +91,8 @@ describe("analytics", () => { it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => { delete process.env.POSTHOG_API_KEY; - const { collectAndSendHeartbeat } = await import( - "../../utils/analytics.js" - ); + const { collectAndSendHeartbeat } = + await import("../../utils/analytics.js"); await collectAndSendHeartbeat(); @@ -108,9 +102,8 @@ describe("analytics", () => { it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => { process.env.POSTHOG_API_KEY = "phc_test"; mockGetBoolean.mockResolvedValue(false); - const { collectAndSendHeartbeat } = await import( - "../../utils/analytics.js" - ); + const { collectAndSendHeartbeat } = + await import("../../utils/analytics.js"); await collectAndSendHeartbeat(); @@ -122,9 +115,8 @@ describe("analytics", () => { mockGetBoolean.mockResolvedValue(true); mockGet.mockResolvedValue("instance-123"); mockPost.mockResolvedValue({}); - const { collectAndSendHeartbeat } = await import( - "../../utils/analytics.js" - ); + const { collectAndSendHeartbeat } = + await import("../../utils/analytics.js"); await collectAndSendHeartbeat(); diff --git a/src/backend/utils/analytics.ts b/src/backend/utils/analytics.ts index c77c4360..9271962a 100644 --- a/src/backend/utils/analytics.ts +++ b/src/backend/utils/analytics.ts @@ -25,7 +25,10 @@ const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com"; const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000; export async function isAnalyticsEnabled(): Promise { - return createCurrentSettingsRepository().getBoolean("analytics_enabled", true); + return createCurrentSettingsRepository().getBoolean( + "analytics_enabled", + true, + ); } export async function getOrCreateInstanceId(): Promise { @@ -120,10 +123,9 @@ export async function collectAndSendHeartbeat(): Promise { export function startAnalyticsHeartbeat(): void { if (!process.env.POSTHOG_API_KEY) { - analyticsLogger.info( - "Analytics disabled: POSTHOG_API_KEY not set", - { operation: "analytics_disabled_no_key" }, - ); + analyticsLogger.info("Analytics disabled: POSTHOG_API_KEY not set", { + operation: "analytics_disabled_no_key", + }); return; } diff --git a/src/main.tsx b/src/main.tsx index 7978d95c..bebb478e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -180,6 +180,7 @@ function App() { stored?.loggedIn ? "verifying" : "idle-auth", ); const [authUsername, setAuthUsername] = useState(stored?.username ?? ""); + const [verifyRetryCount, setVerifyRetryCount] = useState(0); const timerRef = useRef | null>(null); // Track whether fading-in came from a fresh login (vs. session verification on page load). // When session-verified, Auth must not mount during the transition — it would trigger @@ -219,11 +220,36 @@ function App() { setPhase("fading-in"); timerRef.current = setTimeout(() => setPhase("idle-app"), 450); }) - .catch(() => { - clearStoredAuth(); - setPhase("idle-auth"); + .catch((err: unknown) => { + // Only treat a genuine auth rejection (401/403) as "not logged in". + // Anything else (network hiccup, backend still starting up, a + // transient 5xx) is not proof the session is invalid -- clearing + // stored auth here would drop the user back to Auth.tsx, which in + // Electron immediately mints a brand-new auto-session, silently + // swapping out the JWT/cookie from under any still-in-flight + // requests and causing spurious "Session expired" toasts. + const status = + (err as { status?: number; response?: { status?: number } }) + ?.status ?? + (err as { response?: { status?: number } })?.response?.status; + if (status === 401 || status === 403) { + clearStoredAuth(); + setPhase("idle-auth"); + return; + } + // Transient failure: retry shortly rather than logging out. Cap + // retries so a genuinely broken backend still surfaces the login + // screen eventually instead of spinning forever. + if (verifyRetryCount >= 5) { + clearStoredAuth(); + setPhase("idle-auth"); + return; + } + timerRef.current = setTimeout(() => { + setVerifyRetryCount((c) => c + 1); + }, 3000); }); - }, [phase]); + }, [phase, verifyRetryCount]); function handleLogin(u: string) { setAuthUsername(u); @@ -232,6 +258,12 @@ function App() { timerRef.current = setTimeout(() => setPhase("idle-app"), 450); if (isElectron()) { window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {}); + const localJwt = localStorage.getItem("jwt"); + if (localJwt) { + window.electronAPI + ?.invoke?.("notify-local-login", localJwt) + .catch(() => {}); + } } } @@ -244,11 +276,6 @@ function App() { }, 450); } - function handleChangeServer() { - localStorage.setItem("termix_show_server_config", "true"); - handleLogout(); - } - const showApp = phase === "idle-app" || phase === "fading-in" || phase === "fading-out"; const showAuth = @@ -294,11 +321,7 @@ function App() { }} > - + )} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 61a38183..91cc7d4a 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -64,6 +64,15 @@ export interface ElectronAPI { started: number; errors: string[]; }>; + onRemoteSyncStatusChanged?: ( + callback: (status: { + connected: boolean; + syncing: boolean; + lastSyncedAt: string | null; + lastError: string | null; + needsReauth: boolean; + }) => void, + ) => () => void; clearSessionCookies: () => Promise; getSessionCookie: ( name: string, @@ -157,7 +166,6 @@ declare global { interface Window { electronAPI: ElectronAPI; IS_ELECTRON: boolean; - configuredServerUrl?: string | null; electronClipboard?: { writeText(text: string): Promise; readText(): Promise; diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 5bce442d..019ae77d 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -71,6 +71,7 @@ export type Host = { useSocks5?: boolean; socks5Host?: string; socks5Port?: number; + connectionOrigin?: "local" | "remote" | null; socks5Username?: string; socks5Password?: string; socks5ProxyChain?: { diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index bf31da3d..58c12a6f 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -126,10 +126,12 @@ import { getActiveSessions, getUserPreferences, dismissDonationModal, + isElectron, type UserPreferences, type OpenTabRecord, } from "@/main-axios"; import { DonationReminderModal } from "@/user/DonationReminderModal.tsx"; +import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx"; import { dbHealthMonitor } from "@/lib/db-health-monitor"; import type { SSHHostWithStatus } from "@/main-axios"; import { ServerStatusProvider } from "@/lib/ServerStatusContext"; @@ -193,11 +195,9 @@ export { tabIcon, renderTabContent } from "@/shell/tabUtils"; export function AppShell({ username, onLogout, - onChangeServer, }: { username: string; onLogout: () => void; - onChangeServer?: () => void; }) { const { t, i18n } = useTranslation(); const { setTheme } = useTheme(); @@ -238,6 +238,13 @@ export function AppShell({ const [hostsLoading, setHostsLoading] = useState(true); const [allHosts, setAllHosts] = useState([]); const [isAdmin, setIsAdmin] = useState(false); + // Remote sync is not yet configurable (added in a later phase), so this + // is always false for now -- admin/user-management UI stays hidden until + // the desktop app is connected to a remote Termix server, since a + // standalone local install has exactly one implicit user and nothing to + // administer. + const [isRemoteSyncConnected] = useState(false); + const showMultiUserUI = isAdmin && (!isElectron() || isRemoteSyncConnected); const [userId, setUserId] = useState(null); const [showDonationModal, setShowDonationModal] = useState(false); const [backgroundTabRecords, setBackgroundTabRecords] = useState< @@ -1806,7 +1813,6 @@ export function AppShell({ setUserPrefs((current) => ({ ...current, ...updates })) @@ -1815,7 +1821,7 @@ export function AppShell({ )} - {railView === "admin-settings" && isAdmin && ( + {railView === "admin-settings" && showMultiUserUI && (
-
- {/* Skinny icon rail — desktop only, hidden on mobile */} - - - {/* Desktop: inline resizable sidebar */} - {!isMobile && ( -
+ {isElectron() && ( + { + setRailView("user-profile"); + if (!sidebarOpen) setSidebarOpen(true); }} - > - {sidebarHeader} - {sidebarPanelContent} - - {sidebarOpen && !sidebarEditing && ( -
- )} -
+ /> )} - - {/* Mobile: sidebar as overlay sheet */} - {isMobile && ( - - - {sidebarHeader} - {sidebarPanelContent} - - - )} - - {/* Main content area */} -
- {!isMobile && !sidebarOpen && ( - - )} -
- { - const targetTab = tabs.find((t) => t.id === tabId); - if (targetTab?.host) openTab(targetTab.host, "files"); - }} - onOpenShare={openShareForTab} - isAppFullscreen={isAppFullscreen} - onToggleAppFullscreen={toggleAppFullscreen} - /> -
- {/* Split view — always mounted when not mobile, hidden via CSS when inactive */} - {!isMobile && ( -
- -
- )} - - {/* Normal-view container. Tab nodes are appended here (or to pane elements) - by the DOM-placement effect above. React portals each tab's content - into its stable per-tab node so the component is never remounted. - When split is active, shown on top only if the active tab is not in a pane. */} -
- {tabs.map((tab) => { - const tabNode = getTabNode(tab.id, tab.type === "terminal"); - const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1; - const inPane = paneIdx !== -1; - const activeInline = !inPane && tab.id === activeTabId; - return createPortal( - renderTabContent( - tab, - openSingletonTab, - openTab, - closeTab, - inPane || activeInline, - (host, filePath) => - openTab(host, "files", { - instanceId: - typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, - restoredSessionId: null, - initialFilePath: filePath, - }), - (host, _path) => openTab(host, "files"), - (host, path) => - openTab(host, "terminal", { - instanceId: - typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, - restoredSessionId: null, - initialFilePath: path, - }), - renameTab, - saveQuickConnectHost, - ), - tabNode, - tab.id, - ); - })} -
-
-
- - {/* Bottom nav bar — mobile only */} - + {/* Skinny icon rail — desktop only, hidden on mobile */} + + + {/* Desktop: inline resizable sidebar */} + {!isMobile && ( +
+ {sidebarHeader} + {sidebarPanelContent} + + {sidebarOpen && !sidebarEditing && ( +
+ )} +
+ )} + + {/* Mobile: sidebar as overlay sheet */} + {isMobile && ( + + + {sidebarHeader} + {sidebarPanelContent} + + + )} + + {/* Main content area */} +
+ {!isMobile && !sidebarOpen && ( + + )} +
+ { + const targetTab = tabs.find((t) => t.id === tabId); + if (targetTab?.host) openTab(targetTab.host, "files"); + }} + onOpenShare={openShareForTab} + isAppFullscreen={isAppFullscreen} + onToggleAppFullscreen={toggleAppFullscreen} + /> +
+ {/* Split view — always mounted when not mobile, hidden via CSS when inactive */} + {!isMobile && ( +
+ +
+ )} + + {/* Normal-view container. Tab nodes are appended here (or to pane elements) + by the DOM-placement effect above. React portals each tab's content + into its stable per-tab node so the component is never remounted. + When split is active, shown on top only if the active tab is not in a pane. */} +
+ {tabs.map((tab) => { + const tabNode = getTabNode(tab.id, tab.type === "terminal"); + const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1; + const inPane = paneIdx !== -1; + const activeInline = !inPane && tab.id === activeTabId; + return createPortal( + renderTabContent( + tab, + openSingletonTab, + openTab, + closeTab, + inPane || activeInline, + (host, filePath) => + openTab(host, "files", { + instanceId: + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, + restoredSessionId: null, + initialFilePath: filePath, + }), + (host, _path) => openTab(host, "files"), + (host, path) => + openTab(host, "terminal", { + instanceId: + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, + restoredSessionId: null, + initialFilePath: path, + }), + renameTab, + saveQuickConnectHost, + ), + tabNode, + tab.id, + ); + })} +
+
+
+ + {/* Bottom nav bar — mobile only */} + +
diff --git a/src/ui/api/host-metrics-api.ts b/src/ui/api/host-metrics-api.ts index f45b89e8..f7e06ee3 100644 --- a/src/ui/api/host-metrics-api.ts +++ b/src/ui/api/host-metrics-api.ts @@ -1,6 +1,15 @@ import { handleApiError, statsApi } from "@/main-axios"; import type { HostMetricsLayout } from "@/types/host-metrics"; +// Every function below is keyed by a host's numeric database id, and the +// receiving backend must own that host in its own database -- a synced +// host has a different numeric id on each side (only its syncId matches +// across them). These calls always target the embedded local backend; see +// getAllServerStatuses in host-metrics-status-api.ts for the one metrics +// call that IS safely merged across local + remote (a process-local, +// in-memory aggregate keyed by whichever host ids that process happens to +// know about, not a per-host lookup). + export interface MetricsHistoryRow { ts: string; cpu_percent: number | null; diff --git a/src/ui/api/host-metrics-status-api.ts b/src/ui/api/host-metrics-status-api.ts index 0a6eebe7..5ae0949f 100644 --- a/src/ui/api/host-metrics-status-api.ts +++ b/src/ui/api/host-metrics-status-api.ts @@ -1,8 +1,31 @@ import axios, { type AxiosRequestConfig } from "axios"; -import { handleApiError, statsApi } from "@/main-axios"; +import { + handleApiError, + statsApi, + getRemoteStatsApi, + isElectron, +} from "@/main-axios"; import type { ServerMetrics, ServerStatus } from "@/main-axios"; import { getCachedServerStatuses } from "@/lib/hosts-request-cache"; +// Metrics collection/viewer registration below (startMetricsPolling, +// registerMetricsViewer, etc.) is NOT origin-routed: the backend that +// receives the call must own the target host by numeric database id, and a +// synced host has a different numeric id in each database (only its +// syncId matches across them). Only the aggregate status read is merged +// across local + remote, same as tunnel status. +async function isRemoteSyncConnected(): Promise { + if (!isElectron()) return false; + try { + const config = (await window.electronAPI?.invoke?.( + "get-remote-sync-config", + )) as { serverUrl?: string } | null; + return !!config?.serverUrl; + } catch { + return false; + } +} + type ApiConnectionLog = { type: "info" | "success" | "warning" | "error"; stage: string; @@ -76,6 +99,7 @@ export async function getAllServerStatuses(): Promise< > { return getCachedServerStatuses(async () => { let lastError: unknown = null; + let localStatuses: Record = {}; for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) { const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i]; @@ -89,7 +113,9 @@ export async function getAllServerStatuses(): Promise< // blips don't look like real outages. __silentRetry: !isFinalAttempt, } as AxiosRequestConfig & { __silentRetry?: boolean }); - return response.data || {}; + localStatuses = response.data || {}; + lastError = null; + break; } catch (error) { lastError = error; if (!isTransientStatusError(error)) { @@ -102,8 +128,24 @@ export async function getAllServerStatuses(): Promise< } } - handleApiError(lastError, "fetch server statuses"); - return {}; + if (lastError) { + handleApiError(lastError, "fetch server statuses"); + return {}; + } + + if (await isRemoteSyncConnected()) { + try { + const remoteResult = await getRemoteStatsApi().get("/status", { + timeout: 8000, + __silentRetry: true, + } as AxiosRequestConfig & { __silentRetry?: boolean }); + return { ...localStatuses, ...(remoteResult.data || {}) }; + } catch { + // remote unreachable this tick -- fall back to local-only statuses + } + } + + return localStatuses; }); } diff --git a/src/ui/api/session-sharing-api.ts b/src/ui/api/session-sharing-api.ts index 1c4a00e7..343a15bc 100644 --- a/src/ui/api/session-sharing-api.ts +++ b/src/ui/api/session-sharing-api.ts @@ -1,7 +1,7 @@ import axios from "axios"; import { getBasePath } from "@/lib/base-path"; import { isElectron } from "@/lib/electron"; -import { authApi, getServerConfig, handleApiError } from "@/main-axios"; +import { authApi, handleApiError } from "@/main-axios"; export interface ResolvedShareLink { protocol: "ssh" | "rdp" | "vnc" | "telnet"; @@ -30,17 +30,18 @@ const isDev = (): boolean => window.location.port === ""); // Guests have no session/JWT, so this deliberately builds a bare base URL -// rather than going through main-axios's authenticated instances. +// rather than going through main-axios's authenticated instances. The +// desktop app always runs its embedded local backend as the source of +// truth, so a share link opened there always resolves against it -- +// joining a session hosted on someone else's remote server isn't +// supported from the desktop app today. async function resolveApiBaseUrl(): Promise { if (isDev()) { const protocol = window.location.protocol === "https:" ? "https" : "http"; return `${protocol}://localhost:30001`; } if (isElectron()) { - const serverConfig = await getServerConfig(); - const configuredUrl = serverConfig?.serverUrl; - if (configuredUrl) return configuredUrl.replace(/\/$/, ""); - return "http://localhost:30001"; + return "http://127.0.0.1:30001"; } return getBasePath(); } diff --git a/src/ui/api/ssh-file-operations-api.ts b/src/ui/api/ssh-file-operations-api.ts index 082f4c96..f2798fe8 100644 --- a/src/ui/api/ssh-file-operations-api.ts +++ b/src/ui/api/ssh-file-operations-api.ts @@ -1,5 +1,13 @@ import axios from "axios"; -import { authApi, fileManagerApi, handleApiError } from "@/main-axios"; +import { + authApi, + fileManagerApi, + handleApiError, + getFileManagerApiForSession, + setSessionOrigin, + clearSessionOrigin, +} from "@/main-axios"; +import { resolveConnectionOrigin } from "@/lib/connection-origin"; import { fileLogger } from "@/lib/frontend-logger"; import type { SSHHost } from "@/types/index"; @@ -72,7 +80,7 @@ export async function connectSSH( }, ): Promise> { try { - const response = await fileManagerApi.post( + const response = await getFileManagerApiForSession(sessionId).post( "/ssh/connect", { sessionId, ...config }, { timeout: 120000 }, @@ -121,12 +129,15 @@ export async function disconnectSSH( sessionId: string, ): Promise> { try { - const response = await fileManagerApi.post("/ssh/disconnect", { - sessionId, - }); + const response = await getFileManagerApiForSession(sessionId).post( + "/ssh/disconnect", + { sessionId }, + ); return response.data; } catch (error) { handleApiError(error, "disconnect SSH"); + } finally { + clearSessionOrigin(sessionId); } } @@ -135,10 +146,10 @@ export async function verifySSHTOTP( totpCode: string, ): Promise> { try { - const response = await fileManagerApi.post("/ssh/connect-totp", { - sessionId, - totpCode, - }); + const response = await getFileManagerApiForSession(sessionId).post( + "/ssh/connect-totp", + { sessionId, totpCode }, + ); return response.data; } catch (error) { handleApiError(error, "verify SSH TOTP"); @@ -149,9 +160,10 @@ export async function verifySSHWarpgate( sessionId: string, ): Promise> { try { - const response = await fileManagerApi.post("/ssh/connect-warpgate", { - sessionId, - }); + const response = await getFileManagerApiForSession(sessionId).post( + "/ssh/connect-warpgate", + { sessionId }, + ); return response.data; } catch (error) { handleApiError(error, "verify SSH Warpgate"); @@ -239,9 +251,10 @@ export async function getSSHStatus( sessionId: string, ): Promise<{ connected: boolean }> { try { - const response = await fileManagerApi.get("/ssh/status", { - params: { sessionId }, - }); + const response = await getFileManagerApiForSession(sessionId).get( + "/ssh/status", + { params: { sessionId } }, + ); return response.data; } catch (error) { handleApiError(error, "get SSH status"); @@ -252,9 +265,10 @@ export async function keepSSHAlive( sessionId: string, ): Promise> { try { - const response = await fileManagerApi.post("/ssh/keepalive", { - sessionId, - }); + const response = await getFileManagerApiForSession(sessionId).post( + "/ssh/keepalive", + { sessionId }, + ); return response.data; } catch (error) { handleApiError(error, "SSH keepalive"); @@ -266,9 +280,10 @@ export async function listSSHFiles( path: string, ): Promise<{ files: unknown[]; path: string }> { try { - const response = await fileManagerApi.get("/ssh/listFiles", { - params: { sessionId, path }, - }); + const response = await getFileManagerApiForSession(sessionId).get( + "/ssh/listFiles", + { params: { sessionId, path } }, + ); return response.data || { files: [], path }; } catch (error) { handleApiError(error, "list SSH files"); @@ -281,9 +296,10 @@ export async function identifySSHSymlink( path: string, ): Promise<{ path: string; target: string; type: "directory" | "file" }> { try { - const response = await fileManagerApi.get("/ssh/identifySymlink", { - params: { sessionId, path }, - }); + const response = await getFileManagerApiForSession(sessionId).get( + "/ssh/identifySymlink", + { params: { sessionId, path } }, + ); return response.data; } catch (error) { handleApiError(error, "identify SSH symlink"); @@ -295,9 +311,10 @@ export async function resolveSSHPath( path: string, ): Promise { try { - const response = await fileManagerApi.get("/ssh/resolvePath", { - params: { sessionId, path }, - }); + const response = await getFileManagerApiForSession(sessionId).get( + "/ssh/resolvePath", + { params: { sessionId, path } }, + ); return response.data?.resolvedPath || path; } catch { return path; @@ -313,9 +330,10 @@ export async function readSSHFile( encoding?: "base64" | "utf8"; }> { try { - const response = await fileManagerApi.get("/ssh/readFile", { - params: { sessionId, path }, - }); + const response = await getFileManagerApiForSession(sessionId).get( + "/ssh/readFile", + { params: { sessionId, path } }, + ); return response.data; } catch (error: unknown) { if (error.response?.status === 404) { @@ -340,13 +358,10 @@ export async function writeSSHFile( userId?: string, ): Promise> { try { - const response = await fileManagerApi.post("/ssh/writeFile", { - sessionId, - path, - content, - hostId, - userId, - }); + const response = await getFileManagerApiForSession(sessionId).post( + "/ssh/writeFile", + { sessionId, path, content, hostId, userId }, + ); if ( response.data && @@ -410,7 +425,7 @@ export async function uploadSSHFile( form.append("totalSize", String(file.size)); form.append("chunk", chunkBlob, fileName); - const response = await fileManagerApi.postForm( + const response = await getFileManagerApiForSession(sessionId).postForm( "/ssh/uploadFileChunk", form, { timeout: 0 }, @@ -444,7 +459,7 @@ export async function uploadSSHFile( if (userId !== undefined) form.append("userId", userId); form.append("file", file, fileName); - const response = await fileManagerApi.postForm( + const response = await getFileManagerApiForSession(sessionId).postForm( "/ssh/uploadFileStream", form, { @@ -464,7 +479,7 @@ export async function downloadSSHFile( userId?: string, ): Promise> { try { - const response = await fileManagerApi.post( + const response = await getFileManagerApiForSession(sessionId).post( "/ssh/downloadFile", { sessionId, @@ -484,7 +499,7 @@ export async function downloadSSHFileStream( sessionId: string, filePath: string, ): Promise { - const response = await fileManagerApi.post( + const response = await getFileManagerApiForSession(sessionId).post( "/ssh/downloadFileStream", { sessionId, path: filePath }, { responseType: "blob", timeout: 0 }, @@ -503,14 +518,10 @@ export async function createSSHFile( userId?: string, ): Promise> { try { - const response = await fileManagerApi.post("/ssh/createFile", { - sessionId, - path, - fileName, - content, - hostId, - userId, - }); + const response = await getFileManagerApiForSession(sessionId).post( + "/ssh/createFile", + { sessionId, path, fileName, content, hostId, userId }, + ); return response.data; } catch (error) { handleApiError(error, "create SSH file"); @@ -525,13 +536,10 @@ export async function createSSHFolder( userId?: string, ): Promise> { try { - const response = await fileManagerApi.post("/ssh/createFolder", { - sessionId, - path, - folderName, - hostId, - userId, - }); + const response = await getFileManagerApiForSession(sessionId).post( + "/ssh/createFolder", + { sessionId, path, folderName, hostId, userId }, + ); return response.data; } catch (error) { handleApiError(error, "create SSH folder"); @@ -546,15 +554,18 @@ export async function deleteSSHItem( userId?: string, ): Promise> { try { - const response = await fileManagerApi.delete("/ssh/deleteItem", { - data: { - sessionId, - path, - isDirectory, - hostId, - userId, + const response = await getFileManagerApiForSession(sessionId).delete( + "/ssh/deleteItem", + { + data: { + sessionId, + path, + isDirectory, + hostId, + userId, + }, }, - }); + ); return response.data; } catch (error) { handleApiError(error, "delete SSH item"); @@ -566,7 +577,7 @@ export async function setSudoPassword( password: string, ): Promise { try { - await fileManagerApi.post("/sudo-password", { + await getFileManagerApiForSession(sessionId).post("/sudo-password", { sessionId, password, }); @@ -583,7 +594,7 @@ export async function copySSHItem( userId?: string, ): Promise> { try { - const response = await fileManagerApi.post( + const response = await getFileManagerApiForSession(sessionId).post( "/ssh/copyItem", { sessionId, @@ -611,13 +622,10 @@ export async function renameSSHItem( userId?: string, ): Promise> { try { - const response = await fileManagerApi.put("/ssh/renameItem", { - sessionId, - oldPath, - newName, - hostId, - userId, - }); + const response = await getFileManagerApiForSession(sessionId).put( + "/ssh/renameItem", + { sessionId, oldPath, newName, hostId, userId }, + ); return response.data; } catch (error) { handleApiError(error, "rename SSH item"); @@ -633,7 +641,7 @@ export async function moveSSHItem( userId?: string, ): Promise> { try { - const response = await fileManagerApi.put( + const response = await getFileManagerApiForSession(sessionId).put( "/ssh/moveItem", { sessionId, @@ -670,13 +678,10 @@ export async function changeSSHPermissions( userId, }); - const response = await fileManagerApi.post("/ssh/changePermissions", { - sessionId, - path, - permissions, - hostId, - userId, - }); + const response = await getFileManagerApiForSession(sessionId).post( + "/ssh/changePermissions", + { sessionId, path, permissions, hostId, userId }, + ); fileLogger.success("SSH file permissions changed successfully", { operation: "change_permissions", @@ -715,13 +720,10 @@ export async function extractSSHArchive( userId, }); - const response = await fileManagerApi.post("/ssh/extractArchive", { - sessionId, - archivePath, - extractPath, - hostId, - userId, - }); + const response = await getFileManagerApiForSession(sessionId).post( + "/ssh/extractArchive", + { sessionId, archivePath, extractPath, hostId, userId }, + ); fileLogger.success("Archive extracted successfully", { operation: "extract_archive", @@ -762,14 +764,17 @@ export async function compressSSHFiles( userId, }); - const response = await fileManagerApi.post("/ssh/compressFiles", { - sessionId, - paths, - archiveName, - format: format || "zip", - hostId, - userId, - }); + const response = await getFileManagerApiForSession(sessionId).post( + "/ssh/compressFiles", + { + sessionId, + paths, + archiveName, + format: format || "zip", + hostId, + userId, + }, + ); fileLogger.success("Files compressed successfully", { operation: "compress_files", @@ -811,6 +816,12 @@ export async function ensureSSHSessionForHost( host: SSHHost, ): Promise { const sessionId = host.id.toString(); + const origin = await resolveConnectionOrigin({ + connectionType: host.connectionType, + connectionOrigin: host.connectionOrigin, + }); + setSessionOrigin(sessionId, origin); + try { const status = await getSSHStatus(sessionId); if (status?.connected) { diff --git a/src/ui/api/tunnel-api.ts b/src/ui/api/tunnel-api.ts index 311dc73f..ec445f43 100644 --- a/src/ui/api/tunnel-api.ts +++ b/src/ui/api/tunnel-api.ts @@ -1,5 +1,11 @@ import axios from "axios"; -import { authApi, handleApiError, tunnelApi } from "@/main-axios"; +import { + authApi, + handleApiError, + tunnelApi, + getRemoteTunnelApi, + isElectron, +} from "@/main-axios"; import type { C2STunnelPreset, TunnelConfig, @@ -9,13 +15,46 @@ import type { // TUNNEL MANAGEMENT // ============================================================================ +// +// Tunnel status is a process-local, in-memory view (no DB lookup) so it's +// safe to read from both the embedded backend and a connected remote server +// and merge the results. connectTunnel/disconnectTunnel/cancelTunnel are +// NOT origin-routed: they resolve the target host by numeric database id +// against whichever backend receives the request, and a synced host has a +// different numeric id in each database (only its syncId matches across +// them) -- routing those calls to a remote backend would need a +// local-id-to-remote-id resolution step that doesn't exist yet. They always +// target the embedded local backend for now. + +async function isRemoteSyncConnected(): Promise { + if (!isElectron()) return false; + try { + const config = (await window.electronAPI?.invoke?.( + "get-remote-sync-config", + )) as { serverUrl?: string } | null; + return !!config?.serverUrl; + } catch { + return false; + } +} export async function getTunnelStatuses(): Promise< Record > { try { - const response = await tunnelApi.get("/tunnel/status"); - return response.data || {}; + const [localResult, remoteConnected] = await Promise.all([ + tunnelApi.get("/tunnel/status"), + isRemoteSyncConnected(), + ]); + const localStatuses = localResult.data || {}; + if (!remoteConnected) return localStatuses; + + try { + const remoteResult = await getRemoteTunnelApi().get("/tunnel/status"); + return { ...localStatuses, ...(remoteResult.data || {}) }; + } catch { + return localStatuses; + } } catch (error) { handleApiError(error, "fetch tunnel statuses"); } @@ -30,9 +69,18 @@ export function subscribeTunnelStatuses( withCredentials: true, }); + let latestLocal: Record = {}; + let latestRemote: Record = {}; + let remotePollTimer: ReturnType | null = null; + + const emitMerged = () => { + onStatuses({ ...latestLocal, ...latestRemote }); + }; + source.addEventListener("statuses", (event) => { try { - onStatuses(JSON.parse(event.data) as Record); + latestLocal = JSON.parse(event.data) as Record; + emitMerged(); } catch { onError?.(); } @@ -42,7 +90,27 @@ export function subscribeTunnelStatuses( onError?.(); }; - return () => source.close(); + // Remote tunnel status has no SSE stream exposed to the desktop app yet, + // so poll it at a modest interval when a remote server is connected. + isRemoteSyncConnected().then((connected) => { + if (!connected) return; + const pollRemote = async () => { + try { + const result = await getRemoteTunnelApi().get("/tunnel/status"); + latestRemote = result.data || {}; + emitMerged(); + } catch { + // remote unreachable this tick -- keep last known remote statuses + } + }; + pollRemote(); + remotePollTimer = setInterval(pollRemote, 5000); + }); + + return () => { + source.close(); + if (remotePollTimer) clearInterval(remotePollTimer); + }; } export async function getTunnelStatusByName( diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index b34a4c48..8dd6f611 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -29,17 +29,13 @@ import { completePasswordReset, getOIDCAuthorizeUrl, verifyTOTPLogin, - getServerConfig, - saveServerConfig, isElectron, - getEmbeddedServerStatus, getCurrentToken, getOidcSilentLoginDefault, + requestDesktopAutoSession, } from "@/main-axios"; import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api"; import type { SSOProviderPublic } from "@/types/index"; -import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig"; -import { ElectronLoginForm } from "@/auth/ElectronLoginForm"; import { Checkbox } from "@/components/checkbox"; import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n"; import { @@ -263,13 +259,22 @@ export function Auth({ onLogin }: AuthProps) { const [firstUser, setFirstUser] = useState(false); const [dbConnectionFailed, setDbConnectionFailed] = useState(false); const [dbHealthChecking, setDbHealthChecking] = useState(true); - - const [showServerConfig, setShowServerConfig] = useState( - null, - ); - const [currentServerUrl, setCurrentServerUrl] = useState(""); const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false); + // Electron, non-iframed only: the desktop app never shows a login form + // when running standalone -- the embedded backend auto-provisions a + // single local user on first boot, and this component silently exchanges + // that for a session instead of rendering login/register. + // null = probe still in flight (Electron only, blocks rendering below). + // true = probe settled with no auto-login (not applicable outside + // Electron, multiple users exist, or setup is genuinely required) -- + // safe to fall through to the normal form/health-check flow. + // Auto-login success never sets this; it calls onLogin directly and this + // component unmounts. + const [desktopAutoSessionDone, setDesktopAutoSessionDone] = useState< + boolean | null + >(!isElectron() || isInElectronWebView() ? true : null); + useEffect(() => { try { localStorage.setItem("rememberMe", rememberMe.toString()); @@ -320,7 +325,11 @@ export function Auth({ onLogin }: AuthProps) { }, []); useEffect(() => { - if (showServerConfig) return; + // Runs once the auto-session probe has settled (immediately outside + // Electron, since it starts at true there; after the probe resolves in + // Electron). Waiting avoids flashing a login screen the user is about + // to skip past via auto-login. + if (desktopAutoSessionDone !== true) return; setDbHealthChecking(true); getSetupRequired() .then((res) => { @@ -332,53 +341,28 @@ export function Auth({ onLogin }: AuthProps) { }) .catch(() => setDbConnectionFailed(true)) .finally(() => setDbHealthChecking(false)); - }, [showServerConfig]); + }, [desktopAutoSessionDone]); useEffect(() => { - const checkElectron = async () => { - if (isInElectronWebView()) { - setShowServerConfig(false); - return; - } - if (isElectron()) { - const forceShow = localStorage.getItem("termix_show_server_config"); - if (forceShow === "true") { - localStorage.removeItem("termix_show_server_config"); - try { - const config = await getServerConfig(); - setCurrentServerUrl(config?.serverUrl || ""); - } catch { - // ignore - } - setShowServerConfig(true); + if (desktopAutoSessionDone !== null) return; + let cancelled = false; + requestDesktopAutoSession() + .then((res) => { + if (cancelled) return; + if (res?.success) { + storeAuth(res.username || ""); + onLogin(res.username || "", res.userId || undefined, !!res.is_admin); return; } - try { - const [config, status] = await Promise.all([ - getServerConfig(), - getEmbeddedServerStatus(), - ]); - if ( - status?.embedded && - status?.running && - config && - !config.serverUrl - ) { - setShowServerConfig(false); - setCurrentServerUrl(""); - return; - } - setCurrentServerUrl(config?.serverUrl || ""); - setShowServerConfig(!config || !config.serverUrl); - } catch { - setShowServerConfig(true); - } - } else { - setShowServerConfig(false); - } + setDesktopAutoSessionDone(true); + }) + .catch(() => { + if (!cancelled) setDesktopAutoSessionDone(true); + }); + return () => { + cancelled = true; }; - checkElectron(); - }, []); + }, [desktopAutoSessionDone, onLogin]); useEffect(() => { if (view === "totp" && totpInputRef.current) totpInputRef.current.focus(); @@ -474,36 +458,6 @@ export function Auth({ onLogin }: AuthProps) { } }, [onLogin, t]); - const handleElectronAuthSuccess = useCallback( - async (token: string | null) => { - try { - if (!token) { - // No token in postMessage — fall back to waiting for the HttpOnly cookie - const cookieReady = await window.electronAPI?.waitForSessionCookie?.( - "jwt", - currentServerUrl, - null, - 5000, - ); - if (cookieReady && !cookieReady.success) - throw new Error(cookieReady.error || "Auth cookie not ready"); - } - const meRes = await getUserInfo(); - if (!meRes) throw new Error("Failed to get user info"); - storeAuth(meRes.username || ""); - onLogin( - meRes.username || "", - meRes.userId || undefined, - !!meRes.is_admin, - ); - toast.success(t("messages.loginSuccess")); - } catch { - toast.error(t("errors.failedUserInfo")); - } - }, - [onLogin, currentServerUrl, t], - ); - function resetAll() { setUsername(""); setPassword(""); @@ -935,46 +889,19 @@ export function Auth({ onLogin }: AuthProps) { oidcSilentLoginDefaultLoaded, ]); - // Electron server config / webview auth success screens - if (isElectron() && !isInElectronWebView()) { - if (showServerConfig === null) - return ( -
-
-
- ); - if (showServerConfig) - return ( -
-
- window.location.reload()} - onUseEmbedded={async () => { - await saveServerConfig({ - serverUrl: "", - lastUpdated: new Date().toISOString(), - }); - setShowServerConfig(false); - setCurrentServerUrl(""); - }} - onCancel={() => setShowServerConfig(false)} - isFirstTime={!currentServerUrl} - /> -
-
- ); - if (!webviewAuthSuccess && showServerConfig === false && currentServerUrl) - return ( -
-
- setShowServerConfig(true)} - /> -
-
- ); + // Electron, non-iframed: wait for the auto-session probe before rendering + // anything, so a standalone desktop install never flashes a login form + // it's about to skip past. + if ( + isElectron() && + !isInElectronWebView() && + desktopAutoSessionDone === null + ) { + return ( +
+
+
+ ); } if (webviewAuthSuccess || (isInElectronWebView() && webviewAuthSuccess)) @@ -1018,30 +945,11 @@ export function Auth({ onLogin }: AuthProps) { ))}
- {isElectron() && currentServerUrl && ( -
-
- - {t("serverConfig.serverUrl")} - - - {currentServerUrl} - -
- -
- )}
); - if (dbHealthChecking && showServerConfig === false) + if (dbHealthChecking) return (
@@ -1068,21 +976,6 @@ export function Auth({ onLogin }: AuthProps) { return (
- {isElectron() && !isInElectronWebView() && showServerConfig === false && ( -
- - - {t("serverConfig.localServer")} - -
-
- )}
{/* Left decorative panel */}
diff --git a/src/ui/auth/ElectronLoginForm.tsx b/src/ui/auth/ElectronLoginForm.tsx index b49cb358..57c81294 100644 --- a/src/ui/auth/ElectronLoginForm.tsx +++ b/src/ui/auth/ElectronLoginForm.tsx @@ -7,6 +7,12 @@ interface ElectronLoginFormProps { serverUrl: string; onAuthSuccess: (token: string | null) => void | Promise; onChangeServer: () => void; + // "local" (default): the app's own login, JWT goes to localStorage like + // every other client. "remoteSync": this iframe is authenticating a + // Settings-triggered connection to a remote Termix server for the sync + // engine -- the JWT is handed to the Electron main process's encrypted + // store instead, never exposed to the renderer's localStorage. + targetPurpose?: "local" | "remoteSync"; } const AUTH_MESSAGE_SOURCES = new Set([ @@ -19,6 +25,7 @@ export function ElectronLoginForm({ serverUrl, onAuthSuccess, onChangeServer, + targetPurpose = "local", }: ElectronLoginFormProps) { const { t } = useTranslation(); const [loading, setLoading] = useState(true); @@ -43,7 +50,11 @@ export function ElectronLoginForm({ try { if (token) { - localStorage.setItem("jwt", token); + if (targetPurpose === "remoteSync") { + await window.electronAPI?.invoke?.("save-remote-sync-jwt", token); + } else { + localStorage.setItem("jwt", token); + } } await onAuthSuccessRef.current(token); } catch { @@ -53,7 +64,7 @@ export function ElectronLoginForm({ hasAuthenticatedRef.current = false; } }, - [t], + [t, targetPurpose], ); // postMessage from server Auth.tsx after the backend has set the HttpOnly cookie. diff --git a/src/ui/auth/ElectronServerConfig.tsx b/src/ui/auth/ElectronServerConfig.tsx index eef3ff1a..24acf3d9 100644 --- a/src/ui/auth/ElectronServerConfig.tsx +++ b/src/ui/auth/ElectronServerConfig.tsx @@ -9,7 +9,6 @@ import { getServerConfig, saveServerConfig, getEmbeddedServerStatus, - setEmbeddedMode, type ServerConfig, } from "@/main-axios.ts"; import { Server, Monitor, Loader2, ChevronDown, X } from "lucide-react"; @@ -103,7 +102,7 @@ export function ElectronServerConfig({ const checkEmbeddedBackend = async () => { try { const status = await getEmbeddedServerStatus(); - setEmbeddedAvailable(!!status?.embedded); + setEmbeddedAvailable(!!status?.running); } catch { setEmbeddedAvailable(true); } @@ -144,7 +143,6 @@ export function ElectronServerConfig({ const maxRetries = 15; for (let i = 0; i < maxRetries; i++) { if (await probeBackend()) { - setEmbeddedMode(true); if (onUseEmbedded) { onUseEmbedded(); } else { diff --git a/src/ui/auth/LoginPage.tsx b/src/ui/auth/LoginPage.tsx index 4efd8497..e19264f2 100644 --- a/src/ui/auth/LoginPage.tsx +++ b/src/ui/auth/LoginPage.tsx @@ -24,10 +24,8 @@ import { completePasswordReset, getOIDCAuthorizeUrl, verifyTOTPLogin, - getServerConfig, saveServerConfig, isElectron, - getEmbeddedServerStatus, getCurrentToken, getOidcSilentLoginDefault, } from "@/main-axios"; @@ -1019,41 +1017,14 @@ export function Auth({ }, [dbConnectionFailed, t]); useEffect(() => { - const checkServerConfig = async () => { - if (isInElectronWebView()) { - setShowServerConfig(false); - return; - } - - if (isElectron()) { - try { - const [config, status] = await Promise.all([ - getServerConfig(), - getEmbeddedServerStatus(), - ]); - - if ( - status?.embedded && - status?.running && - config && - !config.serverUrl - ) { - setCurrentServerUrl(""); - setShowServerConfig(false); - return; - } - - setCurrentServerUrl(config?.serverUrl || ""); - setShowServerConfig(!config || !config.serverUrl); - } catch { - setShowServerConfig(true); - } - } else { - setShowServerConfig(false); - } - }; - - checkServerConfig(); + // The desktop app always runs its embedded local backend as the source + // of truth now -- there is no more server-config startup gate here or + // in the top-level Auth.tsx. This component only still renders (a) when + // iframed by ElectronLoginForm as a *remote* server's own web root, in + // which case isInElectronWebView() is true and this branch is skipped + // anyway, or (b) as a re-auth fallback for popped-out fullscreen + // sub-app windows, which should also never show a server picker. + setShowServerConfig(false); }, []); if (showServerConfig === null && !isInElectronWebView()) { diff --git a/src/ui/components/RemoteSyncBanner.tsx b/src/ui/components/RemoteSyncBanner.tsx new file mode 100644 index 00000000..f554ac47 --- /dev/null +++ b/src/ui/components/RemoteSyncBanner.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertTriangle } from "lucide-react"; +import { isElectron } from "@/lib/electron"; + +interface RemoteSyncStatus { + connected: boolean; + syncing: boolean; + lastSyncedAt: string | null; + lastError: string | null; + needsReauth: boolean; +} + +// Non-blocking banner shown when a connected remote sync server needs +// re-authentication. Never gates or hides any other UI -- the local app +// keeps working fully regardless of remote sync state. +export function RemoteSyncBanner({ onReconnect }: { onReconnect: () => void }) { + const { t } = useTranslation(); + const [status, setStatus] = useState(null); + + useEffect(() => { + if (!isElectron()) return; + window.electronAPI + ?.invoke?.("get-remote-sync-status") + .then((s) => setStatus((s as RemoteSyncStatus) ?? null)) + .catch(() => {}); + const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.( + (nextStatus: RemoteSyncStatus) => setStatus(nextStatus), + ); + return () => unsubscribe?.(); + }, []); + + if (!status?.connected || !status.needsReauth) return null; + + return ( +
+
+ + {t("remoteSync.bannerMessage")} +
+ +
+ ); +} diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index 50154591..fddd09bc 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -43,6 +43,7 @@ import { getServiceLinks, createServiceLink, deleteServiceLink, + isElectron, } from "@/main-axios"; import type { RecentActivityItem, ServiceLink } from "@/main-axios"; import { useTranslation } from "react-i18next"; @@ -1365,7 +1366,13 @@ export function DashboardTab({ load(); getUserInfo() - .then((info) => setIsAdmin(!!info.is_admin)) + .then((info) => { + // Remote sync is not yet configurable (added in a later phase), so + // a standalone desktop install never shows admin/user-management + // UI -- it has exactly one implicit user and nothing to administer. + const isRemoteSyncConnected = false; + setIsAdmin(!!info.is_admin && (!isElectron() || isRemoteSyncConnected)); + }) .catch(() => {}); getUptime() .then((u) => setUptimeFormatted(u.formatted)) diff --git a/src/ui/features/docker/components/ConsoleTerminal.tsx b/src/ui/features/docker/components/ConsoleTerminal.tsx index b4f1a607..5d3f2c9f 100644 --- a/src/ui/features/docker/components/ConsoleTerminal.tsx +++ b/src/ui/features/docker/components/ConsoleTerminal.tsx @@ -15,6 +15,10 @@ import { } from "@/components/select.tsx"; import { Card, CardContent } from "@/components/card.tsx"; import { getBasePath } from "@/lib/base-path"; +import { + resolveConnectionOrigin, + buildOriginWsUrl, +} from "@/lib/connection-origin.ts"; import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react"; import { toast } from "sonner"; import type { SSHHost } from "@/types"; @@ -265,7 +269,7 @@ export function ConsoleTerminal({ } }, [terminal]); - const connect = React.useCallback(() => { + const connect = React.useCallback(async () => { if (!terminal || containerState !== "running") { toast.error(t("docker.containerMustBeRunning")); return; @@ -287,20 +291,30 @@ export function ConsoleTerminal({ window.location.port === "5173" || window.location.port === ""); - const baseWsUrl = isDev - ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009` - : isElectronApp - ? (() => { - const baseUrl = - (window as { configuredServerUrl?: string }) - .configuredServerUrl || "http://127.0.0.1:30001"; - const wsProtocol = baseUrl.startsWith("https://") - ? "wss://" - : "ws://"; - const wsHost = baseUrl.replace(/^https?:\/\//, ""); - return `${wsProtocol}${wsHost}/docker/console/`; - })() - : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`; + let baseWsUrl: string; + if (isDev) { + baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`; + } else if (isElectronApp) { + const origin = await resolveConnectionOrigin({ + connectionType: "ssh", + connectionOrigin: hostConfig.connectionOrigin, + }); + const resolvedUrl = await buildOriginWsUrl({ + origin, + localPort: 30009, + localPath: "/docker/console/", + remotePath: "/docker/console/", + includeLocalJwt: false, + }); + if (!resolvedUrl) { + setIsConnecting(false); + toast.error(t("errors.remoteServerRequired")); + return; + } + baseWsUrl = resolvedUrl; + } else { + baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`; + } const ws = new WebSocket(baseWsUrl); diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/src/ui/features/guacamole/GuacamoleApp.tsx index 3555c272..69656bf3 100644 --- a/src/ui/features/guacamole/GuacamoleApp.tsx +++ b/src/ui/features/guacamole/GuacamoleApp.tsx @@ -15,7 +15,9 @@ import { getGuacdStatus, getSSHHosts, logActivity, + isElectron, } from "@/main-axios.ts"; +import { resolveConnectionOrigin } from "@/lib/connection-origin.ts"; import { useTranslation } from "react-i18next"; import { AlertCircle, RefreshCw } from "lucide-react"; import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx"; @@ -172,19 +174,34 @@ const GuacamoleAppInner = React.forwardRef< setToken(null); setGuacamoleConnectionId(null); setError(null); - getGuacdStatus() - .then((status) => { + + (async () => { + if (isElectron()) { + const origin = await resolveConnectionOrigin({ + connectionType: resolvedProtocolForConnect, + }); + if (origin === "remote") { + const remoteConfig = (await window.electronAPI?.invoke?.( + "get-remote-sync-config", + )) as { serverUrl?: string } | null; + if (!remoteConfig?.serverUrl) { + setError(t("errors.remoteServerRequired")); + return; + } + } + } + + try { + const status = await getGuacdStatus(); if (status.guacd.status !== "connected") { setError(t("guacamole.guacdUnavailable")); return; } - return getGuacamoleTokenFromHost( + const result = await getGuacamoleTokenFromHost( hostId, protocol, promptedCredentials ?? undefined, ); - }) - .then((result) => { if (result) { setToken(result.token); setGuacamoleConnectionId(result.guacamoleConnectionId ?? null); @@ -192,8 +209,12 @@ const GuacamoleAppInner = React.forwardRef< () => {}, ); } - }) - .catch((err) => setError(err?.message || t("guacamole.failedToConnect"))); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : t("guacamole.failedToConnect"); + setError(message || t("guacamole.failedToConnect")); + } + })(); }, [ hostId, hostName, diff --git a/src/ui/features/guacamole/GuacamoleDisplay.tsx b/src/ui/features/guacamole/GuacamoleDisplay.tsx index c6e75479..502b5817 100644 --- a/src/ui/features/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/features/guacamole/GuacamoleDisplay.tsx @@ -8,10 +8,14 @@ import { } from "react"; import Guacamole from "guacamole-common-js"; import { useTranslation } from "react-i18next"; -import { getGuacamoleToken, isElectron, isEmbeddedMode } from "@/main-axios.ts"; +import { getGuacamoleToken, isElectron } from "@/main-axios.ts"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { getBasePath } from "@/lib/base-path.ts"; import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts"; +import { + resolveConnectionOrigin, + buildOriginWsUrl, +} from "@/lib/connection-origin.ts"; import { isFirefoxBrowser, isPasteShortcut, @@ -169,15 +173,31 @@ export const GuacamoleDisplay = forwardRef< connectionConfig.dpi, ); - const wsBase = buildGuacamoleWebSocketBaseUrl({ - isDev, - isElectronApp: isElectron(), - isEmbeddedApp: isEmbeddedMode(), - configuredServerUrl: (window as { configuredServerUrl?: string }) - .configuredServerUrl, - basePath: getBasePath(), - location: window.location, - }); + let wsBase: string | null; + if (isElectron()) { + const origin = await resolveConnectionOrigin({ + connectionType: connectionProtocol, + }); + wsBase = await buildOriginWsUrl({ + origin, + localPort: 30008, + localPath: "/guacamole/websocket/", + remotePath: "/guacamole/websocket/", + includeLocalJwt: false, + }); + if (!wsBase) { + onError?.(t("errors.remoteServerRequired")); + return null; + } + } else { + wsBase = buildGuacamoleWebSocketBaseUrl({ + isDev, + isElectronApp: false, + isEmbeddedApp: false, + basePath: getBasePath(), + location: window.location, + }); + } const params = new URLSearchParams({ token, @@ -193,7 +213,7 @@ export const GuacamoleDisplay = forwardRef< return null; } }, - [connectionConfig, onError], + [connectionConfig, onError, t], ); const refreshKeyboardHandlers = useCallback(() => { diff --git a/src/ui/features/serial/Serial.tsx b/src/ui/features/serial/Serial.tsx index 828aba1b..851abb15 100644 --- a/src/ui/features/serial/Serial.tsx +++ b/src/ui/features/serial/Serial.tsx @@ -10,7 +10,6 @@ import { FitAddon } from "@xterm/addon-fit"; import { useTranslation } from "react-i18next"; import { TriangleAlert } from "lucide-react"; import { isElectron } from "@/lib/electron"; -import { isEmbeddedMode } from "@/main-axios"; import { useTheme } from "@/components/theme-provider"; import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme"; import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes"; @@ -101,30 +100,10 @@ export const Serial = forwardRef(function Serial( // ── WebSocket (Electron) path ────────────────────────────────────────── const buildWsUrl = useCallback(() => { - const isDev = - !isElectron() && - process.env.NODE_ENV === "development" && - (window.location.port === "3000" || - window.location.port === "5173" || - window.location.port === ""); - - if (isDev || isEmbeddedMode()) { - const token = localStorage.getItem("jwt"); - const base = "ws://127.0.0.1:30011"; - return token ? `${base}?token=${encodeURIComponent(token)}` : base; - } - - const configuredUrl = (window as { configuredServerUrl?: string | null }) - .configuredServerUrl; - - if (!configuredUrl) return null; - - const wsProtocol = configuredUrl.startsWith("https://") - ? "wss://" - : "ws://"; - const wsHost = configuredUrl.replace(/^https?:\/\//, "").replace(/\/$/, ""); + // Serial is always local -- the device is physically attached to this + // desktop machine, so it never routes through a remote server. const token = localStorage.getItem("jwt"); - const base = `${wsProtocol}${wsHost}/serial/websocket/`; + const base = "ws://127.0.0.1:30011"; return token ? `${base}?token=${encodeURIComponent(token)}` : base; }, []); diff --git a/src/ui/features/session-sharing/SharedSessionView.tsx b/src/ui/features/session-sharing/SharedSessionView.tsx index 8f757dae..8827b5ca 100644 --- a/src/ui/features/session-sharing/SharedSessionView.tsx +++ b/src/ui/features/session-sharing/SharedSessionView.tsx @@ -11,7 +11,6 @@ import { import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { getBasePath } from "@/lib/base-path"; import { isElectron } from "@/lib/electron"; -import { getServerConfig } from "@/main-axios"; import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx"; const PING_INTERVAL_MS = 30000; @@ -24,6 +23,9 @@ interface TerminalWsMessage { // Mirrors Terminal.tsx's baseWsUrl construction (dev/electron/embedded/prod). // Duplicated rather than extracted from that file to avoid touching it here. +// A shared session link is always resolved against the desktop app's +// embedded local backend -- joining a session hosted on someone else's +// remote server isn't supported from the desktop app today. async function resolveTerminalWsBaseUrl(): Promise { const isDev = !isElectron() && @@ -36,17 +38,6 @@ async function resolveTerminalWsBaseUrl(): Promise { return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`; } if (isElectron()) { - const serverConfig = await getServerConfig(); - const configuredUrl = serverConfig?.serverUrl; - if (configuredUrl) { - const wsProtocol = configuredUrl.startsWith("https://") - ? "wss://" - : "ws://"; - const wsHost = configuredUrl - .replace(/^https?:\/\//, "") - .replace(/\/$/, ""); - return `${wsProtocol}${wsHost}/ssh/websocket/`; - } return "ws://127.0.0.1:30002"; } const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws"; diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 081ef725..5df3c84e 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -17,16 +17,18 @@ import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { useTranslation } from "react-i18next"; import { getBasePath } from "@/lib/base-path"; +import { + resolveConnectionOrigin, + buildOriginWsUrl, +} from "@/lib/connection-origin.ts"; import { getCookie, isElectron, - isEmbeddedMode, logActivity, getSnippets, deleteCommandFromHistory, getCommandHistory, getHostPassword, - getServerConfig, } from "@/main-axios.ts"; import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx"; import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx"; @@ -973,52 +975,28 @@ const TerminalInner = forwardRef( if (isDev) { baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`; } else if (isElectron()) { - let configuredUrl = (window as { configuredServerUrl?: string | null }) - .configuredServerUrl; - - if (!configuredUrl && !isEmbeddedMode()) { - try { - const serverConfig = await getServerConfig(); - configuredUrl = serverConfig?.serverUrl || null; - if (configuredUrl) { - ( - window as Window & - typeof globalThis & { - configuredServerUrl?: string | null; - } - ).configuredServerUrl = configuredUrl; - } - } catch (error) { - console.error("Failed to resolve Electron server URL:", error); - } - } - - if (isEmbeddedMode()) { - baseWsUrl = "ws://127.0.0.1:30002"; - const storedJwt = localStorage.getItem("jwt"); - if (storedJwt) { - baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`; - } - } else if (!configuredUrl) { - console.error("No configured server URL available for Electron SSH"); + const origin = await resolveConnectionOrigin({ + connectionType: "ssh", + connectionOrigin: hostConfig.connectionOrigin as + | "local" + | "remote" + | null + | undefined, + }); + const resolvedUrl = await buildOriginWsUrl({ + origin, + localPort: 30002, + localPath: "", + remotePath: "/ssh/websocket/", + }); + if (!resolvedUrl) { setIsConnected(false); setIsConnecting(false); - updateConnectionError(t("errors.failedToLoadServer")); + updateConnectionError(t("errors.remoteServerRequired")); isConnectingRef.current = false; return; - } else { - const wsProtocol = configuredUrl.startsWith("https://") - ? "wss://" - : "ws://"; - const wsHost = configuredUrl - .replace(/^https?:\/\//, "") - .replace(/\/$/, ""); - baseWsUrl = `${wsProtocol}${wsHost}/ssh/websocket/`; - const storedJwt = localStorage.getItem("jwt"); - if (storedJwt) { - baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`; - } } + baseWsUrl = resolvedUrl; } else { baseWsUrl = `${getBasePath()}/ssh/websocket/`; } diff --git a/src/ui/lib/connection-origin.ts b/src/ui/lib/connection-origin.ts new file mode 100644 index 00000000..3cc08045 --- /dev/null +++ b/src/ui/lib/connection-origin.ts @@ -0,0 +1,112 @@ +import { isElectron } from "@/lib/electron"; + +export type ConnectionOrigin = "local" | "remote"; + +interface OriginResolvableHost { + connectionType?: string | null; + connectionOrigin?: ConnectionOrigin | null; +} + +/** + * Resolves which backend a given host's interactive connection (SSH, + * Docker console, Serial) should dial: the desktop app's embedded local + * backend, or a connected remote sync server. + * + * RDP/VNC/Telnet always resolve to "remote" -- guacd isn't bundled with the + * embedded backend. Serial always resolves to "local" -- the hardware is + * physically attached to this desktop machine. Everything else follows the + * host's own override if set, falling back to the desktop-wide default. + */ +export async function resolveConnectionOrigin( + host: OriginResolvableHost, +): Promise { + if ( + host.connectionType === "rdp" || + host.connectionType === "vnc" || + host.connectionType === "telnet" + ) { + return "remote"; + } + if (host.connectionType === "serial") { + return "local"; + } + if (!isElectron()) { + return "local"; + } + if (host.connectionOrigin === "local" || host.connectionOrigin === "remote") { + return host.connectionOrigin; + } + + try { + const settings = (await window.electronAPI?.invoke?.( + "get-desktop-settings", + )) as { defaultConnectionOrigin?: ConnectionOrigin } | null; + return settings?.defaultConnectionOrigin === "remote" ? "remote" : "local"; + } catch { + return "local"; + } +} + +export interface RemoteConnectionTarget { + serverUrl: string; + jwt: string | null; +} + +async function getRemoteConnectionTarget(): Promise { + try { + const [config, jwt] = await Promise.all([ + window.electronAPI?.invoke?.("get-remote-sync-config") as Promise<{ + serverUrl?: string; + } | null>, + window.electronAPI?.invoke?.("get-remote-sync-jwt") as Promise< + string | null + >, + ]); + if (!config?.serverUrl) return null; + return { serverUrl: config.serverUrl, jwt: jwt ?? null }; + } catch { + return null; + } +} + +/** + * Builds the base WebSocket URL for an interactive connection protocol, + * given a resolved origin. Returns null when origin is "remote" but no + * remote server is connected -- callers must show a blocking message + * rather than attempting to connect. + */ +export async function buildOriginWsUrl({ + origin, + localPort, + localPath, + remotePath, + includeLocalJwt = true, +}: { + origin: ConnectionOrigin; + localPort: number; + localPath: string; + remotePath: string; + includeLocalJwt?: boolean; +}): Promise { + if (origin === "local") { + let url = `ws://127.0.0.1:${localPort}${localPath}`; + if (includeLocalJwt) { + const token = localStorage.getItem("jwt"); + if (token) url += `?token=${encodeURIComponent(token)}`; + } + return url; + } + + const remote = await getRemoteConnectionTarget(); + if (!remote) return null; + + const wsProtocol = remote.serverUrl.startsWith("https://") + ? "wss://" + : "ws://"; + const wsHost = remote.serverUrl + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); + let url = `${wsProtocol}${wsHost}${remotePath}`; + if (remote.jwt) url += `?token=${encodeURIComponent(remote.jwt)}`; + return url; +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 89e3baa5..d3ec87f1 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -389,6 +389,37 @@ "noSavedServers": "No saved servers", "removeServer": "Remove" }, + "remoteSync": { + "title": "Remote Sync", + "description": "Optionally connect this desktop app to a self-hosted Termix server to sync your hosts, credentials, and snippets across devices. The app always works fully offline whether or not you connect.", + "notConnected": "Not connected", + "connected": "Connected", + "connectedTo": "Connected to {{url}}", + "lastSynced": "Last synced {{time}}", + "neverSynced": "Never synced", + "syncError": "Sync error: {{message}}", + "needsReauth": "Sign-in expired", + "connectButton": "Connect to Server", + "disconnectButton": "Disconnect", + "syncNowButton": "Sync Now", + "syncing": "Syncing...", + "serverUrl": "Server URL", + "enterServerUrl": "Please enter a server URL", + "mustIncludeProtocol": "Server URL must start with http:// or https://", + "allowInvalidCertificate": "Allow invalid certificate", + "allowInvalidCertificateDesc": "Use only for trusted self-hosted servers with self-signed or IP-address certificates.", + "savedServers": "Saved Servers", + "removeServer": "Remove", + "continueButton": "Continue", + "cancelButton": "Cancel", + "signInTitle": "Sign in to {{url}}", + "originTitle": "Connection Origin", + "originDescription": "Choose where SSH connections originate from by default. This can be overridden per host.", + "originLocal": "This device (local network)", + "originRemote": "Remote server", + "bannerReconnect": "Reconnect", + "bannerMessage": "Remote sync needs re-authentication" + }, "versionCheck": { "error": "Version Check Error", "checkFailed": "Failed to check for updates", @@ -651,6 +682,11 @@ "delayAfterMs": "Delay After (ms)", "useSocks5Proxy": "Use SOCKS5 Proxy", "useSocks5ProxyDesc": "Route connection through a proxy server", + "connectionOrigin": "Connection Origin", + "connectionOriginDesc": "Where this host's SSH connection originates from. Overrides the desktop app's global default.", + "connectionOriginDefault": "Use default", + "connectionOriginLocal": "This device (local network)", + "connectionOriginRemote": "Remote server", "proxyHost": "Proxy Host", "proxyPort": "Proxy Port", "proxyUsername": "Proxy Username", @@ -2360,7 +2396,8 @@ "resetCodeRateLimited": "Rate limited: Too many verification attempts. Please try again later.", "resetCodeRateLimitedWithTime": "Rate limited: Too many verification attempts. Please wait {{time}} seconds before trying again.", "authTokenSaveFailed": "Failed to save authentication token", - "failedToLoadServer": "Failed to load server" + "failedToLoadServer": "Failed to load server", + "remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type." }, "messages": { "registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 6d223c2f..b0998ba2 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -51,7 +51,6 @@ import { tunnelLogger, fileLogger, statsLogger, - systemLogger, dashboardLogger, type LogContext, } from "@/lib/frontend-logger"; @@ -646,8 +645,6 @@ function isDev(): boolean { } const apiHost = import.meta.env.VITE_API_HOST || "localhost"; -let configuredServerUrl: string | null = null; -let embeddedMode = false; export interface ServerConfig { serverUrl: string; @@ -665,6 +662,13 @@ interface AxiosErrorExtended extends AxiosError { config?: AxiosRequestConfigExtended; } +/** + * Reads the remote-sync connection record (repurposed from the old + * server-config.json startup-gate file). Not used to route the app's own + * API instances anymore -- see getApiUrl, which always targets the + * embedded backend in Electron. This is only consumed by the Remote Sync + * settings panel (Phase 3). + */ export async function getServerConfig(): Promise { if (!isElectron()) return null; @@ -674,7 +678,6 @@ export async function getServerConfig(): Promise { typeof globalThis & { IS_ELECTRON?: boolean; electronAPI?: unknown; - configuredServerUrl?: string; } ).electronAPI?.invoke("get-server-config"); return result; @@ -693,33 +696,15 @@ export async function saveServerConfig(config: ServerConfig): Promise { typeof globalThis & { IS_ELECTRON?: boolean; electronAPI?: unknown; - configuredServerUrl?: string; } ).electronAPI?.invoke("save-server-config", config); - if (result?.success) { - configuredServerUrl = config.serverUrl; - ( - window as Window & - typeof globalThis & { - IS_ELECTRON?: boolean; - electronAPI?: unknown; - configuredServerUrl?: string; - } - ).configuredServerUrl = configuredServerUrl; - updateApiInstances(); - return true; - } - return false; + return !!result?.success; } catch (error) { console.error("Failed to save server config:", error); return false; } } -export function getConfiguredServerUrl(): string | null { - return configuredServerUrl; -} - export async function testServerConnection( serverUrl: string, ): Promise<{ success: boolean; error?: string }> { @@ -732,7 +717,6 @@ export async function testServerConnection( typeof globalThis & { IS_ELECTRON?: boolean; electronAPI?: unknown; - configuredServerUrl?: string; } ).electronAPI?.invoke("test-server-connection", serverUrl); return result; @@ -767,7 +751,6 @@ export async function checkElectronUpdate(): Promise<{ typeof globalThis & { IS_ELECTRON?: boolean; electronAPI?: unknown; - configuredServerUrl?: string; } ).electronAPI?.invoke("check-electron-update"); return result; @@ -777,9 +760,13 @@ export async function checkElectronUpdate(): Promise<{ } } +/** + * Reports whether the local embedded backend process is alive. The desktop + * app always runs the embedded backend now, so this is purely a liveness + * check -- it no longer reflects a "mode" the app could be in. + */ export async function getEmbeddedServerStatus(): Promise<{ running: boolean; - embedded: boolean; dataDir: string | null; } | null> { if (!isElectron()) return null; @@ -796,7 +783,6 @@ export async function getEmbeddedServerStatus(): Promise<{ ).electronAPI?.invoke("get-embedded-server-status"); return result as { running: boolean; - embedded: boolean; dataDir: string | null; } | null; } catch { @@ -804,33 +790,16 @@ export async function getEmbeddedServerStatus(): Promise<{ } } -export function isEmbeddedMode(): boolean { - return embeddedMode; -} - -export function setEmbeddedMode(value: boolean): void { - embeddedMode = value; - if (value) { - configuredServerUrl = null; - initializeApiInstances(); - } -} - function getApiUrl(path: string, defaultPort: number): string { const devMode = isDev(); const electronMode = isElectron(); if (electronMode) { - if (embeddedMode && !configuredServerUrl) { - return `http://localhost:${defaultPort}${path}`; - } - if (configuredServerUrl) { - const baseUrl = configuredServerUrl.replace(/\/$/, ""); - const url = `${baseUrl}${path}`; - return url; - } - console.warn("Electron mode but no server configured!"); - return "http://no-server-configured"; + // The desktop app always runs its embedded local backend as the + // source of truth. A configured remote sync server is a separate, + // narrow connection used only by the sync engine (see + // remote-sync-axios.ts), not by these shared instances. + return `http://localhost:${defaultPort}${path}`; } else if (devMode) { const protocol = window.location.protocol === "https:" ? "https" : "http"; const sslPort = protocol === "https" ? 8443 : defaultPort; @@ -841,6 +810,116 @@ function getApiUrl(path: string, defaultPort: number): string { } } +// ============================================================================ +// PER-HOST ORIGIN ROUTING (Electron desktop only) +// ============================================================================ +// +// hostApi/fileManagerApi/tunnelApi/statsApi above always point at the +// embedded local backend -- they're the shared, always-on instances. When a +// host's connection origin resolves to "remote" (see +// src/ui/lib/connection-origin.ts), the backend that actually holds that +// host's live SSH session is the connected remote server instead, so file +// manager, tunnel, and stats calls for that host must follow it there. +// +// These dynamically-baseURL'd instances resolve the remote server's URL and +// JWT fresh on every request (cheap, and correct even if the user +// connects/disconnects remote sync without an app reload). + +function createRemoteOriginApiInstance(path: string): AxiosInstance { + const instance = axios.create({ + headers: { "Content-Type": "application/json" }, + timeout: 30000, + }); + + instance.interceptors.request.use(async (config: AxiosRequestConfig) => { + const [remoteConfig, remoteJwt] = await Promise.all([ + window.electronAPI?.invoke?.("get-remote-sync-config") as Promise<{ + serverUrl?: string; + } | null>, + window.electronAPI?.invoke?.("get-remote-sync-jwt") as Promise< + string | null + >, + ]); + + const baseUrl = (remoteConfig?.serverUrl || "").replace(/\/$/, ""); + config.baseURL = baseUrl + ? `${baseUrl}${path}` + : "http://no-server-configured"; + + if (config.headers.set) { + config.headers.set("X-Electron-App", "true"); + if (remoteJwt) config.headers.set("Authorization", `Bearer ${remoteJwt}`); + } else { + config.headers["X-Electron-App"] = "true"; + if (remoteJwt) config.headers["Authorization"] = `Bearer ${remoteJwt}`; + } + + return config; + }); + + return instance; +} + +let remoteFileManagerApi: AxiosInstance | null = null; +let remoteTunnelApi: AxiosInstance | null = null; +let remoteStatsApi: AxiosInstance | null = null; + +export function getRemoteFileManagerApi(): AxiosInstance { + if (!remoteFileManagerApi) { + remoteFileManagerApi = createRemoteOriginApiInstance("/ssh/file_manager"); + } + return remoteFileManagerApi; +} + +export function getRemoteTunnelApi(): AxiosInstance { + if (!remoteTunnelApi) { + remoteTunnelApi = createRemoteOriginApiInstance("/ssh"); + } + return remoteTunnelApi; +} + +export function getRemoteStatsApi(): AxiosInstance { + if (!remoteStatsApi) { + remoteStatsApi = createRemoteOriginApiInstance(""); + } + return remoteStatsApi; +} + +// Maps a live SSH session (keyed by sessionId, which today is the host's +// numeric id as a string -- see ensureSSHSessionForHost) to the resolved +// origin it was connected through, so every subsequent file-manager call +// for that session reaches the backend that actually holds it. +const sessionOrigins = new Map(); + +export function setSessionOrigin( + sessionId: string, + origin: "local" | "remote", +): void { + sessionOrigins.set(sessionId, origin); +} + +export function clearSessionOrigin(sessionId: string): void { + sessionOrigins.delete(sessionId); +} + +export function getFileManagerApiForSession(sessionId: string): AxiosInstance { + return sessionOrigins.get(sessionId) === "remote" + ? getRemoteFileManagerApi() + : fileManagerApi; +} + +export function getTunnelApiForOrigin( + origin: "local" | "remote", +): AxiosInstance { + return origin === "remote" ? getRemoteTunnelApi() : tunnelApi; +} + +export function getStatsApiForOrigin( + origin: "local" | "remote", +): AxiosInstance { + return origin === "remote" ? getRemoteStatsApi() : statsApi; +} + function initializeApiInstances() { // Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST"); @@ -921,43 +1000,8 @@ export const appReadyPromise: Promise = new Promise((resolve) => { }); function initializeApp() { - if (isElectron()) { - Promise.all([getServerConfig(), getEmbeddedServerStatus()]) - .then(([config, status]) => { - if (status?.embedded && status?.running && !config?.serverUrl) { - embeddedMode = true; - } - if (config?.serverUrl) { - configuredServerUrl = config.serverUrl; - ( - window as Window & - typeof globalThis & { - IS_ELECTRON?: boolean; - electronAPI?: unknown; - configuredServerUrl?: string; - } - ).configuredServerUrl = configuredServerUrl; - } else if (embeddedMode) { - // Embedded backend running, no remote server needed - } else { - console.warn("No server URL in config"); - } - initializeApiInstances(); - }) - .catch((error) => { - console.error( - "Failed to load server config, initializing with default:", - error, - ); - initializeApiInstances(); - }) - .finally(() => { - _resolveAppReady(); - }); - } else { - initializeApiInstances(); - _resolveAppReady(); - } + initializeApiInstances(); + _resolveAppReady(); } if (document.readyState === "loading") { @@ -966,29 +1010,6 @@ if (document.readyState === "loading") { initializeApp(); } -function updateApiInstances() { - systemLogger.info("Updating API instances with new server configuration", { - operation: "api_instance_update", - configuredServerUrl, - }); - - initializeApiInstances(); - - ( - window as Window & - typeof globalThis & { - IS_ELECTRON?: boolean; - electronAPI?: unknown; - configuredServerUrl?: string; - } - ).configuredServerUrl = configuredServerUrl; - - systemLogger.success("All API instances updated successfully", { - operation: "api_instance_update_complete", - configuredServerUrl, - }); -} - // ============================================================================ // ERROR HANDLING // ============================================================================ @@ -1856,6 +1877,49 @@ export async function getSetupRequired(): Promise<{ setup_required: boolean }> { } } +// Module-level (not per-component) so concurrent/duplicate mounts -- e.g. +// React StrictMode's intentional double-invoke of effects in dev, or any +// other double-call -- share one in-flight request instead of each minting +// its own session. Minting more than one session here is not just wasted +// work: the backend sets a fresh `jwt` cookie on every call, and since the +// auth middleware prefers the cookie over the Authorization header, a +// second mint silently invalidates whichever token the app already started +// using, surfacing as a spurious "session expired/revoked" on the very +// next request. +let desktopAutoSessionRequest: Promise | null = null; + +/** + * Electron-only, non-iframed local login: exchanges the embedded backend's + * single auto-provisioned local user for a session without ever showing a + * login form. Only succeeds when exactly one user exists locally (a synced + * or otherwise multi-user install never satisfies this, and falls through + * to a normal login screen instead). + */ +export async function requestDesktopAutoSession(): Promise { + if (desktopAutoSessionRequest) return desktopAutoSessionRequest; + + desktopAutoSessionRequest = (async () => { + try { + const response = await authApi.post("/users/internal/auto-session"); + if (response.data?.token) { + localStorage.setItem("jwt", response.data.token); + } + if (response.data?.success) { + markUserAuthenticated(); + } + return response.data; + } catch { + return null; + } + })(); + + try { + return await desktopAutoSessionRequest; + } finally { + desktopAutoSessionRequest = null; + } +} + export async function getUserCount(): Promise { try { const response = await authApi.get("/users/count"); diff --git a/src/ui/settings/RemoteSyncPanel.tsx b/src/ui/settings/RemoteSyncPanel.tsx new file mode 100644 index 00000000..3ea84761 --- /dev/null +++ b/src/ui/settings/RemoteSyncPanel.tsx @@ -0,0 +1,269 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Server, RefreshCw, Loader2 } from "lucide-react"; +import { Button } from "@/components/button.tsx"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/dialog.tsx"; +import { RemoteSyncServerPicker } from "./RemoteSyncServerPicker.tsx"; +import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx"; + +interface RemoteSyncConfig { + serverUrl: string; + connectedAt: string; + lastSyncedAt?: string | null; + lastSyncStatus?: "ok" | "error" | "never"; + lastSyncError?: string | null; +} + +interface RemoteSyncStatus { + connected: boolean; + syncing: boolean; + lastSyncedAt: string | null; + lastError: string | null; + needsReauth: boolean; +} + +type DesktopSettings = { defaultConnectionOrigin: "local" | "remote" }; + +export function RemoteSyncPanel() { + const { t } = useTranslation(); + const [config, setConfig] = useState(null); + const [status, setStatus] = useState(null); + const [desktopSettings, setDesktopSettings] = useState({ + defaultConnectionOrigin: "local", + }); + const [step, setStep] = useState<"idle" | "picker" | "login">("idle"); + const [pendingServerUrl, setPendingServerUrl] = useState(""); + const [syncingNow, setSyncingNow] = useState(false); + + const refresh = useCallback(async () => { + const [cfg, st, settings] = await Promise.all([ + window.electronAPI?.invoke?.( + "get-remote-sync-config", + ) as Promise, + window.electronAPI?.invoke?.( + "get-remote-sync-status", + ) as Promise, + window.electronAPI?.invoke?.( + "get-desktop-settings", + ) as Promise, + ]); + setConfig(cfg ?? null); + setStatus(st ?? null); + if (settings) setDesktopSettings(settings); + }, []); + + useEffect(() => { + refresh(); + const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.( + (nextStatus: RemoteSyncStatus) => setStatus(nextStatus), + ); + return () => unsubscribe?.(); + }, [refresh]); + + const handleConnectClick = () => setStep("picker"); + + const handleServerConfigured = (serverUrl: string) => { + setPendingServerUrl(serverUrl); + setStep("login"); + }; + + const handleAuthSuccess = async () => { + setStep("idle"); + await refresh(); + }; + + const handleDisconnect = async () => { + await window.electronAPI?.invoke?.("clear-remote-sync-config"); + await refresh(); + }; + + const handleSyncNow = async () => { + setSyncingNow(true); + try { + await window.electronAPI?.invoke?.("remote-sync-now"); + } finally { + setSyncingNow(false); + await refresh(); + } + }; + + const handleOriginChange = async (origin: "local" | "remote") => { + const next = { ...desktopSettings, defaultConnectionOrigin: origin }; + setDesktopSettings(next); + await window.electronAPI?.invoke?.("save-desktop-settings", next); + }; + + const isConnected = !!config?.serverUrl; + + return ( +
+
+
+ +

{t("remoteSync.title")}

+
+

+ {t("remoteSync.description")} +

+ +
+
+ {isConnected ? ( + <> + + {t("remoteSync.connectedTo", { url: config.serverUrl })} + + + {status?.needsReauth + ? t("remoteSync.needsReauth") + : status?.lastError + ? t("remoteSync.syncError", { + message: status.lastError, + }) + : status?.lastSyncedAt + ? t("remoteSync.lastSynced", { + time: new Date( + status.lastSyncedAt, + ).toLocaleString(), + }) + : t("remoteSync.neverSynced")} + + + ) : ( + + {t("remoteSync.notConnected")} + + )} +
+ +
+ {isConnected ? ( + <> + {status?.needsReauth && ( + + )} + + + + ) : ( + + )} +
+
+
+ +
+

{t("remoteSync.originTitle")}

+

+ {t("remoteSync.originDescription")} +

+
+ + +
+
+ + !open && setStep("idle")} + > + + + + {t("remoteSync.title")} + + + setStep("idle")} + /> + + + + !open && setStep("idle")} + > + + + + {t("remoteSync.signInTitle", { url: pendingServerUrl })} + + +
+ setStep("picker")} + /> +
+
+
+
+ ); +} diff --git a/src/ui/settings/RemoteSyncServerPicker.tsx b/src/ui/settings/RemoteSyncServerPicker.tsx new file mode 100644 index 00000000..fc856e95 --- /dev/null +++ b/src/ui/settings/RemoteSyncServerPicker.tsx @@ -0,0 +1,259 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/button.tsx"; +import { Input } from "@/components/input.tsx"; +import { Label } from "@/components/label.tsx"; +import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx"; +import { Switch } from "@/components/switch.tsx"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, X } from "lucide-react"; + +const SAVED_URLS_KEY = "termix_saved_server_urls"; +const MAX_SAVED_URLS = 5; + +function getSavedUrls(): string[] { + try { + const raw = localStorage.getItem(SAVED_URLS_KEY); + if (!raw) return []; + return JSON.parse(raw); + } catch { + return []; + } +} + +function addSavedUrl(url: string) { + const urls = getSavedUrls().filter((u) => u !== url); + urls.unshift(url); + localStorage.setItem( + SAVED_URLS_KEY, + JSON.stringify(urls.slice(0, MAX_SAVED_URLS)), + ); +} + +function removeSavedUrl(url: string) { + const urls = getSavedUrls().filter((u) => u !== url); + localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls)); +} + +interface RemoteSyncServerPickerProps { + onServerConfigured: (serverUrl: string) => void; + onCancel: () => void; +} + +export function RemoteSyncServerPicker({ + onServerConfigured, + onCancel, +}: RemoteSyncServerPickerProps) { + const { t } = useTranslation(); + const [serverUrl, setServerUrl] = useState(""); + const [allowInvalidCertificate, setAllowInvalidCertificate] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [savedUrls, setSavedUrls] = useState([]); + const [dropdownOpen, setDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + setSavedUrls(getSavedUrls()); + }, []); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setDropdownOpen(false); + } + } + if (dropdownOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [dropdownOpen]); + + const handleSaveConfig = async () => { + if (!serverUrl.trim()) { + setError(t("remoteSync.enterServerUrl")); + return; + } + + setLoading(true); + setError(null); + + try { + const normalizedUrl = serverUrl.trim(); + + if ( + !normalizedUrl.startsWith("http://") && + !normalizedUrl.startsWith("https://") + ) { + setError(t("remoteSync.mustIncludeProtocol")); + setLoading(false); + return; + } + + const result = await window.electronAPI?.invoke?.( + "save-remote-sync-config", + { + serverUrl: normalizedUrl, + allowInvalidCertificate: + normalizedUrl.startsWith("https://") && allowInvalidCertificate, + connectedAt: new Date().toISOString(), + }, + ); + + if ((result as { success?: boolean })?.success) { + addSavedUrl(normalizedUrl); + setSavedUrls(getSavedUrls()); + onServerConfigured(normalizedUrl); + } else { + setError(t("serverConfig.saveFailed")); + } + } catch { + setError(t("serverConfig.saveError")); + } finally { + setLoading(false); + } + }; + + const handleUrlChange = (value: string) => { + setServerUrl(value); + setError(null); + }; + + return ( +
+
+

{t("remoteSync.title")}

+

+ {t("remoteSync.description")} +

+
+ +
+
+ +
+ handleUrlChange(e.target.value)} + disabled={loading} + className={savedUrls.length > 0 ? "pr-9" : ""} + onFocus={() => { + if (savedUrls.length > 0) setDropdownOpen(true); + }} + /> + {savedUrls.length > 0 && ( + + )} + {dropdownOpen && savedUrls.length > 0 && ( +
+

+ {t("remoteSync.savedServers")} +

+ {savedUrls.map((url) => ( +
+ + +
+ ))} +
+ )} +
+
+ + {serverUrl.trim().startsWith("https://") && ( +
+
+ +

+ {t("remoteSync.allowInvalidCertificateDesc")} +

+
+ +
+ )} + + {error && ( + + {t("common.error")} + {error} + + )} + +
+ + +
+
+
+ ); +} diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 02dde18c..05c8ce05 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -124,6 +124,7 @@ function hostToSSHHost(h: Host): SSHHost { defaultPath: h.defaultPath ?? "", tunnelConnections: [], connectionType: "ssh", + connectionOrigin: h.connectionOrigin ?? null, createdAt: "", updatedAt: "", } as SSHHost; diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index deaaaaec..9ea84d03 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -33,7 +33,6 @@ import { getCommandHistoryEnabled, updateCommandHistoryEnabled, isElectron, - getConfiguredServerUrl, getUserRoles, } from "@/main-axios"; import { @@ -808,7 +807,7 @@ export function AdminSettingsPanel({ try { const apiUrl = getDatabaseTransferUrl("export", { electron: isElectron(), - configuredServerUrl: getConfiguredServerUrl(), + configuredServerUrl: null, location: window.location, }); @@ -854,7 +853,7 @@ export function AdminSettingsPanel({ try { const apiUrl = getDatabaseTransferUrl("import", { electron: isElectron(), - configuredServerUrl: getConfiguredServerUrl(), + configuredServerUrl: null, location: window.location, }); diff --git a/src/ui/sidebar/AppRail.tsx b/src/ui/sidebar/AppRail.tsx index 7e5afb75..6b012f18 100644 --- a/src/ui/sidebar/AppRail.tsx +++ b/src/ui/sidebar/AppRail.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types"; import { getAlertFirings } from "@/api/alerts-api"; +import { isElectron } from "@/lib/electron"; export type RailView = | "hosts" @@ -247,8 +248,44 @@ export function AppRail({ return () => window.removeEventListener("hiddenRailTabsChanged", handler); }, []); + // Termix ID publishes SSH public keys under a claimed public handle for + // other servers to fetch -- meaningless for a standalone desktop install + // with no synced multi-device account, so it stays hidden until a remote + // server is actually connected. + const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState( + () => !isElectron(), + ); + + useEffect(() => { + if (!isElectron()) return; + let cancelled = false; + const refreshSyncStatus = () => { + window.electronAPI + ?.invoke?.("get-remote-sync-config") + .then((config) => { + if (!cancelled) { + setIsRemoteSyncConnected( + !!(config as { serverUrl?: string } | null)?.serverUrl, + ); + } + }) + .catch(() => {}); + }; + refreshSyncStatus(); + const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() => + refreshSyncStatus(), + ); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, []); + const railExpanded = pinned || (expandOnHover && hovered); - const railButtons = buildRailButtons(splitMode, t, hiddenTabs); + const effectiveHiddenTabs = isRemoteSyncConnected + ? hiddenTabs + : new Set([...hiddenTabs, "termix-id"]); + const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs); return (
( @@ -717,6 +717,31 @@ export function HostEditorGeneralTab({ ) : null}
)} + {isElectron() && protocols.enableSsh && ( + + + + )}
diff --git a/src/ui/sidebar/HostEditorGuacamoleTabs.tsx b/src/ui/sidebar/HostEditorGuacamoleTabs.tsx index c98d62f8..e7ccd0bc 100644 --- a/src/ui/sidebar/HostEditorGuacamoleTabs.tsx +++ b/src/ui/sidebar/HostEditorGuacamoleTabs.tsx @@ -225,9 +225,7 @@ export function HostEditorRdpTab({ } className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" > - + {credentials.map((c) => (
diff --git a/src/ui/sidebar/SerialPanel.tsx b/src/ui/sidebar/SerialPanel.tsx index 4f988974..8959edf3 100644 --- a/src/ui/sidebar/SerialPanel.tsx +++ b/src/ui/sidebar/SerialPanel.tsx @@ -32,30 +32,10 @@ export function SerialPanel({ onConnect }: SerialPanelProps) { const [loadingPorts, setLoadingPorts] = useState(false); const buildWsUrl = () => { - const isDev = - process.env.NODE_ENV === "development" && - (window.location.port === "3000" || - window.location.port === "5173" || - window.location.port === ""); - - if ( - isDev || - (isElectron() && - !(window as { configuredServerUrl?: string }).configuredServerUrl) - ) { - const token = localStorage.getItem("jwt"); - const base = "ws://127.0.0.1:30011"; - return token ? `${base}?token=${encodeURIComponent(token)}` : base; - } - const configuredUrl = (window as { configuredServerUrl?: string }) - .configuredServerUrl; - if (!configuredUrl) return null; - const wsProtocol = configuredUrl.startsWith("https://") - ? "wss://" - : "ws://"; - const wsHost = configuredUrl.replace(/^https?:\/\//, "").replace(/\/$/, ""); + // Serial is always local -- the device is physically attached to this + // desktop machine, so it never routes through a remote server. const token = localStorage.getItem("jwt"); - const base = `${wsProtocol}${wsHost}/serial/websocket/`; + const base = "ws://127.0.0.1:30011"; return token ? `${base}?token=${encodeURIComponent(token)}` : base; }; diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index 5c7afb28..d7d447e3 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -16,7 +16,6 @@ import { getUserRoles, saveUserPreferences, getUserPreferences, - getConfiguredServerUrl, } from "@/main-axios"; import { getDatabaseTransferUrl } from "@/lib/database-transfer-url"; import { @@ -29,6 +28,7 @@ import { import type { UserRole } from "@/main-axios"; import type React from "react"; import { isElectron } from "@/lib/electron"; +import { RemoteSyncPanel } from "@/settings/RemoteSyncPanel.tsx"; import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; @@ -426,13 +426,11 @@ function PasswordChangeSection({ export function UserProfilePanel({ username, onLogout, - onChangeServer, userPrefs, onPrefsChange, }: { username?: string; onLogout?: () => void; - onChangeServer?: () => void; userPrefs?: { reopenTabsOnLogin: boolean; storageMode?: string | null; @@ -541,6 +539,46 @@ export function UserProfilePanel({ } }, [userPrefs?.storageMode]); + // Remote sync is not connected by default on the desktop app (it's an + // opt-in feature configured from this same panel). "cloud" storage mode + // and Termix ID both assume a real multi-device server account, so they + // stay hidden/forced-off until the user actually connects one. + const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState( + () => !isElectron(), + ); + + useEffect(() => { + if (!isElectron()) return; + let cancelled = false; + const refreshSyncStatus = () => { + window.electronAPI + ?.invoke?.("get-remote-sync-config") + .then((config) => { + if (!cancelled) { + setIsRemoteSyncConnected( + !!(config as { serverUrl?: string } | null)?.serverUrl, + ); + } + }) + .catch(() => {}); + }; + refreshSyncStatus(); + const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() => + refreshSyncStatus(), + ); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, []); + + useEffect(() => { + if (isElectron() && !isRemoteSyncConnected && storageMode === "cloud") { + setStorageMode("local"); + onPrefsChange?.({ storageMode: "local" }); + } + }, [isRemoteSyncConnected, storageMode, onPrefsChange]); + // Settings toggles — all backed by localStorage const [commandAutocomplete, setCommandAutocomplete] = useState( () => localStorage.getItem("commandAutocomplete") === "true", @@ -1143,7 +1181,7 @@ export function UserProfilePanel({ try { const apiUrl = getDatabaseTransferUrl("export", { electron: isElectron(), - configuredServerUrl: getConfiguredServerUrl(), + configuredServerUrl: null, location: window.location, }); @@ -1171,9 +1209,7 @@ export function UserProfilePanel({ toast.success(t("newUi.sidebar.userProfile.exportSuccess")); } else { const err = await response.json().catch(() => ({})); - toast.error( - err.error || t("newUi.sidebar.userProfile.exportFailed"), - ); + toast.error(err.error || t("newUi.sidebar.userProfile.exportFailed")); } } catch { toast.error(t("newUi.sidebar.userProfile.exportFailed")); @@ -1191,7 +1227,7 @@ export function UserProfilePanel({ try { const apiUrl = getDatabaseTransferUrl("import", { electron: isElectron(), - configuredServerUrl: getConfiguredServerUrl(), + configuredServerUrl: null, location: window.location, }); @@ -1280,44 +1316,48 @@ export function UserProfilePanel({
- {/* Storage mode toggle */} -
- - {t("newUi.sidebar.userProfile.storageModeSwitch")} - -
+ {/* Storage mode toggle — only meaningful once a remote server is + connected; with no sync there's nowhere for "cloud" to sync to, + so this stays forced to local storage and hidden. */} + {(!isElectron() || isRemoteSyncConnected) && ( +
+ + {t("newUi.sidebar.userProfile.storageModeSwitch")} + +
+ + +
+

+ {t("newUi.sidebar.userProfile.storageModeDescription")} +

-
-

- {t("newUi.sidebar.userProfile.storageModeDescription")} -

- -
+ )} {/* Account */}
- {isElectron() && onChangeServer && ( + {isElectron() && (
-
-
- - {t("serverConfig.changeServer")} - - - {t("newUi.sidebar.userProfile.changeServerDescription")} - -
- -
+
)} diff --git a/src/ui/tests/lib/connection-origin.test.ts b/src/ui/tests/lib/connection-origin.test.ts new file mode 100644 index 00000000..d92725a7 --- /dev/null +++ b/src/ui/tests/lib/connection-origin.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { resolveConnectionOrigin } from "../../lib/connection-origin.js"; + +const win = window as unknown as Record; + +afterEach(() => { + delete win.IS_ELECTRON; + delete win.electronAPI; +}); + +describe("resolveConnectionOrigin", () => { + it("always resolves rdp/vnc/telnet to remote, even with a local override", async () => { + win.IS_ELECTRON = true; + for (const connectionType of ["rdp", "vnc", "telnet"]) { + await expect( + resolveConnectionOrigin({ connectionType, connectionOrigin: "local" }), + ).resolves.toBe("remote"); + } + }); + + it("always resolves serial to local, even with a remote override", async () => { + win.IS_ELECTRON = true; + await expect( + resolveConnectionOrigin({ + connectionType: "serial", + connectionOrigin: "remote", + }), + ).resolves.toBe("local"); + }); + + it("resolves to local outside Electron regardless of connectionType", async () => { + await expect( + resolveConnectionOrigin({ + connectionType: "ssh", + connectionOrigin: "remote", + }), + ).resolves.toBe("local"); + }); + + it("honors a host-level override for ssh in Electron", async () => { + win.IS_ELECTRON = true; + await expect( + resolveConnectionOrigin({ + connectionType: "ssh", + connectionOrigin: "remote", + }), + ).resolves.toBe("remote"); + await expect( + resolveConnectionOrigin({ + connectionType: "ssh", + connectionOrigin: "local", + }), + ).resolves.toBe("local"); + }); + + it("falls back to the desktop-wide default when no host override is set", async () => { + win.IS_ELECTRON = true; + win.electronAPI = { + invoke: async (channel: string) => { + if (channel === "get-desktop-settings") { + return { defaultConnectionOrigin: "remote" }; + } + return null; + }, + }; + await expect( + resolveConnectionOrigin({ + connectionType: "ssh", + connectionOrigin: null, + }), + ).resolves.toBe("remote"); + }); + + it("defaults to local when the desktop settings lookup fails", async () => { + win.IS_ELECTRON = true; + win.electronAPI = { + invoke: async () => { + throw new Error("ipc failed"); + }, + }; + await expect( + resolveConnectionOrigin({ + connectionType: "ssh", + connectionOrigin: null, + }), + ).resolves.toBe("local"); + }); +});