mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-04 07:46:16 +02:00
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:
parent
2d285bfbc7
commit
cd28a109b9
14
electron/assets/README.md
Normal file
14
electron/assets/README.md
Normal 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
49
electron/package.json
Normal 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
251
electron/src/loading.html
Normal 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
344
electron/src/main.ts
Normal 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
22
electron/src/preload.ts
Normal 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
16
electron/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,13 @@
|
||||||
"description": "\"",
|
"description": "\"",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"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",
|
"author": "Crosstalk Solutions, LLC",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user