rojects/electron/main.ts

441 lines
15 KiB
TypeScript

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<string, string> ): Promise<unknown> {
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<string, unknown> | 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<string, unknown>;
} 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<void> {
const tokens = currentTokens;
if ( !tokens ) return;
try {
const result = await postJson(
`${ AUTH_HOST }/api/auth/refresh`,
{ refreshToken: tokens.refreshToken }
) as Record<string, unknown>;
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<void> {
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<boolean> {
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<string, unknown>;
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<void> {
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<string, unknown>;
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<string, unknown>;
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();
} );