// 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 } = require("./remote-sync-entities.cjs"); 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", migrationNoticeAcknowledged: false, }); } 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 }; } async function getRemoteSyncUserInfo() { const config = getRemoteSyncConfig(); const token = getRemoteSyncJwt(); if (!config?.serverUrl || !token || isJwtExpiredOrExpiringSoon(token)) { return null; } const baseUrl = config.serverUrl.replace(/\/$/, ""); const userResponse = await fetch(`${baseUrl}/users/me`, { headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" }, }); if (!userResponse.ok) return null; const user = await userResponse.json(); const rolesResponse = await fetch( `${baseUrl}/rbac/users/${encodeURIComponent(user.userId)}/roles`, { headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" }, }, ); const roles = rolesResponse.ok ? (await rolesResponse.json()).roles || [] : []; return { userId: user.userId, username: user.username, is_admin: !!user.is_admin, is_oidc: !!user.is_oidc, is_dual_auth: !!user.is_dual_auth, totp_enabled: !!user.totp_enabled, roles, }; } 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 -- this is expected for the // first tick or two right after a cold boot (renderer hasn't finished // its own session check yet), but if it never arrives (e.g. a gap in // whichever code path establishes the local session), sync would // otherwise silently no-op forever with no visible error. Surface it // as a normal, non-alarming "not synced yet" status rather than // leaving lastSyncedAt/lastError untouched. this.updateStatus({ connected: true, syncing: false, lastError: "Waiting for local session", }); 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}`); } const text = await res.text(); // A reverse-proxy SSO in front of the remote server (Pangolin, Authelia, // etc.) can intercept even an authenticated, Bearer-token'd request and // serve its own login page instead of forwarding to Termix -- that comes // back as a normal 200 OK, so the status checks above don't catch it. // This is NOT the same as needsReauth/a bad Termix JWT: sync runs as a // plain server-to-server fetch() in this main process, with no browser // cookie jar at all, so re-authenticating through the login iframe (which // only affects the renderer's browser session) can never fix this -- // reconnecting would tell the user to do something that doesn't help. // The proxy has to allow this traffic through some other way (an API // bypass rule, a separate hostname/port that isn't proxy-gated, etc.), // so this gets its own distinct, honest error rather than piggybacking // on needsReauth or a raw JSON.parse crash. const looksLikeHtml = text.includes("") || text.includes(""); if (looksLikeHtml) { const err = new Error( "The reverse proxy in front of this server is blocking sync traffic with its own login page. Reconnecting won't fix this -- the proxy needs to let Termix's API requests through (e.g. an SSO bypass rule for the sync API, or a non-proxied hostname/port for it).", ); err.proxyBlocked = true; throw err; } try { return JSON.parse(text); } catch { throw new Error(`Server returned invalid JSON: ${url}`); } } 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, getRemoteSyncUserInfo, isJwtExpiredOrExpiringSoon, decodeJwtExpiry, };