2026-07-26 18:47:27 -05:00
// 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" );
2026-08-06 14:41:39 -05:00
const { SYNCED_ENTITY_TYPES } = require ( "./remote-sync-entities.cjs" );
2026-07-26 18:47:27 -05:00
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 };
}
2026-08-06 14:41:39 -05:00
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 ,
};
}
2026-07-26 18:47:27 -05:00
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 ( "<html" ) ||
text . includes ( "<!DOCTYPE" ) ||
text . includes ( "<head>" ) ||
text . includes ( "<body>" );
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 || [];
}
2026-08-06 14:41:39 -05:00
/**
* 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 ));
}
2026-07-26 18:47:27 -05:00
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.
2026-08-06 14:41:39 -05:00
//
// 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 ,
);
}
2026-07-26 18:47:27 -05:00
}
}
2026-08-06 14:41:39 -05:00
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 ,
);
}
2026-07-26 18:47:27 -05:00
}
}
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 ,
2026-08-06 14:41:39 -05:00
getRemoteSyncUserInfo ,
2026-07-26 18:47:27 -05:00
isJwtExpiredOrExpiringSoon ,
decodeJwtExpiry ,
};