Monday, 14 July 2026

Electron desktop app shell

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.

What we built

Electron main process (electron/main.ts)

The Express server starts in-process inside Electron's main process via a compiled startServer(port) export. A BrowserWindow points at http://localhost:3000. The server's static file, data, and locale paths all previously relied on __dirname relative to the TypeScript source; a new source/server/rootDir.ts module resolves these using process.env.ROJECT_ROOT (set by Electron before starting the server) with a fallback that preserves the existing ts-node behaviour.

electron/main.ts startServer(port) source/server/rootDir.ts ROJECT_ROOT

Electron login window

A minimal electron/login.html form collects email and password. Credentials are sent to the main process via IPC (contextBridge + ipcRenderer.invoke). The main process calls POST https://account.rokojori.com/api/auth/login directly using Node.js https — no browser redirect, no CORS, no cookie. Tokens are written to userData/tokens.json and reloaded on the next app start.

electron/login.html electron/preload.ts contextBridge tokens.json

Authorization header injection

session.defaultSession.webRequest.onBeforeSendHeaders intercepts every request from the BrowserWindow to http://localhost:3000/* and adds Authorization: Bearer <accessToken>. The existing extractToken middleware already checked this header (designed for Electron from the start), so the frontend required zero changes.

Token refresh + navigation guard

Server-side 302 redirects to /api/auth/refresh-session are intercepted by will-redirect — the main process calls POST /api/auth/refresh with the refresh token, updates the stored tokens, and reloads the original URL. A will-navigate guard prevents the BrowserWindow from ever leaving localhost: any external navigation (e.g. the app's login link pointing to account.rokojori.com) is intercepted and replaced with the Electron login window.

Build pipeline additions

Three new tsconfigs: tsconfig.electron.json (compiles electron/build/electron/), tsconfig.electron-server.json (compiles source/server/build/server/ for Electron's in-process require). New npm scripts: electron:build, electron:dev, electron:dist. scripts/copy-electron-assets.js copies login.html into build/electron/. scripts/launch-electron.js spawns the Electron binary with ELECTRON_RUN_AS_NODE 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).

electron-builder scripts/launch-electron.js ELECTRON_RUN_AS_NODE workaround

Key decisions

Express runs in-process, not as a child process

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 require(serverPath) call.

ELECTRON_RUN_AS_NODE is set by the VS Code environment

When this env var is set, the Electron binary runs as plain Node.js: no GUI, no Electron API, require('electron') 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.

rootDir.ts centralises all server path resolution

Six server files used path.join(__dirname, '..', '..', ...) relative to the TypeScript source depth. Compiled output is one level deeper (build/server/server/), breaking all paths. A single rootDir.ts module exposes ROOT that all files import, using ROJECT_ROOT when set (Electron) or the __dirname fallback (ts-node).

.env loaded by the Electron main process

The Express server reads JWT_SECRET from the environment. When started via npm start this is provided by the shell; when started from Electron there is no shell. A small inline loadEnv() function in electron/main.ts parses the project-root .env file and sets missing variables before startServer is called.

What's next

Local filesystem access — extend the file tree to browse arbitrary directories on the host machine using Node.js fs rather than the server's JSON-backed project storage.

Remote projects in Electron — allow the Electron app to connect to a remote Roject server (roject.rokojori.com) 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.