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:
Rokojori 2026-07-14 13:31:11 +02:00
parent 54267624bf
commit 7b9f7a1fb9
20 changed files with 4846 additions and 33 deletions

136
electron/login.html Normal file
View File

@ -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>

234
electron/main.ts Normal file
View File

@ -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();
} );

8
electron/preload.ts Normal file
View File

@ -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' ),
} );

4109
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
} }
} }

View File

@ -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.' );

View File

@ -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 ) );

View File

@ -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 {

View File

@ -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' ) );
export function startServer( port: number ): void {
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; const PORT = process.env.PORT ? Number( process.env.PORT ) : 3000;
app.listen( PORT, () => console.log( `Roject running on http://localhost:${PORT}` ) ); startServer( PORT );
}

View File

@ -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
{ {

4
source/server/rootDir.ts Normal file
View File

@ -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, '..', '..' );

View File

@ -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
{ {

View File

@ -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;

View File

@ -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>

View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "build/server",
"strictNullChecks": false
},
"include": ["source/server/**/*"]
}

13
tsconfig.electron.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "build/electron",
"rootDir": "electron"
},
"include": ["electron/**/*"]
}

View File

@ -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' },

View File

@ -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 &lt;accessToken&gt;</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 &mdash; session history
</footer>
</div>
<script>var NAV_ROOT = '../../../../';</script>
<script src="../../../../_assets_/nav-data.js"></script>
<script src="../../../../_assets_/nav.js"></script>
</body>
</html>

View File

@ -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>

View File

@ -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/&lt;uuid&gt;/root/</code> paths managed by the server. This is <code>storage/&lt;uuid&gt;/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 &lt;accessToken&gt;</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>