mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 19:49:25 +01: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": "\"",
|
||||
"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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user