feat: add Electron desktop app wrapper

Adds a native desktop application shell in electron/ that wraps the
existing Docker-based NOMAD stack in an Electron BrowserWindow.

Architecture:
- Single instance lock prevents multiple app copies from running
- Main process manages the full Docker Compose lifecycle (up/stop)
- Health-check polling (GET /api/health) with 3-minute timeout before
  loading the NOMAD web UI in the BrowserWindow
- System tray stays alive when the window is closed, with menu items
  for Show/Hide, Open in Browser, Restart, Stop, and Quit
- Retry button on the loading screen re-runs the launch sequence
  without needing to restart the app

Files:
  electron/package.json       - Electron 34 + electron-builder config
                                Targets: AppImage/deb (Linux), DMG (Mac),
                                NSIS (Windows); supports x64 + arm64
  electron/tsconfig.json      - CommonJS/ES2020 TypeScript for main process
  electron/src/main.ts        - Main process: window, tray, Docker helpers,
                                startup state machine, IPC handlers
  electron/src/preload.ts     - contextBridge: exposes onStatus / retryLaunch
                                to loading screen without full nodeIntegration
  electron/src/loading.html   - Branded startup screen with animated progress
                                bar, status messages, and error retry button
  electron/assets/README.md   - Icon file guide (512px PNG, tray 32px PNG,
                                ICNS for Mac, ICO for Windows)

Root scripts added:
  npm run electron:install     - install electron deps
  npm run electron:build       - compile TypeScript
  npm run electron:dev         - build + launch in dev mode
  npm run electron📦*   - package for linux/mac/win

https://claude.ai/code/session_01WfRC4tDeYprykhMrg4PxX6
This commit is contained in:
Claude 2026-03-22 21:53:02 +00:00
parent 2d285bfbc7
commit cd28a109b9
No known key found for this signature in database
7 changed files with 703 additions and 1 deletions

14
electron/assets/README.md Normal file
View File

@ -0,0 +1,14 @@
# Electron App Icons
Place the following icon files here for the packaged desktop app:
| File | Size | Platform |
|-----------------|-------------|----------------|
| `icon.png` | 512×512 px | Linux (AppImage, deb) |
| `tray-icon.png` | 32×32 px | System tray (all platforms) |
| `icon.icns` | macOS bundle | macOS |
| `icon.ico` | Multi-size ICO | Windows |
The `tray-icon.png` is also used as a fallback tray icon during development.
If it is missing, Electron will use a blank 1×1 image and the tray entry will
still appear (just without a visible icon).

49
electron/package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "nomad-desktop",
"version": "1.0.0",
"description": "Project N.O.M.A.D. Desktop Application",
"main": "dist/main.js",
"private": true,
"scripts": {
"build": "tsc && cp src/loading.html dist/loading.html",
"dev": "npm run build && electron .",
"watch": "tsc --watch",
"package:linux": "npm run build && electron-builder --linux",
"package:mac": "npm run build && electron-builder --mac",
"package:win": "npm run build && electron-builder --win"
},
"devDependencies": {
"@types/node": "^22.0.0",
"electron": "^34.0.0",
"electron-builder": "^25.0.0",
"typescript": "^5.7.0"
},
"build": {
"appId": "us.projectnomad.desktop",
"productName": "Project N.O.M.A.D.",
"copyright": "Copyright © 2025 Crosstalk Solutions, LLC",
"files": [
"dist/**/*",
"assets/**/*"
],
"linux": {
"target": [
{ "target": "AppImage", "arch": ["x64", "arm64"] },
{ "target": "deb", "arch": ["x64", "arm64"] }
],
"icon": "assets/icon.png",
"category": "Network"
},
"mac": {
"target": [
{ "target": "dmg", "arch": ["x64", "arm64"] }
],
"icon": "assets/icon.icns",
"darkModeSupport": true
},
"win": {
"target": "nsis",
"icon": "assets/icon.ico"
}
}
}

251
electron/src/loading.html Normal file
View File

