// 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()) { // Carries a stable reason alongside the message: the renderer has a // translated explanation for this one, because "no OS keyring" is a // machine-level problem the user has to go and fix, not something signing // in again can resolve. return { success: false, reason: "encryption_unavailable", 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 || []; } /** * Every syncId a side currently holds, ignoring the incremental window. * Used only to decide whether a deletion still has something to delete. */ async pullSyncIds(baseUrl, token, entityType) { const rows = await this.pullSide(baseUrl, token, entityType, null); return new Set(rows.filter((row) => row.syncId).map((row) => row.syncId)); } 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. // // The presence check cannot use localRows/remoteRows: those are the // incremental window, and a row deleted on one side while untouched on // the other is by definition outside it, so every deletion was dropped. // It also cannot be skipped -- pushing unconditionally makes the // receiving side record a fresh tombstone, which the next pass would push // back, forever. So ask the receiving side what it actually still holds, // and only when there is a deletion to apply. if (localTombstones.length) { const remoteSyncIds = await this.pullSyncIds( remoteBaseUrl, remoteJwt, entityType, ); for (const tombstone of localTombstones) { if (remoteSyncIds.has(tombstone.syncId)) { await this.pushTombstone( remoteBaseUrl, remoteJwt, entityType, tombstone.syncId, ); } } } if (remoteTombstones.length) { const localSyncIds = await this.pullSyncIds( EMBEDDED_BASE_URL, this.localJwt, entityType, ); for (const tombstone of remoteTombstones) { if (localSyncIds.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, };