296 lines
9.4 KiB
TypeScript
296 lines
9.4 KiB
TypeScript
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<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( raw ) ); } } );
|
|
}
|
|
);
|
|
req.on( 'error', reject );
|
|
req.write( data );
|
|
req.end();
|
|
} );
|
|
}
|
|
|
|
async function apiFetch( apiPath: string, options: RequestInit = {} ): Promise<Response>
|
|
{
|
|
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<string, TunnelAgent>();
|
|
|
|
// ── 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<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();
|
|
if ( !tray ) buildTray();
|
|
} );
|
|
|
|
// List tunnels
|
|
ipcMain.handle( 'tunnels:list', async () =>
|
|
{
|
|
const res = await apiFetch( '/api/tunnels' );
|
|
const data = await res.json();
|
|
if ( !res.ok || !Array.isArray( data ) )
|
|
throw new Error( ( data as Record<string, string> )?.error ?? `HTTP ${ res.status }` );
|
|
return ( data as Array<Record<string, unknown>> ).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();
|
|
} );
|