@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
<title>Project N.O.M.A.D.</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0f1e;
--surface: #0f172a;
--border: #1e293b;
--accent: #38bdf8;
--accent-dim:#0c4a6e;
--text: #f1f5f9;
--muted: #64748b;
--success: #34d399;
--error: #f87171;
}
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
/* Allow dragging the frameless window by the body */
-webkit-app-region: drag;
user-select: none;
overflow: hidden;
}
/* ── Layout ── */
.shell {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
padding: 40px;
}
/* ── Logo mark ── */
.logo-mark {
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, var(--accent-dim) 0%, #0f2d4a 100%);
border: 1px solid var(--accent-dim);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
box-shadow: 0 0 32px rgba(56, 189, 248, 0.12);
}
.logo-mark svg {
width: 34px;
height: 34px;
}
/* ── Wordmark ── */
.wordmark {
font-size: 22px;
font-weight: 700;
letter-spacing: 0.2em;
color: var(--text);
margin-bottom: 6px;
}
.wordmark .dot { color: var(--accent); }
.tagline {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 52px;
}
/* ── Progress area ── */
.progress-area {
width: 280px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.track {
width: 100%;
height: 2px;
background: var(--border);
border-radius: 1px;
overflow: hidden;
position: relative;
}
.fill {
position: absolute;
inset: 0;
background: var(--accent);
border-radius: 1px;
width: 0%;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: left;
}
.fill.indeterminate {
width: 35%;
animation: slide 1.6s ease-in-out infinite;
}
.fill.success { background: var(--success); }
.fill.error { background: var(--error); width: 100%; }
@keyframes slide {
0% { transform: translateX(-110%); }
100% { transform: translateX(350%); }
}
.status-text {
font-size: 13px;
color: var(--muted);
text-align: center;
min-height: 20px;
transition: color 0.2s;
}
.status-text.success { color: var(--success); }
.status-text.error { color: var(--error); }
/* ── Error retry button ── */
.retry-btn {
display: none;
margin-top: 10px;
padding: 8px 20px;
background: transparent;
color: var(--accent);
border: 1px solid var(--accent-dim);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
cursor: pointer;
-webkit-app-region: no-drag;
transition: background 0.15s, border-color 0.15s;
}
.retry-btn:hover {
background: rgba(56, 189, 248, 0.08);
border-color: var(--accent);
}
.retry-btn.visible { display: block; }
/* ── Subtle version ── */
.version {
position: fixed;
bottom: 20px;
font-size: 11px;
color: #334155;
letter-spacing: 0.04em;
}
</style>
</head>
<body>
<div class="shell">
<!-- Logo mark -->
<div class="logo-mark">
<svg viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Stylised signal-wave / satellite icon -->
<circle cx="17" cy="17" r="4" fill="#38bdf8"/>
<path d="M11 17 A6 6 0 0 1 23 17" stroke="#38bdf8" stroke-width="1.8"
stroke-linecap="round" fill="none"/>
<path d="M7 17 A10 10 0 0 1 27 17" stroke="#38bdf8" stroke-width="1.4"
stroke-linecap="round" fill="none" opacity="0.5"/>
<path d="M3 17 A14 14 0 0 1 31 17" stroke="#38bdf8" stroke-width="1"
stroke-linecap="round" fill="none" opacity="0.25"/>
</svg>
</div>
<!-- Wordmark -->
<div class="wordmark">
N<span class="dot">.</span>O<span class="dot">.</span>M<span class="dot">.</span>A<span class="dot">.</span>D<span class="dot">.</span>
</div>
<div class="tagline">Offline Knowledge Server</div>
<!-- Progress -->
<div class="progress-area">
<div class="track">
<div class="fill indeterminate" id="fill"></div>
</div>
<div class="status-text" id="status">Initializing…</div>
<button class="retry-btn" id="retry-btn">Try Again</button>
</div>
</div>
<div class="version">v<!-- version injected by build if needed --></div>
<script>
const fill = document.getElementById('fill')
const statusEl = document.getElementById('status')
const retryBtn = document.getElementById('retry-btn')
function applyStatus({ message, progress, type }) {
statusEl.textContent = message
// Reset classes
fill.className = 'fill'
statusEl.className = 'status-text'
retryBtn.className = 'retry-btn'
if (type === 'error') {
fill.classList.add('error')
statusEl.classList.add('error')
retryBtn.classList.add('visible')
} else if (type === 'success') {
fill.classList.add('success')
fill.style.width = '100%'
statusEl.classList.add('success')
} else {
fill.style.width = progress + '%'
}
}
retryBtn.addEventListener('click', () => {
// Reset UI to loading state
fill.className = 'fill indeterminate'
fill.style.width = ''
statusEl.className = 'status-text'
statusEl.textContent = 'Retrying…'
retryBtn.className = 'retry-btn'
if (window.nomadElectron) {
window.nomadElectron.retryLaunch()
}
})
// Hook into Electron IPC
if (window.nomadElectron) {
window.nomadElectron.onStatus(applyStatus)
}
</script>
</body>
</html>

344
electron/src/main.ts Normal file
View File

@ -0,0 +1,344 @@
import {
app,
BrowserWindow,
Tray,
Menu,
nativeImage,
shell,
ipcMain,
dialog,
} from 'electron'
import { execFile, spawn } from 'child_process'
import * as path from 'path'
import * as http from 'http'
import * as fs from 'fs'
// ─── Constants ───────────────────────────────────────────────────────────────
const COMPOSE_FILE = '/opt/project-nomad/compose.yml'
const ADMIN_URL = 'http://localhost:8080'
const HEALTH_URL = `${ADMIN_URL}/api/health`
const MAX_WAIT_MS = 180_000 // 3 minutes
// ─── State ───────────────────────────────────────────────────────────────────
let win: BrowserWindow | null = null
let tray: Tray | null = null
let isQuitting = false
let serverState: 'stopped' | 'starting' | 'running' | 'error' = 'stopped'
// ─── Single instance lock ─────────────────────────────────────────────────────
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) {
app.quit()
process.exit(0)
}
app.on('second-instance', () => {
win?.show()
win?.focus()
})
// ─── Window ──────────────────────────────────────────────────────────────────
function createWindow(): void {
win = new BrowserWindow({
width: 1280,
height: 840,
minWidth: 940,
minHeight: 600,
title: 'Project N.O.M.A.D.',
backgroundColor: '#0f172a',
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
})
win.loadFile(path.join(__dirname, 'loading.html'))
win.once('ready-to-show', () => win?.show())
// Minimize to tray on close instead of quitting
win.on('close', (e) => {
if (!isQuitting) {
e.preventDefault()
win?.hide()
}
})
// Open external links in the system browser, not in the Electron window
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
}
// ─── System Tray ─────────────────────────────────────────────────────────────
function getTrayIcon(): Electron.NativeImage {
const iconPath = path.join(__dirname, '..', 'assets', 'tray-icon.png')
if (fs.existsSync(iconPath)) {
return nativeImage.createFromPath(iconPath)
}
// Fallback: 1×1 transparent PNG so Electron doesn't throw
return nativeImage.createFromDataURL(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
)
}
function createTray(): void {
tray = new Tray(getTrayIcon())
tray.setToolTip('Project N.O.M.A.D.')
rebuildTrayMenu()
tray.on('double-click', () => {
win?.show()
win?.focus()
})
}
function rebuildTrayMenu(): void {
const isRunning = serverState === 'running'
const menu = Menu.buildFromTemplate([
{
label: 'Show NOMAD',
click: () => {
win?.show()
win?.focus()
},
},
{
label: 'Open in Browser',
enabled: isRunning,
click: () => shell.openExternal(ADMIN_URL),
},
{ type: 'separator' },
{
label: serverState === 'starting' ? 'Starting…' : 'Restart Server',
enabled: serverState !== 'starting',
click: () => restartServer(),
},
{
label: 'Stop Server',
enabled: isRunning,
click: () => stopServerAndNotify(),
},
{ type: 'separator' },
{
label: 'Quit',
click: () => quit(),
},
])
tray?.setContextMenu(menu)
}
// ─── IPC handlers ─────────────────────────────────────────────────────────────
ipcMain.on('retry-launch', () => {
launch()
})
// ─── Status messaging ────────────────────────────────────────────────────────
function sendStatus(
message: string,
progress: number,
type: 'info' | 'error' | 'success' = 'info'
): void {
win?.webContents.send('startup:status', { message, progress, type })
}
// ─── Docker helpers ───────────────────────────────────────────────────────────
function checkDockerRunning(): Promise<boolean> {
return new Promise((resolve) => {
execFile('docker', ['info'], { timeout: 10_000 }, (err) => resolve(!err))
})
}
function nomadIsInstalled(): boolean {
return fs.existsSync(COMPOSE_FILE)
}
function runCompose(...args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn(
'docker',
['compose', '-p', 'project-nomad', '-f', COMPOSE_FILE, ...args],
{ stdio: 'pipe' }
)
const stderr: string[] = []
proc.stderr?.on('data', (d: Buffer) => stderr.push(d.toString()))
proc.on('close', (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(stderr.join('').trim() || `docker compose exited with code ${code}`))
}
})
proc.on('error', reject)
})
}
function checkHealth(): Promise<boolean> {
return new Promise((resolve) => {
const req = http.get(HEALTH_URL, { timeout: 5_000 }, (res) => {
resolve(res.statusCode === 200)
})
req.on('error', () => resolve(false))
req.on('timeout', () => {
req.destroy()
resolve(false)
})
})
}
async function waitForHealth(): Promise<void> {
const deadline = Date.now() + MAX_WAIT_MS
const milestones = [
{ after: 0, message: 'Starting services…', progress: 8 },
{ after: 10_000, message: 'Waiting for database…', progress: 25 },
{ after: 30_000, message: 'Initializing AI services…', progress: 50 },
{ after: 60_000, message: 'Almost ready…', progress: 72 },
{ after: 90_000, message: 'Taking longer than usual…', progress: 85 },
{ after: 120_000, message: 'Still waiting…', progress: 92 },
]
const started = Date.now()
let mIdx = 0
while (Date.now() < deadline) {
const elapsed = Date.now() - started
while (mIdx < milestones.length - 1 && elapsed >= milestones[mIdx + 1].after) mIdx++
const m = milestones[mIdx]
sendStatus(m.message, m.progress)
if (await checkHealth()) return
await new Promise<void>((r) => setTimeout(r, 2_000))
}
throw new Error('Server did not become healthy within the timeout period')
}
// ─── Server lifecycle ─────────────────────────────────────────────────────────
async function restartServer(): Promise<void> {
// Reload loading screen before starting
win?.loadFile(path.join(__dirname, 'loading.html'))
await new Promise<void>((r) => setTimeout(r, 300))
await launch()
}
async function stopServerAndNotify(): Promise<void> {
serverState = 'stopped'
rebuildTrayMenu()
try {
await runCompose('stop')
} catch {
// best-effort
}
}
// ─── Main launch sequence ─────────────────────────────────────────────────────
async function launch(): Promise<void> {
serverState = 'starting'
rebuildTrayMenu()
sendStatus('Checking Docker…', 2)
const dockerOk = await checkDockerRunning()
if (!dockerOk) {
serverState = 'error'
rebuildTrayMenu()
sendStatus(
'Docker is not running. Please start Docker and try again.',
0,
'error'
)
return
}
if (!nomadIsInstalled()) {
serverState = 'error'
rebuildTrayMenu()
sendStatus(
'NOMAD is not installed. Run install/install_nomad.sh to set it up.',
0,
'error'
)
return
}
sendStatus('Starting NOMAD containers…', 10)
try {
await runCompose('up', '-d')
} catch (err) {
serverState = 'error'
rebuildTrayMenu()
sendStatus(
`Failed to start containers: ${err instanceof Error ? err.message : String(err)}`,
0,
'error'
)
return
}
try {
await waitForHealth()
} catch {
serverState = 'error'
rebuildTrayMenu()
sendStatus(
'Server did not start in time. Check logs via Settings → Support.',
0,
'error'
)
return
}
serverState = 'running'
rebuildTrayMenu()
sendStatus('Ready!', 100, 'success')
// Brief pause so the user sees the "Ready!" message
await new Promise<void>((r) => setTimeout(r, 400))
win?.loadURL(ADMIN_URL)
}
// ─── Quit ─────────────────────────────────────────────────────────────────────
function quit(): void {
isQuitting = true
tray?.destroy()
app.quit()
}
// ─── App lifecycle ────────────────────────────────────────────────────────────
app.whenReady().then(() => {
createWindow()
createTray()
launch()
})
// Keep running in system tray when all windows are hidden
app.on('window-all-closed', () => {
// intentionally empty — do not quit
})
// macOS: re-show window on Dock click
app.on('activate', () => {
if (win) {
win.show()
} else {
createWindow()
}
})
app.on('before-quit', () => {
isQuitting = true
})

