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 passwordFile(): string { return path.join( app.getPath( 'userData' ), 'last-password.txt' ); } 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 loadLastPassword(): string { try { return fs.readFileSync( passwordFile(), 'utf-8' ).trim(); } catch { return ''; } } function saveLastPassword( password: string ): void { fs.writeFileSync( passwordFile(), password, 'utf-8' ); } function clearCredentials(): void { try { fs.unlinkSync( emailFile() ); } catch { /* already gone */ } try { fs.unlinkSync( passwordFile() ); } 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; } // ── Network ──────────────────────────────────────────────────────────────────── function postJson( url: string, body: unknown ): 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 ), }, }, ( res ) => { 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(); } ); } // ── 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( () => { 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 ); saveLastPassword( password ); } 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:last-password', () => loadLastPassword() ); 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 { 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(); } );