feat: initial commit — odo v0.9.4 Electron desktop app for OpenClaw management

- Electron main process with tray icon and IPC setup
- openclaw-manager.js server bridge
- Architecture design document (ODO_ARCHITECTURE.md)
- preload.js for renderer context bridge
This commit is contained in:
2026-03-19 21:10:14 +10:00
commit a1e7e66ae1
9 changed files with 10786 additions and 0 deletions
+224
View File
@@ -0,0 +1,224 @@
'use strict';
const { app, BrowserWindow, Tray, Menu, nativeImage, dialog } = require('electron');
const path = require('path');
const { fork } = require('child_process');
// ── State ────────────────────────────────────────────────────
let mainWindow = null;
let tray = null;
let serverProcess = null;
let serverPort = 3333;
let isQuitting = false;
// ── Server Management ────────────────────────────────────────
function startServer() {
return new Promise((resolve, reject) => {
const serverPath = path.join(__dirname, 'server', 'openclaw-manager.js');
serverProcess = fork(serverPath, ['--host', '127.0.0.1'], {
env: { ...process.env, ELECTRON: '1' },
stdio: ['pipe', 'pipe', 'pipe', 'ipc']
});
// Listen for server ready message
serverProcess.on('message', (msg) => {
if (msg.type === 'server-ready') {
serverPort = msg.port || 3333;
console.log(`[ODO] Server ready on port ${serverPort}`);
resolve(serverPort);
}
});
// Forward server output to console
serverProcess.stdout.on('data', (data) => {
process.stdout.write(`[Server] ${data}`);
});
serverProcess.stderr.on('data', (data) => {
process.stderr.write(`[Server] ${data}`);
});
serverProcess.on('error', (err) => {
console.error('[ODO] Server process error:', err);
reject(err);
});
serverProcess.on('exit', (code) => {
console.log(`[ODO] Server process exited with code ${code}`);
serverProcess = null;
if (!isQuitting) {
// Server crashed — offer to restart
if (mainWindow) {
dialog.showMessageBox(mainWindow, {
type: 'error',
title: 'Server Stopped',
message: 'The OCM server has stopped unexpectedly.',
buttons: ['Restart Server', 'Quit ODO'],
defaultId: 0
}).then(({ response }) => {
if (response === 0) {
startServer().then(() => {
if (mainWindow) mainWindow.loadURL(`http://127.0.0.1:${serverPort}`);
});
} else {
app.quit();
}
});
}
}
});
// Timeout: if server doesn't respond in 10 seconds
setTimeout(() => {
if (serverProcess && !serverProcess.killed) {
// Try loading anyway — server might be ready but didn't send IPC
resolve(serverPort);
}
}, 10000);
});
}
function stopServer() {
if (serverProcess) {
serverProcess.kill('SIGTERM');
serverProcess = null;
}
}
// ── Window ───────────────────────────────────────────────────
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
title: 'ODO',
backgroundColor: '#0f1117',
show: false, // Show when ready to avoid flash
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true
}
});
mainWindow.loadURL(`http://127.0.0.1:${serverPort}`);
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
// Close → hide to tray (don't quit)
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault();
mainWindow.hide();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
// ── Tray ─────────────────────────────────────────────────────
function createTray() {
// Create a simple tray icon (16x16 template image for macOS)
const iconPath = path.join(__dirname, 'assets', 'tray-icon.png');
let trayIcon;
try {
trayIcon = nativeImage.createFromPath(iconPath);
if (trayIcon.isEmpty()) throw new Error('Icon not found');
} catch {
// Fallback: create a simple colored square icon
trayIcon = nativeImage.createEmpty();
}
// On macOS, use a template image for proper dark/light menu bar
if (process.platform === 'darwin') {
trayIcon = trayIcon.resize({ width: 16, height: 16 });
trayIcon.setTemplateImage(true);
}
tray = new Tray(trayIcon);
tray.setToolTip('ODO — OpenClaw Dashboard Orchestrator');
const contextMenu = Menu.buildFromTemplate([
{
label: 'Show ODO',
click: () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
} else {
createWindow();
}
}
},
{ type: 'separator' },
{
label: 'Restart Server',
click: async () => {
stopServer();
await startServer();
if (mainWindow) mainWindow.loadURL(`http://127.0.0.1:${serverPort}`);
}
},
{ type: 'separator' },
{
label: 'Quit ODO',
click: () => {
isQuitting = true;
app.quit();
}
}
]);
tray.setContextMenu(contextMenu);
// Double-click tray → show window (Windows/Linux)
tray.on('double-click', () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
});
}
// ── App Lifecycle ────────────────────────────────────────────
app.whenReady().then(async () => {
try {
await startServer();
} catch (err) {
console.error('[ODO] Failed to start server:', err);
dialog.showErrorBox('ODO — Startup Error', `Could not start the server:\n${err.message}`);
app.quit();
return;
}
createTray();
createWindow();
});
app.on('before-quit', () => {
isQuitting = true;
stopServer();
});
app.on('window-all-closed', () => {
// On macOS, don't quit when all windows close (tray keeps running)
if (process.platform !== 'darwin') {
// On Windows/Linux, keep running in tray too
// User must explicitly quit from tray menu
}
});
app.on('activate', () => {
// macOS dock click → show window
if (mainWindow) {
mainWindow.show();
} else {
createWindow();
}
});