22
electron/src/preload.ts Normal file
View File

@ -0,0 +1,22 @@
import { contextBridge, ipcRenderer } from 'electron'
export interface StatusPayload {
message: string
progress: number
type: 'info' | 'error' | 'success'
}
// Expose a minimal, typed API to the loading screen renderer.
// This runs in every page the BrowserWindow loads — the NOMAD web app
// simply ignores `window.nomadElectron` if it's not needed.
contextBridge.exposeInMainWorld('nomadElectron', {
onStatus: (cb: (payload: StatusPayload) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: StatusPayload) => cb(data)
ipcRenderer.on('startup:status', handler)
// Return a cleanup function so the renderer can remove the listener
return () => ipcRenderer.removeListener('startup:status', handler)
},
retryLaunch: () => {
ipcRenderer.send('retry-launch')
},
})

16
electron/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -4,7 +4,13 @@
"description": "\"",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"electron:install": "cd electron && npm install",
"electron:build": "cd electron && npm run build",
"electron:dev": "cd electron && npm run dev",
"electron:package:linux": "cd electron && npm run package:linux",
"electron:package:mac": "cd electron && npm run package:mac",
"electron:package:win": "cd electron && npm run package:win"
},
"author": "Crosstalk Solutions, LLC",
"license": "ISC"