From cd28a109b989a0b0c3d0919360a0a3e9dbbe5b5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 21:53:02 +0000 Subject: [PATCH] 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:* - package for linux/mac/win https://claude.ai/code/session_01WfRC4tDeYprykhMrg4PxX6 --- electron/assets/README.md | 14 ++ electron/package.json | 49 ++++++ electron/src/loading.html | 251 +++++++++++++++++++++++++++ electron/src/main.ts | 344 ++++++++++++++++++++++++++++++++++++++ electron/src/preload.ts | 22 +++ electron/tsconfig.json | 16 ++ package.json | 8 +- 7 files changed, 703 insertions(+), 1 deletion(-) create mode 100644 electron/assets/README.md create mode 100644 electron/package.json create mode 100644 electron/src/loading.html create mode 100644 electron/src/main.ts create mode 100644 electron/src/preload.ts create mode 100644 electron/tsconfig.json diff --git a/electron/assets/README.md b/electron/assets/README.md new file mode 100644 index 0000000..2dcd23b --- /dev/null +++ b/electron/assets/README.md @@ -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). diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 0000000..82fa5bc --- /dev/null +++ b/electron/package.json @@ -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" + } + } +} diff --git a/electron/src/loading.html b/electron/src/loading.html new file mode 100644 index 0000000..1cee5be --- /dev/null +++ b/electron/src/loading.html @@ -0,0 +1,251 @@ + + + + + + + Project N.O.M.A.D. + + + +
+ + +
+ + + + + + + +
+ + +
+ N.O.M.A.D. +
+
Offline Knowledge Server
+ + +
+
+
+
+
Initializing…
+ +
+ +
+ +
v
+ + + + diff --git a/electron/src/main.ts b/electron/src/main.ts new file mode 100644 index 0000000..d77e032 --- /dev/null +++ b/electron/src/main.ts @@ -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 { + 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 { + 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 { + 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 { + 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((r) => setTimeout(r, 2_000)) + } + + throw new Error('Server did not become healthy within the timeout period') +} + +// ─── Server lifecycle ───────────────────────────────────────────────────────── + +async function restartServer(): Promise { + // Reload loading screen before starting + win?.loadFile(path.join(__dirname, 'loading.html')) + await new Promise((r) => setTimeout(r, 300)) + await launch() +} + +async function stopServerAndNotify(): Promise { + serverState = 'stopped' + rebuildTrayMenu() + try { + await runCompose('stop') + } catch { + // best-effort + } +} + +// ─── Main launch sequence ───────────────────────────────────────────────────── + +async function launch(): Promise { + 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((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 +}) diff --git a/electron/src/preload.ts b/electron/src/preload.ts new file mode 100644 index 0000000..8c35e2d --- /dev/null +++ b/electron/src/preload.ts @@ -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') + }, +}) diff --git a/electron/tsconfig.json b/electron/tsconfig.json new file mode 100644 index 0000000..19701f3 --- /dev/null +++ b/electron/tsconfig.json @@ -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"] +} diff --git a/package.json b/package.json index 7d0a07c..4ce6a2d 100644 --- a/package.json +++ b/package.json @@ -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"