mirror of
https://github.com/dtzp555-max/odo.git
synced 2026-07-21 21:15:10 +00:00
- Add Vite + React + TypeScript + Tailwind CSS to Electron app - Create Dashboard page with gateway control (start/stop/restart/status) - Expand preload.js IPC bridge with gateway and OCM API channels - Add IPC handlers in main.js for openclaw CLI and OCM proxy - Dev mode loads Vite dev server, production loads built dist/ - Bump version to 0.10.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
305 lines
8.7 KiB
JavaScript
305 lines
8.7 KiB
JavaScript
'use strict';
|
|
|
|
const { app, BrowserWindow, Tray, Menu, nativeImage, dialog, ipcMain } = require('electron');
|
|
const path = require('path');
|
|
const { fork, execFile } = require('child_process');
|
|
const http = require('http');
|
|
|
|
// ── Dev Mode Detection ──────────────────────────────────────
|
|
const isDev = !app.isPackaged;
|
|
|
|
// ── 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.reload();
|
|
});
|
|
} 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
|
|
}
|
|
});
|
|
|
|
// Load the React UI (Vite dev server in dev, built files in production)
|
|
if (isDev) {
|
|
mainWindow.loadURL('http://localhost:5173');
|
|
} else {
|
|
mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'));
|
|
}
|
|
|
|
mainWindow.once('ready-to-show', () => {
|
|
mainWindow.show();
|
|
});
|
|
|
|
// Close → minimize to tray (hide window, keep server running)
|
|
mainWindow.on('close', (event) => {
|
|
if (!isQuitting) {
|
|
event.preventDefault();
|
|
mainWindow.hide();
|
|
if (process.platform === 'darwin' && app.dock) {
|
|
app.dock.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();
|
|
if (process.platform === 'darwin' && app.dock) app.dock.show();
|
|
} else {
|
|
createWindow();
|
|
}
|
|
}
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: 'Restart Server',
|
|
click: async () => {
|
|
stopServer();
|
|
await startServer();
|
|
if (mainWindow) mainWindow.reload();
|
|
}
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: 'Quit ODO',
|
|
click: () => {
|
|
isQuitting = true;
|
|
app.quit();
|
|
}
|
|
}
|
|
]);
|
|
|
|
tray.setContextMenu(contextMenu);
|
|
|
|
// Left-click tray → show window (Windows/Linux; macOS shows context menu by default)
|
|
tray.on('click', () => {
|
|
if (mainWindow) {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
if (process.platform === 'darwin' && app.dock) app.dock.show();
|
|
} else {
|
|
createWindow();
|
|
}
|
|
});
|
|
|
|
// Double-click tray → show window (Windows/Linux)
|
|
tray.on('double-click', () => {
|
|
if (mainWindow) {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
if (process.platform === 'darwin' && app.dock) app.dock.show();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── IPC Handlers ────────────────────────────────────────────
|
|
|
|
function runOpenclawCommand(args) {
|
|
return new Promise((resolve, reject) => {
|
|
execFile('openclaw', args, { timeout: 30000 }, (error, stdout, stderr) => {
|
|
if (error) {
|
|
resolve(stderr || error.message);
|
|
} else {
|
|
resolve(stdout || 'OK');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function proxyOcmApi(method, urlPath, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const options = {
|
|
hostname: '127.0.0.1',
|
|
port: serverPort,
|
|
path: urlPath,
|
|
method: method.toUpperCase(),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
};
|
|
|
|
const req = http.request(options, (res) => {
|
|
let data = '';
|
|
res.on('data', (chunk) => (data += chunk));
|
|
res.on('end', () => {
|
|
try {
|
|
resolve(JSON.parse(data));
|
|
} catch {
|
|
resolve(data);
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('error', (err) => reject(err.message));
|
|
|
|
if (body && method.toUpperCase() !== 'GET') {
|
|
req.write(JSON.stringify(body));
|
|
}
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
ipcMain.handle('gateway:status', () => runOpenclawCommand(['gateway', 'status']));
|
|
ipcMain.handle('gateway:start', () => runOpenclawCommand(['gateway', 'start']));
|
|
ipcMain.handle('gateway:stop', () => runOpenclawCommand(['gateway', 'stop']));
|
|
ipcMain.handle('gateway:restart', () => runOpenclawCommand(['gateway', 'restart']));
|
|
ipcMain.handle('ocm:api', (_event, { method, path: urlPath, body }) =>
|
|
proxyOcmApi(method, urlPath, body)
|
|
);
|
|
|
|
// ── 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();
|
|
mainWindow.focus();
|
|
if (process.platform === 'darwin' && app.dock) app.dock.show();
|
|
} else {
|
|
createWindow();
|
|
}
|
|
});
|