import { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from 'electron'; import path from 'path'; import fs from 'fs'; import https from 'https'; import { TunnelAgent } from './agent/TunnelAgent'; 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 — rely on inherited env */ } } loadEnv(); const AUTH_HOST = 'https://account.rokojori.com'; const TUNNEL_SERVER_URL = process.env.TUNNEL_SERVER_URL ?? 'https://tunnel.rokojori.com'; // ── Tokens ──────────────────────────────────────────────────────────────────── interface Tokens { accessToken: string; refreshToken: string; } function tokenFile(): string { return path.join( app.getPath( 'userData' ), 'tokens.json' ); } function loadTokens(): Tokens | null { try { return JSON.parse( fs.readFileSync( tokenFile(), 'utf-8' ) ) as Tokens; } catch { return null; } } function saveTokens( t: Tokens ): void { fs.writeFileSync( tokenFile(), JSON.stringify( t ), 'utf-8' ); } function clearTokens(): void { try { fs.unlinkSync( tokenFile() ); } catch { /* already gone */ } } // ── HTTP helpers ────────────────────────────────────────────────────────────── 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( raw ) ); } } ); } ); req.on( 'error', reject ); req.write( data ); req.end(); } ); } async function apiFetch( apiPath: string, options: RequestInit = {} ): Promise { const url = `${ TUNNEL_SERVER_URL }${ apiPath }`; return fetch( url, { ...options, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${ currentTokens?.accessToken ?? '' }`, ...( options.headers ?? {} ), }, } ); } // ── State ───────────────────────────────────────────────────────────────────── let currentTokens: Tokens | null = null; let mainWindow: BrowserWindow | null = null; let loginWindow: BrowserWindow | null = null; let tray: Tray | null = null; const agents = new Map(); // ── Tray ────────────────────────────────────────────────────────────────────── function buildTray(): void { const iconPath = path.join( __dirname, '..', '..', 'assets', 'tray-icon.png' ); const icon = fs.existsSync( iconPath ) ? nativeImage.createFromPath( iconPath ) : nativeImage.createEmpty(); tray = new Tray( icon ); tray.setToolTip( 'Rokojori Tunnel Agent' ); const menu = Menu.buildFromTemplate( [ { label: 'Show Window', click: showMainWindow }, { type: 'separator' }, { label: 'Disconnect All', click: () => { for ( const [ id, agent ] of agents ) { agent.stop(); mainWindow?.webContents.send( 'tunnel:status', id, false ); } agents.clear(); }, }, { type: 'separator' }, { label: 'Quit', role: 'quit' }, ] ); tray.setContextMenu( menu ); tray.on( 'double-click', showMainWindow ); tray.on( 'click', showMainWindow ); // Windows single-click } function showMainWindow(): void { if ( mainWindow ) { mainWindow.show(); mainWindow.focus(); } else createMainWindow(); } // ── Windows ─────────────────────────────────────────────────────────────────── function createLoginWindow(): void { loginWindow = new BrowserWindow( { width: 420, height: 520, resizable: false, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join( __dirname, 'preload.js' ) }, title: 'Tunnel Agent — Sign in', } ); loginWindow.loadFile( path.join( __dirname, 'login.html' ) ); loginWindow.on( 'closed', () => { loginWindow = null; } ); } function createMainWindow(): void { mainWindow = new BrowserWindow( { width: 400, height: 560, minWidth: 320, minHeight: 400, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join( __dirname, 'preload.js' ) }, title: 'Tunnel Agent', } ); mainWindow.loadFile( path.join( __dirname, 'window.html' ) ); mainWindow.on( 'closed', () => { mainWindow = null; } ); } // ── IPC ─────────────────────────────────────────────────────────────────────── function registerIPC(): void { // Login ipcMain.handle( 'auth:login', async ( _e, email: string, password: string ) => { 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 ); 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(); if ( !tray ) buildTray(); } ); // List tunnels ipcMain.handle( 'tunnels:list', async () => { const res = await apiFetch( '/api/tunnels' ); const tunnels = await res.json() as Array>; return tunnels.map( t => ( { ...t, agentActive: agents.get( t.id as string )?.isActive() ?? false } ) ); } ); // Connect ipcMain.handle( 'tunnel:connect', async ( _e, tunnelId: string ) => { if ( agents.has( tunnelId ) ) return; const res = await apiFetch( `/api/tunnels/${ tunnelId }` ); const tunnel = await res.json() as { localPort: number }; const agent = new TunnelAgent( { tunnelId, token: currentTokens!.accessToken, localPort: tunnel.localPort, serverUrl: TUNNEL_SERVER_URL, } ); agent.onStatus = ( active ) => mainWindow?.webContents.send( 'tunnel:status', tunnelId, active ); agents.set( tunnelId, agent ); agent.start(); } ); // Disconnect ipcMain.handle( 'tunnel:disconnect', ( _e, tunnelId: string ) => { const agent = agents.get( tunnelId ); agent?.stop(); agents.delete( tunnelId ); mainWindow?.webContents.send( 'tunnel:status', tunnelId, false ); } ); // Add tunnel ipcMain.handle( 'tunnel:add', async ( _e, data: object ) => { const res = await apiFetch( '/api/tunnels', { method: 'POST', body: JSON.stringify( data ) } ); if ( !res.ok ) { const err = await res.json() as { error: string }; throw new Error( err.error ?? 'Failed to create tunnel' ); } return res.json(); } ); // Delete tunnel ipcMain.handle( 'tunnel:delete', async ( _e, tunnelId: string ) => { agents.get( tunnelId )?.stop(); agents.delete( tunnelId ); await apiFetch( `/api/tunnels/${ tunnelId }`, { method: 'DELETE' } ); } ); } // ── Startup ─────────────────────────────────────────────────────────────────── app.whenReady().then( () => { registerIPC(); currentTokens = loadTokens(); if ( currentTokens ) { createMainWindow(); buildTray(); } else { createLoginWindow(); } app.on( 'activate', () => { if ( BrowserWindow.getAllWindows().length === 0 ) { if ( currentTokens ) createMainWindow(); else createLoginWindow(); } } ); } ); // Stay alive in tray when all windows are closed app.on( 'window-all-closed', () => { /* tray keeps the app alive */ } ); app.on( 'before-quit', () => { for ( const agent of agents.values() ) agent.stop(); agents.clear(); } );