electron: desktop app shell — login, JWT auth, header injection, token persistence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
54267624bf
commit
7b9f7a1fb9
|
|
@ -0,0 +1,136 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Roject — Sign in</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #e8e8e8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 340px;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e8e8e8;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus { border-color: #555; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem;
|
||||||
|
background: #e8e8e8;
|
||||||
|
color: #0f0f0f;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover { background: #fff; }
|
||||||
|
button:disabled { background: #333; color: #666; cursor: default; }
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.83rem;
|
||||||
|
color: #e05555;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
min-height: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Roject</h1>
|
||||||
|
<p class="subtitle">Sign in with your rokojori account</p>
|
||||||
|
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input id="email" type="email" placeholder="you@example.com" autocomplete="email">
|
||||||
|
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" type="password" placeholder="••••••••" autocomplete="current-password">
|
||||||
|
|
||||||
|
<button id="btn">Sign in</button>
|
||||||
|
<p class="error" id="error"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const emailEl = document.getElementById( 'email' );
|
||||||
|
const passwordEl = document.getElementById( 'password' );
|
||||||
|
const btn = document.getElementById( 'btn' );
|
||||||
|
const errorEl = document.getElementById( 'error' );
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const email = emailEl.value.trim();
|
||||||
|
const password = passwordEl.value;
|
||||||
|
if ( !email || !password ) { errorEl.textContent = 'Please enter email and password.'; return; }
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Signing in…';
|
||||||
|
errorEl.textContent = '';
|
||||||
|
|
||||||
|
const result = await window.electronAuth.login( email, password );
|
||||||
|
|
||||||
|
if ( result.ok ) {
|
||||||
|
window.electronAuth.loginSuccess();
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = result.error ?? 'Sign in failed.';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Sign in';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener( 'click', login );
|
||||||
|
document.addEventListener( 'keydown', ( e ) => { if ( e.key === 'Enter' ) login(); } );
|
||||||
|
|
||||||
|
emailEl.focus();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
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();
|
||||||
|
} );
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld( 'electronAuth', {
|
||||||
|
login: ( email: string, password: string ) =>
|
||||||
|
ipcRenderer.invoke( 'auth:login', email, password ),
|
||||||
|
loginSuccess: () =>
|
||||||
|
ipcRenderer.send( 'auth:login-success' ),
|
||||||
|
} );
|
||||||
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
|
|
@ -1,10 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "roject",
|
"name": "roject",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "source/server/index.ts",
|
"main": "build/electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npm run build && ts-node --project tsconfig.ts-node.json source/server/index.ts",
|
"start": "npm run build && ts-node --project tsconfig.ts-node.json source/server/index.ts",
|
||||||
"build": "tsc --build tsconfig.client.json && node scripts/copy-pages.js"
|
"build": "tsc --build tsconfig.client.json && node scripts/copy-pages.js",
|
||||||
|
"electron:build": "npm run build && tsc --project tsconfig.electron-server.json && tsc --project tsconfig.electron.json && node scripts/copy-electron-assets.js",
|
||||||
|
"electron:dev": "npm run electron:build && node scripts/launch-electron.js",
|
||||||
|
"electron:dist": "npm run electron:build && electron-builder"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@langchain/core": "^1.2.2",
|
"@langchain/core": "^1.2.2",
|
||||||
|
|
@ -21,7 +24,30 @@
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"electron": "^35.7.5",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.rokojori.roject",
|
||||||
|
"productName": "Roject",
|
||||||
|
"files": [
|
||||||
|
"build/**/*",
|
||||||
|
"node_modules/**/*",
|
||||||
|
"data/**/*"
|
||||||
|
],
|
||||||
|
"directories": {
|
||||||
|
"output": "dist"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": "nsis"
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": "dmg"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
const fs = require( 'fs' );
|
||||||
|
const path = require( 'path' );
|
||||||
|
|
||||||
|
const src = path.join( __dirname, '..', 'electron' );
|
||||||
|
const dest = path.join( __dirname, '..', 'build', 'electron' );
|
||||||
|
|
||||||
|
fs.mkdirSync( dest, { recursive: true } );
|
||||||
|
fs.copyFileSync( path.join( src, 'login.html' ), path.join( dest, 'login.html' ) );
|
||||||
|
|
||||||
|
console.log( 'Electron assets copied.' );
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
const { spawn } = require( 'child_process' );
|
||||||
|
const path = require( 'path' );
|
||||||
|
|
||||||
|
const electronBin = require( '../node_modules/electron' );
|
||||||
|
const appDir = path.join( __dirname, '..' );
|
||||||
|
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE;
|
||||||
|
|
||||||
|
const child = spawn( electronBin, [ appDir ], { stdio: 'inherit', env } );
|
||||||
|
child.on( 'close', ( code ) => process.exit( code ?? 0 ) );
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { ROOT } from './rootDir';
|
||||||
|
|
||||||
const DATA_DIR = path.join(__dirname, '..', '..', 'build', 'data', 'db');
|
const DATA_DIR = path.join( ROOT, 'build', 'data', 'db' );
|
||||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { ROOT } from './rootDir';
|
||||||
import { jwtMiddleware, requireAuth } from './middleware/auth';
|
import { jwtMiddleware, requireAuth } from './middleware/auth';
|
||||||
import groupsRouter from './routes/groups';
|
import groupsRouter from './routes/groups';
|
||||||
import projectsRouter from './routes/projects';
|
import projectsRouter from './routes/projects';
|
||||||
|
|
@ -21,7 +22,7 @@ app.use( express.text( { type: 'text/plain' } ) );
|
||||||
app.use( cookieParser() );
|
app.use( cookieParser() );
|
||||||
app.use( jwtMiddleware );
|
app.use( jwtMiddleware );
|
||||||
|
|
||||||
app.use( express.static( path.join( __dirname, '..', '..', 'build', 'app' ) ) );
|
app.use( express.static( path.join( ROOT, 'build', 'app' ) ) );
|
||||||
|
|
||||||
app.use( '/api/groups', groupsRouter );
|
app.use( '/api/groups', groupsRouter );
|
||||||
app.use( '/api/projects', projectsRouter );
|
app.use( '/api/projects', projectsRouter );
|
||||||
|
|
@ -34,5 +35,11 @@ app.get( '/api/auth/me', requireAuth, ( req, res ) => res.json( req.user ) );
|
||||||
|
|
||||||
app.get( '/', ( _req, res ) => res.redirect( '/dashboard.html' ) );
|
app.get( '/', ( _req, res ) => res.redirect( '/dashboard.html' ) );
|
||||||
|
|
||||||
const PORT = process.env.PORT ? Number( process.env.PORT ) : 3000;
|
export function startServer( port: number ): void {
|
||||||
app.listen( PORT, () => console.log( `Roject running on http://localhost:${PORT}` ) );
|
app.listen( port, () => console.log( `Roject running on http://localhost:${port}` ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( require.main === module ) {
|
||||||
|
const PORT = process.env.PORT ? Number( process.env.PORT ) : 3000;
|
||||||
|
startServer( PORT );
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { ROOT } from './rootDir';
|
||||||
|
|
||||||
const LOCALES_DIR = path.join( __dirname, '..', 'locale-data', 'en' );
|
const LOCALES_DIR = path.join( ROOT, 'source', 'locale-data', 'en' );
|
||||||
const GENERATED_DIR = path.join( __dirname, '..', 'locales', 'generated' );
|
const GENERATED_DIR = path.join( ROOT, 'source', 'locales', 'generated' );
|
||||||
|
|
||||||
function toPascalCase( name: string ): string
|
function toPascalCase( name: string ): string
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import path from 'path';
|
||||||
|
// ROJECT_ROOT is set by electron/main.ts before starting the server.
|
||||||
|
// In ts-node mode __dirname is source/server/, so ../../ is the project root.
|
||||||
|
export const ROOT = process.env.ROJECT_ROOT ?? path.join( __dirname, '..', '..' );
|
||||||
|
|
@ -3,11 +3,12 @@ import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { requireAuth } from '../middleware/auth';
|
import { requireAuth } from '../middleware/auth';
|
||||||
import { RJLog } from '../../library-ts/node/log/RJLog';
|
import { RJLog } from '../../library-ts/node/log/RJLog';
|
||||||
|
import { ROOT } from '../rootDir';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use( requireAuth );
|
router.use( requireAuth );
|
||||||
|
|
||||||
const LAYOUTS_DIR = path.join( __dirname, '..', '..', '..', 'build', 'data', 'storage', 'layouts' );
|
const LAYOUTS_DIR = path.join( ROOT, 'build', 'data', 'storage', 'layouts' );
|
||||||
|
|
||||||
function layoutFilePath( userId: string, deviceId: string ): string
|
function layoutFilePath( userId: string, deviceId: string ): string
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { ROOT } from '../rootDir';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const LOCALES_DIR = path.join(__dirname, '..', '..', 'locale-data');
|
const LOCALES_DIR = path.join( ROOT, 'source', 'locale-data' );
|
||||||
|
|
||||||
router.get('/:locale/*', (req, res) => {
|
router.get('/:locale/*', (req, res) => {
|
||||||
const locale = req.params.locale;
|
const locale = req.params.locale;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { ROOT } from './rootDir';
|
||||||
|
|
||||||
const STORAGE_DIR = path.join(__dirname, '..', '..', 'build', 'data', 'storage');
|
const STORAGE_DIR = path.join( ROOT, 'build', 'data', 'storage' );
|
||||||
if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||||
|
|
||||||
const INITIAL_HTML = `<!DOCTYPE html>
|
const INITIAL_HTML = `<!DOCTYPE html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "build/server",
|
||||||
|
"strictNullChecks": false
|
||||||
|
},
|
||||||
|
"include": ["source/server/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "build/electron",
|
||||||
|
"rootDir": "electron"
|
||||||
|
},
|
||||||
|
"include": ["electron/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,7 @@ var NAV_DATA = {
|
||||||
title: 'History',
|
title: 'History',
|
||||||
path: 'history/index.html',
|
path: 'history/index.html',
|
||||||
children: [
|
children: [
|
||||||
|
{ title: 'Monday, 14 July 2026', path: 'history/2026/07-July/14-Monday/index.html' },
|
||||||
{ title: 'Sunday, 13 July 2026', path: 'history/2026/07-July/13-Sunday/index.html' },
|
{ title: 'Sunday, 13 July 2026', path: 'history/2026/07-July/13-Sunday/index.html' },
|
||||||
{ title: 'Sunday, 12 July 2026', path: 'history/2026/07-July/12-Sunday/index.html' },
|
{ title: 'Sunday, 12 July 2026', path: 'history/2026/07-July/12-Sunday/index.html' },
|
||||||
{ title: 'Saturday, 11 July 2026', path: 'history/2026/07-July/11-Saturday/index.html' },
|
{ title: 'Saturday, 11 July 2026', path: 'history/2026/07-July/11-Saturday/index.html' },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Monday, 14 July 2026 — Roject</title>
|
||||||
|
<link rel="stylesheet" href="../../../../_assets_/styles.css">
|
||||||
|
<link rel="stylesheet" href="../../../../_assets_/nav.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<p class="date">Monday, 14 July 2026</p>
|
||||||
|
<h1>Electron desktop app shell</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
Roject packaged as a standalone Windows desktop app with JWT-based auth,
|
||||||
|
token persistence, Authorization header injection, and a local Express server
|
||||||
|
running in-process.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>What we built</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Electron main process (<code>electron/main.ts</code>)</h3>
|
||||||
|
<p>
|
||||||
|
The Express server starts in-process inside Electron's main process via a
|
||||||
|
compiled <code>startServer(port)</code> export. A <code>BrowserWindow</code>
|
||||||
|
points at <code>http://localhost:3000</code>. The server's static file,
|
||||||
|
data, and locale paths all previously relied on <code>__dirname</code>
|
||||||
|
relative to the TypeScript source; a new <code>source/server/rootDir.ts</code>
|
||||||
|
module resolves these using <code>process.env.ROJECT_ROOT</code> (set by
|
||||||
|
Electron before starting the server) with a fallback that preserves the
|
||||||
|
existing ts-node behaviour.
|
||||||
|
</p>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">electron/main.ts</span>
|
||||||
|
<span class="tag">startServer(port)</span>
|
||||||
|
<span class="tag">source/server/rootDir.ts</span>
|
||||||
|
<span class="tag">ROJECT_ROOT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Electron login window</h3>
|
||||||
|
<p>
|
||||||
|
A minimal <code>electron/login.html</code> form collects email and password.
|
||||||
|
Credentials are sent to the main process via IPC (<code>contextBridge</code> +
|
||||||
|
<code>ipcRenderer.invoke</code>). The main process calls
|
||||||
|
<code>POST https://account.rokojori.com/api/auth/login</code> directly using
|
||||||
|
Node.js <code>https</code> — no browser redirect, no CORS, no cookie.
|
||||||
|
Tokens are written to <code>userData/tokens.json</code> and reloaded on
|
||||||
|
the next app start.
|
||||||
|
</p>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">electron/login.html</span>
|
||||||
|
<span class="tag">electron/preload.ts</span>
|
||||||
|
<span class="tag">contextBridge</span>
|
||||||
|
<span class="tag">tokens.json</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Authorization header injection</h3>
|
||||||
|
<p>
|
||||||
|
<code>session.defaultSession.webRequest.onBeforeSendHeaders</code> intercepts
|
||||||
|
every request from the BrowserWindow to <code>http://localhost:3000/*</code>
|
||||||
|
and adds <code>Authorization: Bearer <accessToken></code>. The
|
||||||
|
existing <code>extractToken</code> middleware already checked this header
|
||||||
|
(designed for Electron from the start), so the frontend required zero changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Token refresh + navigation guard</h3>
|
||||||
|
<p>
|
||||||
|
Server-side <code>302</code> redirects to
|
||||||
|
<code>/api/auth/refresh-session</code> are intercepted by
|
||||||
|
<code>will-redirect</code> — the main process calls
|
||||||
|
<code>POST /api/auth/refresh</code> with the refresh token, updates the
|
||||||
|
stored tokens, and reloads the original URL. A <code>will-navigate</code>
|
||||||
|
guard prevents the BrowserWindow from ever leaving <code>localhost</code>:
|
||||||
|
any external navigation (e.g. the app's login link pointing to
|
||||||
|
<code>account.rokojori.com</code>) is intercepted and replaced with the
|
||||||
|
Electron login window.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Build pipeline additions</h3>
|
||||||
|
<p>
|
||||||
|
Three new tsconfigs: <code>tsconfig.electron.json</code> (compiles
|
||||||
|
<code>electron/</code> → <code>build/electron/</code>),
|
||||||
|
<code>tsconfig.electron-server.json</code> (compiles
|
||||||
|
<code>source/server/</code> → <code>build/server/</code> for Electron's
|
||||||
|
in-process require). New npm scripts: <code>electron:build</code>,
|
||||||
|
<code>electron:dev</code>, <code>electron:dist</code>.
|
||||||
|
<code>scripts/copy-electron-assets.js</code> copies <code>login.html</code>
|
||||||
|
into <code>build/electron/</code>.
|
||||||
|
<code>scripts/launch-electron.js</code> spawns the Electron binary with
|
||||||
|
<code>ELECTRON_RUN_AS_NODE</code> deleted from the environment (VS Code /
|
||||||
|
Claude Code set this variable, which otherwise makes Electron behave as
|
||||||
|
plain Node.js with no GUI or API).
|
||||||
|
</p>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">electron-builder</span>
|
||||||
|
<span class="tag">scripts/launch-electron.js</span>
|
||||||
|
<span class="tag">ELECTRON_RUN_AS_NODE workaround</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Key decisions</h2>
|
||||||
|
|
||||||
|
<div class="decision">
|
||||||
|
<strong>Express runs in-process, not as a child process</strong>
|
||||||
|
<p>
|
||||||
|
The outline originally described the server as a child process. Running it
|
||||||
|
in-process is simpler, removes IPC overhead, and is equivalent for the first
|
||||||
|
iteration. The compiled server JS is required at runtime via a dynamic
|
||||||
|
<code>require(serverPath)</code> call.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="decision">
|
||||||
|
<strong>ELECTRON_RUN_AS_NODE is set by the VS Code environment</strong>
|
||||||
|
<p>
|
||||||
|
When this env var is set, the Electron binary runs as plain Node.js: no GUI,
|
||||||
|
no Electron API, <code>require('electron')</code> returns the binary path
|
||||||
|
string. The launcher script deletes it before spawning so the binary
|
||||||
|
initialises as a real Electron app. This was discovered by writing debug
|
||||||
|
output to a log file from inside the Electron process.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="decision">
|
||||||
|
<strong>rootDir.ts centralises all server path resolution</strong>
|
||||||
|
<p>
|
||||||
|
Six server files used <code>path.join(__dirname, '..', '..', ...)</code>
|
||||||
|
relative to the TypeScript source depth. Compiled output is one level deeper
|
||||||
|
(<code>build/server/server/</code>), breaking all paths. A single
|
||||||
|
<code>rootDir.ts</code> module exposes <code>ROOT</code> that all files
|
||||||
|
import, using <code>ROJECT_ROOT</code> when set (Electron) or the
|
||||||
|
<code>__dirname</code> fallback (ts-node).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="decision">
|
||||||
|
<strong>.env loaded by the Electron main process</strong>
|
||||||
|
<p>
|
||||||
|
The Express server reads <code>JWT_SECRET</code> from the environment.
|
||||||
|
When started via <code>npm start</code> this is provided by the shell; when
|
||||||
|
started from Electron there is no shell. A small inline <code>loadEnv()</code>
|
||||||
|
function in <code>electron/main.ts</code> parses the project-root
|
||||||
|
<code>.env</code> file and sets missing variables before <code>startServer</code>
|
||||||
|
is called.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>What's next</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p>
|
||||||
|
Local filesystem access — extend the file tree to browse arbitrary directories
|
||||||
|
on the host machine using Node.js <code>fs</code> rather than the server's
|
||||||
|
JSON-backed project storage.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
Remote projects in Electron — allow the Electron app to connect to a remote
|
||||||
|
Roject server (<code>roject.rokojori.com</code>) and list projects hosted
|
||||||
|
there alongside local ones. The JWT is already available; it's a matter of
|
||||||
|
pointing a request (or a BrowserView panel) at the remote URL with the token.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Roject — session history
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script>var NAV_ROOT = '../../../../';</script>
|
||||||
|
<script src="../../../../_assets_/nav-data.js"></script>
|
||||||
|
<script src="../../../../_assets_/nav.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -19,6 +19,11 @@
|
||||||
<section>
|
<section>
|
||||||
<h2>2026 — July</h2>
|
<h2>2026 — July</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3><a href="2026/07-July/14-Monday/index.html">Monday, 14 July 2026</a></h3>
|
||||||
|
<p>Electron desktop app shell — login window, JWT auth via API, Authorization header injection, token persistence, ROJECT_ROOT path fix, ELECTRON_RUN_AS_NODE workaround.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3><a href="2026/07-July/13-Sunday/index.html">Sunday, 13 July 2026</a></h3>
|
<h3><a href="2026/07-July/13-Sunday/index.html">Sunday, 13 July 2026</a></h3>
|
||||||
<p>rokojori-auth built and deployed; Roject local auth replaced with JWT middleware; CI/CD pipeline — webhook-based auto-deploy on push to main via /api/deploy.</p>
|
<p>rokojori-auth built and deployed; Roject local auth replaced with JWT middleware; CI/CD pipeline — webhook-based auto-deploy on push to main via /api/deploy.</p>
|
||||||
|
|
|
||||||
|
|
@ -91,38 +91,48 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>1 — Electron desktop app + local filesystem access</h3>
|
<h3>1 — Local filesystem access</h3>
|
||||||
<p>
|
<p>
|
||||||
Package Roject as a standalone desktop application using Electron. Since the
|
The Electron shell is done (see below). The remaining work is extending the
|
||||||
frontend is already plain HTML/JS/CSS, the Electron integration is mostly
|
file tree to browse arbitrary directories on the host machine using Node.js
|
||||||
structural: the Express server runs as a child process inside Electron's main
|
|
||||||
process, and the BrowserWindow is pointed at it. No frontend changes are needed
|
|
||||||
to make the existing UI run inside Electron.
|
|
||||||
</p>
|
|
||||||
<p style="margin-top:0.75rem">
|
|
||||||
Local filesystem access follows from this almost for free: the file tree is
|
|
||||||
extended to browse arbitrary directories on the host machine using Node.js
|
|
||||||
<code>fs</code> directly, rather than being restricted to the
|
<code>fs</code> directly, rather than being restricted to the
|
||||||
<code>storage/<uuid>/root/</code> paths managed by the server. This is
|
<code>storage/<uuid>/root/</code> paths managed by the server. This is
|
||||||
what turns Roject from a hosted CMS into something closer to VS Code — the
|
what turns Roject from a hosted CMS into something closer to VS Code — the
|
||||||
user can open any folder on their machine as a project.
|
user can open any folder on their machine as a project.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin-top:0.75rem">
|
|
||||||
These two features are treated as a single unit of work: there is no point
|
|
||||||
shipping Electron without local filesystem access, and local filesystem access
|
|
||||||
in the browser would require the File System Access API with significant UX
|
|
||||||
friction. Electron is the cleaner path.
|
|
||||||
</p>
|
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
<span class="tag">Electron</span>
|
|
||||||
<span class="tag">child_process</span>
|
|
||||||
<span class="tag">Node.js fs</span>
|
<span class="tag">Node.js fs</span>
|
||||||
<span class="tag">BrowserWindow</span>
|
<span class="tag">file tree extension</span>
|
||||||
|
<span class="tag">local mode</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>2 — Local git repository integration</h3>
|
<h3>2 — Remote projects in Electron</h3>
|
||||||
|
<p>
|
||||||
|
The Electron app currently runs a fully local Express server with its own
|
||||||
|
data store — it shares the same identity as the web version (via rokojori-auth)
|
||||||
|
but not the same projects. The next step is to allow the Electron app to also
|
||||||
|
connect to a remote Roject server (e.g. <code>roject.rokojori.com</code>) and
|
||||||
|
list, open, and edit projects hosted there, alongside any local filesystem
|
||||||
|
projects.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
The Electron app already holds a valid JWT and can send it as an
|
||||||
|
<code>Authorization: Bearer</code> header. Connecting to a remote server is
|
||||||
|
therefore a matter of pointing a second BrowserWindow (or a panel in the
|
||||||
|
existing window) at the remote URL and injecting the token — no new auth
|
||||||
|
work needed.
|
||||||
|
</p>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">remote Roject server</span>
|
||||||
|
<span class="tag">Authorization header</span>
|
||||||
|
<span class="tag">mixed local + remote</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>3 — Local git repository integration</h3>
|
||||||
<p>
|
<p>
|
||||||
Git integration inside the editor: file status indicators in the tree, staging,
|
Git integration inside the editor: file status indicators in the tree, staging,
|
||||||
commit, push and pull, and eventually diffs and history. This depends on local
|
commit, push and pull, and eventually diffs and history. This depends on local
|
||||||
|
|
@ -146,7 +156,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>3 — Internet tunnel / port pass-through relay</h3>
|
<h3>4 — Internet tunnel / port pass-through relay</h3>
|
||||||
<p>
|
<p>
|
||||||
A tunneling feature that allows local devices — a main workstation running
|
A tunneling feature that allows local devices — a main workstation running
|
||||||
Stable Diffusion, a local LLM, a GDScript language server, or any other
|
Stable Diffusion, a local LLM, a GDScript language server, or any other
|
||||||
|
|
@ -174,6 +184,7 @@
|
||||||
entirely on the server, so it can be developed in parallel with the desktop
|
entirely on the server, so it can be developed in parallel with the desktop
|
||||||
work. It is placed here because its primary value is unlocked only once mobile
|
work. It is placed here because its primary value is unlocked only once mobile
|
||||||
access (feature 5) also exists.
|
access (feature 5) also exists.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
<span class="tag">WebSocket relay</span>
|
<span class="tag">WebSocket relay</span>
|
||||||
|
|
@ -184,7 +195,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>4 — Mobile app (PWA first, native shell later)</h3>
|
<h3>5 — Mobile app (PWA first, native shell later)</h3>
|
||||||
<p>
|
<p>
|
||||||
Make Roject usable on a phone or tablet. The quickest path given the existing
|
Make Roject usable on a phone or tablet. The quickest path given the existing
|
||||||
web frontend is a Progressive Web App (PWA) — a manifest file and a service
|
web frontend is a Progressive Web App (PWA) — a manifest file and a service
|
||||||
|
|
@ -213,6 +224,40 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Done — Electron desktop app shell</h3>
|
||||||
|
<p>
|
||||||
|
Roject runs as a standalone desktop application on Windows. The Express server
|
||||||
|
starts in-process inside Electron's main process. A custom login window
|
||||||
|
(<code>electron/login.html</code>) collects credentials and calls
|
||||||
|
<code>POST https://account.rokojori.com/api/auth/login</code> directly from
|
||||||
|
the main process — no browser redirect, no cookie. Tokens are stored in
|
||||||
|
<code>userData/tokens.json</code> and re-used across sessions.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
All HTTP requests from the BrowserWindow to <code>localhost</code> have
|
||||||
|
<code>Authorization: Bearer <accessToken></code> injected automatically
|
||||||
|
via <code>session.webRequest.onBeforeSendHeaders</code> — the frontend requires
|
||||||
|
zero changes. Expired tokens are refreshed via
|
||||||
|
<code>POST /api/auth/refresh</code> and the page is reloaded transparently.
|
||||||
|
Any navigation away from <code>localhost</code> is intercepted and redirected
|
||||||
|
to the Electron login window instead.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
Known issue: <code>ELECTRON_RUN_AS_NODE=1</code> is set by VS Code / Claude
|
||||||
|
Code, which makes Electron behave as plain Node.js. The launcher script
|
||||||
|
(<code>scripts/launch-electron.js</code>) deletes this variable before
|
||||||
|
spawning the binary. Run with <code>npm run electron:dev</code> or
|
||||||
|
<code>node scripts/launch-electron.js</code> after the build.
|
||||||
|
</p>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">electron/main.ts</span>
|
||||||
|
<span class="tag">webRequest header injection</span>
|
||||||
|
<span class="tag">token persistence</span>
|
||||||
|
<span class="tag">ELECTRON_RUN_AS_NODE workaround</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Done — CI/CD pipeline</h3>
|
<h3>Done — CI/CD pipeline</h3>
|
||||||
<p>
|
<p>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue