import { app, BrowserWindow, dialog, ipcMain, session } from 'electron'; import path from 'path'; import fs from 'fs'; import https from 'https'; const AUTH_HOST = 'https://account.rokojori.com'; const PORT = 3000; // ── Persistence helpers ──────────────────────────────────────────────────────── function tokenFile(): string { return path.join( app.getPath( 'userData' ), 'tokens.json' ); } function emailFile(): string { return path.join( app.getPath( 'userData' ), 'last-email.txt' ); } interface Tokens { accessToken: string; refreshToken: string; } function loadTokens(): Tokens | null { try { const raw = fs.readFileSync( tokenFile(), 'utf-8' ); return JSON.parse( raw ) as Tokens; } catch { return null; } } function saveTokens( tokens: Tokens ): void { fs.writeFileSync( tokenFile(), JSON.stringify( tokens ), 'utf-8' ); } function clearTokens(): void { try { fs.unlinkSync( tokenFile() ); } catch { /* already gone */ } } function loadLastEmail(): string { try { return fs.readFileSync( emailFile(), 'utf-8' ).trim(); } catch { return ''; } } function saveLastEmail( email: string ): void { fs.writeFileSync( emailFile(), email, 'utf-8' ); } function clearCredentials(): void { try { fs.unlinkSync( emailFile() ); } catch { /* already gone */ } } function localRecentsFile(): string { return path.join( app.getPath( 'userData' ), 'local-recents.json' ); } function loadLocalRecents(): string[] { try { const raw = fs.readFileSync( localRecentsFile(), 'utf-8' ); return JSON.parse( raw ) as string[]; } catch { return []; } } function saveLocalRecents( recents: string[] ): void { fs.writeFileSync( localRecentsFile(), JSON.stringify( recents ), 'utf-8' ); } function addLocalRecent( folderPath: string ): void { const recents = loadLocalRecents().filter( r => r !== folderPath ); recents.unshift( folderPath ); saveLocalRecents( recents.slice( 0, 10 ) ); } function removeLocalRecent( folderPath: string ): string[] { const recents = loadLocalRecents().filter( r => r !== folderPath ); saveLocalRecents( recents ); return recents; } // ── Clock offset ─────────────────────────────────────────────────────────────── // Electron holds the access token directly, so expiry comparisons must use // server-authoritative time, not the local clock (which can be significantly wrong). // We read the Date header from the first auth-server response and cache the offset. let _serverClockOffsetMs: number | null = null; function updateClockOffset( dateHeader: string | undefined ): void { if ( _serverClockOffsetMs !== null || !dateHeader ) return; const serverMs = new Date( dateHeader ).getTime(); if ( isNaN( serverMs ) ) return; _serverClockOffsetMs = serverMs - Date.now(); } function serverNow(): number { return Date.now() + ( _serverClockOffsetMs ?? 0 ); } // ── Network ──────────────────────────────────────────────────────────────────── function postJson( url: string, body: unknown, extraHeaders?: Record ): Promise { return new Promise( ( resolve, reject ) => { const data = JSON.stringify( body ); const parsed = new URL( url ); const req = https.request( { hostname: parsed.hostname, path: parsed.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength( data ), ...( extraHeaders ?? {} ), }, }, ( res ) => { updateClockOffset( res.headers.date ); let raw = ''; res.on( 'data', ( chunk: string ) => { raw += chunk; } ); res.on( 'end', () => { try { resolve( JSON.parse( raw ) ); } catch { reject( new Error( `Non-JSON response: ${raw}` ) ); } } ); } ); req.on( 'error', reject ); req.write( data ); req.end(); } ); } // ── Electron token updater ───────────────────────────────────────────────────── // Checks the access token's expiry (decoded from the JWT payload) against // server-corrected time every 5 minutes. Refreshes proactively when within // 15 minutes of expiry — matching the server-side PROACTIVE_REFRESH_MARGIN_SEC. // On refresh failure: network errors are silently retried next tick; // auth failures (revoked / expired refresh token) close the main window and // show the login screen. const REFRESH_MARGIN_MS = 15 * 60 * 1000; const UPDATER_INTERVAL_MS = 5 * 60 * 1000; function decodeJwtPayload( token: string ): Record | null { try { const parts = token.split( '.' ); if ( parts.length !== 3 ) return null; const json = Buffer.from( parts[ 1 ].replace( /-/g, '+' ).replace( /_/g, '/' ), 'base64' ).toString( 'utf-8' ); return JSON.parse( json ) as Record; } catch { return null; } } function tokenExpMs( token: string ): number | null { const payload = decodeJwtPayload( token ); if ( !payload || typeof payload.exp !== 'number' ) return null; return payload.exp * 1000; } async function refreshCurrentTokens(): Promise { const tokens = currentTokens; if ( !tokens ) return; try { const result = await postJson( `${ AUTH_HOST }/api/auth/refresh`, { refreshToken: tokens.refreshToken } ) as Record; if ( result.accessToken && result.refreshToken ) { currentTokens = { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string, }; saveTokens( currentTokens ); } else { clearTokens(); currentTokens = null; mainWindow?.close(); createLoginWindow(); } } catch { // network error — keep current tokens and retry next tick } } async function checkAndRefreshIfDue(): Promise { const tokens = currentTokens; if ( !tokens ) return; const expMs = tokenExpMs( tokens.accessToken ); if ( expMs === null ) return; if ( expMs - serverNow() < REFRESH_MARGIN_MS ) { await refreshCurrentTokens(); } } function startElectronTokenUpdater(): void { setInterval( () => void checkAndRefreshIfDue(), UPDATER_INTERVAL_MS ); setInterval( () => writeHeartbeat(), HEARTBEAT_WRITE_INTERVAL_MS ); } // ── Session heartbeat ─────────────────────────────────────────────────────────── // A running instance writes its current access token + timestamp every 10 s. // A newly-starting instance reads this file on launch: if ≤ 30 s old it calls // POST /api/auth/new-session (requireAuth-guarded) to mint its own independent // token pair, avoiding the login screen when at least one other instance is live. // This replaces the plaintext last-password.txt auto-login that was here before. const HEARTBEAT_WRITE_INTERVAL_MS = 10_000; const HEARTBEAT_MAX_AGE_MS = 30_000; interface Heartbeat { accessToken: string; timestamp: number; } function heartbeatFile(): string { return path.join( app.getPath( 'userData' ), 'session-heartbeat.json' ); } function writeHeartbeat(): void { const tokens = currentTokens; if ( !tokens ) return; const hb: Heartbeat = { accessToken: tokens.accessToken, timestamp: Date.now() }; try { fs.writeFileSync( heartbeatFile(), JSON.stringify( hb ), 'utf-8' ); } catch { /* ignore */ } } function loadHeartbeat(): Heartbeat | null { try { const raw = fs.readFileSync( heartbeatFile(), 'utf-8' ); return JSON.parse( raw ) as Heartbeat; } catch { return null; } } async function tryHeartbeatLogin(): Promise { const hb = loadHeartbeat(); if ( !hb ) return false; if ( Date.now() - hb.timestamp > HEARTBEAT_MAX_AGE_MS ) return false; try { const result = await postJson( `${ AUTH_HOST }/api/auth/new-session`, {}, { Authorization: `Bearer ${ hb.accessToken }` } ) as Record; if ( result.accessToken && result.refreshToken ) { currentTokens = { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string, }; saveTokens( currentTokens ); return true; } } catch { /* network error — fall through to login screen */ } return false; } // ── Header injection ─────────────────────────────────────────────────────────── function registerHeaderInjector( getToken: () => string | null ): void { session.defaultSession.webRequest.onBeforeSendHeaders( { urls: [ `http://localhost:${PORT}/*` ] }, ( details, callback ) => { const token = getToken(); const headers = { ...details.requestHeaders }; if ( token ) headers[ 'Authorization' ] = `Bearer ${token}`; callback( { requestHeaders: headers } ); } ); } // ── Windows ──────────────────────────────────────────────────────────────────── let mainWindow: BrowserWindow | null = null; let loginWindow: BrowserWindow | null = null; let currentTokens: Tokens | null = null; function createLoginWindow(): void { loginWindow = new BrowserWindow( { width: 420, height: 520, resizable: false, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join( __dirname, 'preload.js' ), }, title: 'Roject — Sign in', } ); loginWindow.loadFile( path.join( __dirname, 'login.html' ) ); loginWindow.on( 'closed', () => { loginWindow = null; } ); } async function createMainWindow(): Promise { await session.defaultSession.clearStorageData( { storages: [ 'cookies' ] } ); mainWindow = new BrowserWindow( { width: 1400, height: 900, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join( __dirname, 'preload.js' ), }, title: 'Roject', } ); mainWindow.loadURL( `http://localhost:${PORT}/` ); const localBase = `http://localhost:${PORT}/`; mainWindow.webContents.on( 'will-navigate', ( event, url ) => { if ( !url.startsWith( localBase ) ) { event.preventDefault(); clearTokens(); currentTokens = null; mainWindow?.close(); createLoginWindow(); } } ); mainWindow.on( 'closed', () => { mainWindow = null; } ); } // ── Server ───────────────────────────────────────────────────────────────────── function loadEnv(): void { const envPath = path.join( __dirname, '..', '..', '.env' ); try { const lines = fs.readFileSync( envPath, 'utf-8' ).split( /\r?\n/ ); for ( const line of lines ) { const trimmed = line.trim(); if ( !trimmed || trimmed.startsWith( '#' ) ) continue; const eq = trimmed.indexOf( '=' ); if ( eq === -1 ) continue; const key = trimmed.slice( 0, eq ).trim(); const val = trimmed.slice( eq + 1 ).trim().replace( /^["']|["']$/g, '' ); if ( key && !( key in process.env ) ) process.env[ key ] = val; } } catch { /* no .env file — rely on inherited env */ } } function startExpressServer(): void { loadEnv(); process.env.ROJECT_ROOT = path.join( __dirname, '..', '..' ); process.env.ROJECT_ELECTRON = 'true'; const serverPath = path.join( __dirname, '..', 'server', 'server', 'index.js' ); // eslint-disable-next-line @typescript-eslint/no-require-imports const { startServer } = require( serverPath ) as { startServer: ( port: number ) => void }; startServer( PORT ); } // ── App lifecycle ────────────────────────────────────────────────────────────── app.whenReady().then( () => { startElectronTokenUpdater(); registerHeaderInjector( () => currentTokens?.accessToken ?? null ); ipcMain.handle( 'auth:login', async ( _event, email: string, password: string, remember: boolean ) => { try { const result = await postJson( `${ AUTH_HOST }/api/auth/login`, { email, password } ) as Record; if ( result.accessToken && result.refreshToken ) { currentTokens = { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string }; saveTokens( currentTokens ); if ( remember ) saveLastEmail( email ); else clearCredentials(); return { ok: true }; } return { ok: false, error: ( result.error as string ) ?? 'Login failed' }; } catch ( err ) { return { ok: false, error: String( err ) }; } } ); ipcMain.handle( 'auth:last-email', () => loadLastEmail() ); ipcMain.handle( 'auth:clear-credentials', () => { clearCredentials(); } ); ipcMain.handle( 'local:open-folder', async () => { const win = mainWindow ?? BrowserWindow.getFocusedWindow(); if ( !win ) return null; const result = await dialog.showOpenDialog( win, { properties: [ 'openDirectory' ] } ); if ( result.canceled || result.filePaths.length === 0 ) return null; const folderPath = result.filePaths[ 0 ]; addLocalRecent( folderPath ); return folderPath; } ); ipcMain.handle( 'local:get-recents', () => loadLocalRecents() ); ipcMain.handle( 'local:remove-recent', ( _event, folderPath: string ) => { return removeLocalRecent( folderPath ); } ); ipcMain.on( 'auth:login-success', () => { // Create the main window first, close login only after it exists. // Closing login before main is ready triggers window-all-closed → app quit. createMainWindow().then( () => loginWindow?.close() ); } ); startExpressServer(); setTimeout( async () => { currentTokens = loadTokens(); if ( currentTokens ) { try { const result = await postJson( `${ AUTH_HOST }/api/auth/refresh`, { refreshToken: currentTokens.refreshToken } ) as Record; if ( result.accessToken && result.refreshToken ) { currentTokens = { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string }; saveTokens( currentTokens ); createMainWindow(); } else { clearTokens(); currentTokens = null; createLoginWindow(); } } catch { clearTokens(); currentTokens = null; createLoginWindow(); } } else { // No saved tokens — try to mint a new independent session from a running instance's // heartbeat before falling through to the login screen. const gotSession = await tryHeartbeatLogin(); if ( gotSession ) createMainWindow(); else createLoginWindow(); } }, 500 ); app.on( 'activate', () => { if ( BrowserWindow.getAllWindows().length === 0 ) { if ( currentTokens ) void createMainWindow(); else createLoginWindow(); } } ); } ); app.on( 'window-all-closed', () => { if ( process.platform !== 'darwin' ) app.quit(); } );