rojects/electron/main.ts

235 lines
7.0 KiB
TypeScript
Raw Normal View History

import { app, BrowserWindow, 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;
function tokenFile(): string {
return path.join( app.getPath( 'userData' ), 'tokens.json' );
}
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 postJson( url: string, body: unknown ): 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 ),
},
},
( 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();
} );
}
async function refreshTokens( refreshToken: string ): Promise<Tokens | null> {
try {
const result = await postJson( `${AUTH_HOST}/api/auth/refresh`, { refreshToken } ) as Record<string, unknown>;
if ( result.accessToken && result.refreshToken ) {
return { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string };
}
return null;
} catch {
return null;
}
}
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 } );
}
);
}
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; } );
}
function createMainWindow(): void {
mainWindow = new BrowserWindow( {
width: 1400,
height: 900,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
title: 'Roject',
} );
mainWindow.loadURL( `http://localhost:${PORT}/dashboard.html` );
const localBase = `http://localhost:${PORT}/`;
// Keep the main window on localhost — any navigation away means the user needs to re-auth
mainWindow.webContents.on( 'will-navigate', ( event, url ) => {
if ( !url.startsWith( localBase ) ) {
event.preventDefault();
clearTokens();
currentTokens = null;
mainWindow?.close();
createLoginWindow();
}
} );
// Intercept the browser-cookie refresh-session redirect and handle it ourselves
mainWindow.webContents.on( 'will-redirect', async ( event, url ) => {
if ( url.includes( '/api/auth/refresh-session' ) ) {
event.preventDefault();
const redirectParam = new URL( url ).searchParams.get( 'redirect' );
const fallback = `http://localhost:${PORT}/dashboard.html`;
if ( currentTokens ) {
const fresh = await refreshTokens( currentTokens.refreshToken );
if ( fresh ) {
currentTokens = fresh;
saveTokens( fresh );
mainWindow?.loadURL( redirectParam ?? fallback );
return;
}
}
// Refresh failed — go back to login
clearTokens();
currentTokens = null;
mainWindow?.close();
createLoginWindow();
}
} );
mainWindow.on( 'closed', () => { mainWindow = null; } );
}
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();
// Tell the server where the project root is so __dirname-relative paths work when compiled.
// build/electron/ → up two levels → project root.
process.env.ROJECT_ROOT = path.join( __dirname, '..', '..' );
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.whenReady().then( () => {
registerHeaderInjector( () => currentTokens?.accessToken ?? null );
ipcMain.handle( 'auth:login', async ( _event, email: string, password: string ) => {
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 );
return { ok: true };
}
return { ok: false, error: ( result.error as string ) ?? 'Login failed' };
} catch ( err ) {
return { ok: false, error: String( err ) };
}
} );
ipcMain.on( 'auth:login-success', () => {
loginWindow?.close();
createMainWindow();
} );
startExpressServer();
// Give the server a moment to bind before loading the window
setTimeout( () => {
currentTokens = loadTokens();
if ( currentTokens ) {
createMainWindow();
} else {
createLoginWindow();
}
}, 500 );
app.on( 'activate', () => {
if ( BrowserWindow.getAllWindows().length === 0 ) {
if ( currentTokens ) createMainWindow();
else createLoginWindow();
}
} );
} );
app.on( 'window-all-closed', () => {
if ( process.platform !== 'darwin' ) app.quit();
} );