192 lines
8.1 KiB
HTML
192 lines
8.1 KiB
HTML
|
|
<!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>
|