feat: Zacus Studio macOS app

Electron + Swift scaffold, 4 tabs, OTA manager,
7 Dev Tools components, ESP32 ota_server component.
Phases 1,5,6,7,9 implemented.
This commit is contained in:
L'électron rare
2026-04-03 10:19:33 +02:00
parent 95b78e239b
commit 2f78fab2b6
32 changed files with 2976 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Dependencies
node_modules/
# Build outputs
dist/
release/
# Native modules build artifacts
src/native/.build/
src/native/build/
*.node
# Electron builder cache
.electron-builder.env
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Logs
*.log
npm-debug.log*
# Env
.env
.env.local
+56
View File
@@ -0,0 +1,56 @@
appId: fr.lelectronrare.zacus-studio
productName: Zacus Studio
copyright: "Copyright © 2026 L'Electron Rare"
directories:
output: release
buildResources: resources
files:
- dist/**/*
- node_modules/**/*
- "!node_modules/**/{CHANGELOG.md,README.md,readme.md,readme}"
- "!node_modules/**/{test,__tests__,tests,powered-test,example,examples}"
- "!node_modules/*.d.ts"
# macOS config
mac:
target:
- target: dmg
arch:
- arm64
- x64
bundleVersion: "1"
category: public.app-category.developer-tools
hardenedRuntime: true
gatekeeperAssess: false
entitlements: resources/entitlements.plist
entitlementsInherit: resources/entitlements.plist
icon: resources/icon.icns
minimumSystemVersion: "14.0"
extendInfo:
NSBluetoothAlwaysUsageDescription: "Zacus Studio needs Bluetooth to communicate with puzzle devices."
NSBluetoothPeripheralUsageDescription: "Zacus Studio needs Bluetooth to communicate with puzzle devices."
NSLocalNetworkUsageDescription: "Zacus Studio needs local network access to discover and update puzzle devices."
NSBonjourServices:
- _zacus._tcp
dmg:
contents:
- x: 130
y: 220
- x: 410
y: 220
type: link
path: /Applications
window:
width: 540
height: 380
sign: false
# Auto-updater
publish:
provider: github
owner: electron-rare
repo: le-mystere-professeur-zacus
releaseType: release
+39
View File
@@ -0,0 +1,39 @@
{
"name": "zacus-studio",
"version": "1.0.0",
"description": "Zacus Studio — macOS control hub for Professeur Zacus escape room kits",
"main": "dist/main/index.js",
"scripts": {
"dev": "concurrently \"npm run dev:renderer\" \"npm run dev:main\"",
"dev:main": "tsc -p tsconfig.main.json --watch & sleep 2 && electron .",
"dev:renderer": "vite",
"build": "npm run build:renderer && npm run build:main",
"build:renderer": "vite build",
"build:main": "tsc -p tsconfig.main.json",
"build:mac": "npm run build && electron-builder --mac --universal",
"build:mac-arm64": "npm run build && electron-builder --mac --arm64",
"notarize": "node scripts/notarize.js",
"rebuild-native": "electron-rebuild -f -w zacus-native",
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
"electron-updater": "^6.3.0",
"serialport": "^12.0.0",
"node-addon-api": "^8.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"concurrently": "^9.0.0",
"electron": "^33.0.0",
"electron-builder": "^25.0.0",
"electron-rebuild": "^3.2.9",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vite-plugin-electron": "^0.28.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Required for Electron -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Hardened runtime -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<!-- Network (WiFi OTA, mDNS) -->
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- Bluetooth (BLE DFU) -->
<key>com.apple.security.device.bluetooth</key>
<true/>
<!-- USB Serial (IOKit) -->
<key>com.apple.security.device.usb</key>
<true/>
<!-- File access for scenario files and firmware cache -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<!-- For esptool.py subprocess -->
<key>com.apple.security.cs.allow-execution</key>
<true/>
</dict>
</plist>
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
FRONTEND_V3="$REPO_ROOT/frontend-v3"
DESKTOP_DIST="$REPO_ROOT/desktop/dist/renderer/apps"
echo "Building frontend-v3 apps..."
mkdir -p "$DESKTOP_DIST"
for app in editor dashboard simulation; do
echo " -> Building $app..."
(cd "$FRONTEND_V3/apps/$app" && npm run build -- --outDir "$DESKTOP_DIST/$app" --base "./apps/$app/")
done
echo "Done: frontend-v3 apps built to $DESKTOP_DIST"
+18
View File
@@ -0,0 +1,18 @@
import { autoUpdater } from 'electron-updater';
import { BrowserWindow } from 'electron';
export function setupAutoUpdater(win: BrowserWindow): void {
autoUpdater.checkForUpdatesAndNotify();
autoUpdater.on('update-available', () => {
win.webContents.send('updater:update-available');
});
autoUpdater.on('update-downloaded', () => {
win.webContents.send('updater:update-downloaded');
});
autoUpdater.on('download-progress', (progress: { percent: number }) => {
win.webContents.send('updater:progress', progress.percent);
});
}
+52
View File
@@ -0,0 +1,52 @@
import { dialog, app, IpcMain, BrowserWindow, Notification } from 'electron';
import { writeFile } from 'fs/promises';
export function setupFileHandlers(ipcMain: IpcMain, win: BrowserWindow): void {
ipcMain.handle('file:open', async (_e, { filters } = {}) => {
const result = await dialog.showOpenDialog(win, {
properties: ['openFile'],
filters: filters ?? [
{ name: 'Zacus Scenarios', extensions: ['yaml', 'yml', 'zacus'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (result.canceled || result.filePaths.length === 0) return null;
const filePath = result.filePaths[0];
app.addRecentDocument(filePath);
return filePath;
});
ipcMain.handle('file:save', async (_e, { data, defaultPath }: { data: string; defaultPath?: string }) => {
const result = await dialog.showSaveDialog(win, {
defaultPath: defaultPath ?? 'scenario.yaml',
filters: [
{ name: 'Zacus Scenarios', extensions: ['yaml', 'yml'] },
{ name: 'Zacus Binary', extensions: ['zacus'] },
],
});
if (result.canceled || !result.filePath) return null;
await writeFile(result.filePath, data, 'utf-8');
app.addRecentDocument(result.filePath);
return result.filePath;
});
ipcMain.handle('file:recent', async () => {
// macOS manages recent documents via NSDocumentController
// Return empty array; the menu:open-recent event handles it natively
return [];
});
ipcMain.handle('file:add-recent', async (_e, filePath: string) => {
app.addRecentDocument(filePath);
});
ipcMain.handle('notify', async (_e, { title, body }: { title: string; body: string }) => {
if (Notification.isSupported()) {
new Notification({ title, body }).show();
}
});
}
+99
View File
@@ -0,0 +1,99 @@
import { app, BrowserWindow, ipcMain, shell, nativeTheme } from 'electron';
import { join } from 'path';
import { setupMenu } from './menu';
import { setupFileHandlers } from './file-handler';
import { setupAutoUpdater } from './auto-updater';
const isDev = process.env.NODE_ENV === 'development';
const RENDERER_URL = 'http://localhost:5173';
let mainWindow: BrowserWindow | null = null;
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1100,
minHeight: 700,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 16, y: 16 },
backgroundColor: '#1a1a2e',
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
webSecurity: !isDev,
},
});
// Load renderer
if (isDev) {
mainWindow.loadURL(RENDERER_URL);
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
mainWindow.once('ready-to-show', () => {
mainWindow!.show();
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.whenReady().then(() => {
// Force dark mode for consistent look
nativeTheme.themeSource = 'dark';
createWindow();
// Setup macOS menu
setupMenu(mainWindow!);
// Setup all IPC handlers
setupFileHandlers(ipcMain, mainWindow!);
// Setup auto-updater (production only)
if (!isDev) {
setupAutoUpdater(mainWindow!);
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// Handle open-file events (drag .zacus onto dock icon)
app.on('open-file', (event, filePath) => {
event.preventDefault();
if (mainWindow) {
mainWindow.webContents.send('file:opened', filePath);
}
});
// Security: prevent new window creation
app.on('web-contents-created', (_event, contents) => {
contents.on('will-navigate', (event, navigationUrl) => {
const parsedUrl = new URL(navigationUrl);
if (isDev && parsedUrl.origin === RENDERER_URL) return;
if (!isDev && navigationUrl.startsWith('file://')) return;
event.preventDefault();
});
});
+186
View File
@@ -0,0 +1,186 @@
import {
app,
BrowserWindow,
Menu,
MenuItemConstructorOptions,
shell,
} from 'electron';
export function setupMenu(win: BrowserWindow): void {
const template: MenuItemConstructorOptions[] = [
// App menu
{
label: app.name,
submenu: [
{ role: 'about', label: `About Zacus Studio` },
{ type: 'separator' },
{ label: 'Preferences\u2026', accelerator: 'Cmd+,', click: () => win.webContents.send('menu:preferences') },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
// File menu
{
label: 'File',
submenu: [
{
label: 'New Scenario',
accelerator: 'Cmd+N',
click: () => win.webContents.send('menu:new-scenario'),
},
{
label: 'Open\u2026',
accelerator: 'Cmd+O',
click: () => win.webContents.send('menu:open'),
},
{
label: 'Open Recent',
role: 'recentDocuments',
submenu: [
{ role: 'clearRecentDocuments' },
],
},
{ type: 'separator' },
{
label: 'Save',
accelerator: 'Cmd+S',
click: () => win.webContents.send('menu:save'),
},
{
label: 'Save As\u2026',
accelerator: 'Cmd+Shift+S',
click: () => win.webContents.send('menu:save-as'),
},
{ type: 'separator' },
{
label: 'Export to SD Card',
accelerator: 'Cmd+E',
click: () => win.webContents.send('menu:export-sd'),
},
{ type: 'separator' },
{ role: 'close' },
],
},
// Edit menu
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
// View menu
{
label: 'View',
submenu: [
{
label: 'Editor',
accelerator: 'Cmd+1',
click: () => win.webContents.send('menu:tab', 'editor'),
},
{
label: 'Game Master',
accelerator: 'Cmd+2',
click: () => win.webContents.send('menu:tab', 'dashboard'),
},
{
label: 'Simulation',
accelerator: 'Cmd+3',
click: () => win.webContents.send('menu:tab', 'simulation'),
},
{
label: 'Dev Tools',
accelerator: 'Cmd+4',
click: () => win.webContents.send('menu:tab', 'devtools'),
},
{ type: 'separator' },
{
label: 'Compile Scenario',
accelerator: 'Cmd+B',
click: () => win.webContents.send('menu:compile'),
},
{
label: 'Validate Scenario',
accelerator: 'Cmd+R',
click: () => win.webContents.send('menu:validate'),
},
{ type: 'separator' },
{ role: 'togglefullscreen' },
{ role: 'reload' },
{ role: 'toggleDevTools' },
],
},
// Dev menu
{
label: 'Dev',
submenu: [
{
label: 'Scan Devices',
accelerator: 'Cmd+Shift+D',
click: () => win.webContents.send('menu:scan-devices'),
},
{
label: 'Open Serial Monitor',
click: () => win.webContents.send('menu:serial-monitor'),
},
{
label: 'Firmware Manager',
click: () => win.webContents.send('menu:firmware-manager'),
},
{ type: 'separator' },
{
label: 'Show App Logs',
click: () => shell.openPath(app.getPath('logs')),
},
{
label: 'Show Firmware Cache',
click: () => {
const { homedir } = require('os');
const { join } = require('path');
shell.openPath(join(homedir(), '.zacus-studio', 'firmwares'));
},
},
],
},
// Window menu
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
{ type: 'separator' },
{ role: 'front' },
{ type: 'separator' },
{ role: 'window' },
],
},
// Help menu
{
role: 'help',
submenu: [
{
label: 'Zacus Documentation',
click: () => shell.openExternal('https://github.com/electron-rare/le-mystere-professeur-zacus'),
},
{
label: 'Report Issue',
click: () => shell.openExternal('https://github.com/electron-rare/le-mystere-professeur-zacus/issues'),
},
],
},
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
+414
View File
@@ -0,0 +1,414 @@
import { IpcMain, BrowserWindow } from 'electron';
import { createReadStream, statSync } from 'fs';
import { homedir } from 'os';
import { join, basename } from 'path';
import { mkdir, writeFile, readFile } from 'fs/promises';
import { existsSync } from 'fs';
import * as http from 'http';
import type { IncomingMessage } from 'http';
export interface DeviceVersion {
firmware: string;
version: string;
idf: string;
}
export interface DeviceStatus {
battery_pct: number;
uptime_s: number;
espnow_peers: number;
heap_free: number;
}
export interface OTAStatus {
state: 'idle' | 'downloading' | 'verifying' | 'rebooting';
progress: number;
}
type OTAMethod = 'wifi' | 'ble' | 'usb';
interface ActiveOTA {
deviceId: string;
method: OTAMethod;
startedAt: number;
abortController?: AbortController;
}
export class OTAManager {
private win: BrowserWindow;
private activeOTAs = new Map<string, ActiveOTA>();
private firmwareCacheDir: string;
private lastOTATime = new Map<string, number>();
private readonly OTA_RATE_LIMIT_MS = 60_000;
constructor(win: BrowserWindow) {
this.win = win;
this.firmwareCacheDir = join(homedir(), '.zacus-studio', 'firmwares');
this.ensureCacheDir();
}
private async ensureCacheDir(): Promise<void> {
await mkdir(this.firmwareCacheDir, { recursive: true });
}
setupHandlers(ipcMain: IpcMain): void {
ipcMain.handle('ota:check', async (_e, deviceId: string) => {
return this.checkUpdate(deviceId);
});
ipcMain.handle('ota:update', async (_e, {
deviceId,
method,
firmwarePath,
}: { deviceId: string; method: OTAMethod; firmwarePath: string }) => {
await this.startUpdate(deviceId, method, firmwarePath);
});
ipcMain.handle('ota:rollback', async (_e, deviceId: string) => {
return this.rollback(deviceId);
});
}
// ─── Version Check ────────────────────────────────────────────────────────
async checkUpdate(deviceId: string): Promise<{
current: string;
available: string;
needsUpdate: boolean;
}> {
try {
const deviceVersion = await this.getDeviceVersion(deviceId);
const availableVersion = await this.getAvailableVersion(deviceId);
return {
current: deviceVersion?.version ?? 'unknown',
available: availableVersion ?? 'unknown',
needsUpdate: Boolean(
deviceVersion && availableVersion &&
availableVersion !== deviceVersion.version
),
};
} catch {
return { current: 'unknown', available: 'unknown', needsUpdate: false };
}
}
private async getDeviceVersion(deviceId: string): Promise<DeviceVersion | null> {
// deviceId can be an IP or a serial port path
if (deviceId.includes('/dev/')) {
// USB: query via serial command
return this.queryVersionViaSerial(deviceId);
}
// WiFi: HTTP GET /version
const url = `http://${deviceId}/version`;
try {
const response = await this.httpGet(url, 5000);
return JSON.parse(response) as DeviceVersion;
} catch {
return null;
}
}
private async getAvailableVersion(deviceId: string): Promise<string | null> {
// Look in firmware cache for matching firmware
const manifestPath = join(this.firmwareCacheDir, 'manifest.json');
if (!existsSync(manifestPath)) return null;
try {
const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')) as Record<string, string>;
// Match by device name prefix (e.g., "p1_son" for puzzle 1)
const key = Object.keys(manifest).find(k => deviceId.toLowerCase().includes(k.split('_')[0]));
return key ? manifest[key] : null;
} catch {
return null;
}
}
// ─── OTA Update Orchestration ─────────────────────────────────────────────
async startUpdate(deviceId: string, method: OTAMethod, firmwarePath: string): Promise<void> {
// Rate limiting: prevent accidental double-flash
const lastOTA = this.lastOTATime.get(deviceId) ?? 0;
if (Date.now() - lastOTA < this.OTA_RATE_LIMIT_MS) {
throw new Error(`OTA rate limit: wait ${Math.ceil((this.OTA_RATE_LIMIT_MS - (Date.now() - lastOTA)) / 1000)}s`);
}
if (this.activeOTAs.has(deviceId)) {
throw new Error(`OTA already in progress for ${deviceId}`);
}
const abortController = new AbortController();
this.activeOTAs.set(deviceId, {
deviceId,
method,
startedAt: Date.now(),
abortController,
});
try {
this.lastOTATime.set(deviceId, Date.now());
switch (method) {
case 'wifi': await this.otaViaWiFi(deviceId, firmwarePath, abortController.signal); break;
case 'ble': await this.otaViaBLE(deviceId, firmwarePath); break;
case 'usb': await this.otaViaUSB(deviceId, firmwarePath); break;
default: throw new Error(`Unknown OTA method: ${method}`);
}
// Confirm update by polling /version until device reboots and comes back
await this.waitForReboot(deviceId);
const newVersion = await this.getDeviceVersion(deviceId);
this.sendEvent('ota:complete', {
deviceId,
success: true,
newVersion: newVersion?.version,
});
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
this.sendEvent('ota:complete', { deviceId, success: false, error });
throw err;
} finally {
this.activeOTAs.delete(deviceId);
}
}
// ─── WiFi OTA ─────────────────────────────────────────────────────────────
private async otaViaWiFi(
deviceId: string,
firmwarePath: string,
signal: AbortSignal
): Promise<void> {
const fileSize = statSync(firmwarePath).size;
const url = `http://${deviceId}/ota`;
this.sendProgress(deviceId, 0, 'uploading');
// Stream the firmware binary via chunked HTTP POST
await new Promise<void>((resolve, reject) => {
if (signal.aborted) { reject(new Error('Aborted')); return; }
const fileStream = createReadStream(firmwarePath);
let uploaded = 0;
const options = new URL(url);
const req = http.request(
{
hostname: options.hostname,
port: options.port || 80,
path: options.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': fileSize,
'X-Firmware-Name': basename(firmwarePath),
},
},
(res: IncomingMessage) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
if (res.statusCode === 200) resolve();
else reject(new Error(`OTA server returned ${res.statusCode}: ${Buffer.concat(chunks).toString()}`));
});
}
);
req.on('error', reject);
signal.addEventListener('abort', () => {
req.destroy();
reject(new Error('OTA cancelled'));
});
fileStream.on('data', (chunk: Buffer) => {
uploaded += chunk.length;
const pct = Math.round((uploaded / fileSize) * 100);
this.sendProgress(deviceId, pct, 'uploading');
});
fileStream.on('error', reject);
fileStream.pipe(req);
});
// Poll /ota/status for verification phase
await this.pollOTAStatus(deviceId);
}
private async pollOTAStatus(deviceId: string): Promise<void> {
const maxAttempts = 60;
let attempts = 0;
while (attempts < maxAttempts) {
await sleep(1000);
attempts++;
try {
const statusJson = await this.httpGet(`http://${deviceId}/ota/status`, 3000);
const status = JSON.parse(statusJson) as OTAStatus;
if (status.state === 'verifying') {
this.sendProgress(deviceId, status.progress, 'verifying');
} else if (status.state === 'rebooting') {
this.sendProgress(deviceId, 100, 'rebooting');
return; // Device will reboot, we're done
} else if (status.state === 'idle' && status.progress === 100) {
return; // Done
}
} catch {
// Device may have rebooted; treat connection failure as success
if (attempts > 5) return;
}
}
throw new Error('OTA status polling timed out');
}
// ─── BLE OTA ──────────────────────────────────────────────────────────────
private async otaViaBLE(deviceId: string, firmwarePath: string): Promise<void> {
// Delegate to ble-handler IPC
await this.win.webContents.executeJavaScript(
`window.zacus.ble.dfu(${JSON.stringify(deviceId)}, ${JSON.stringify(firmwarePath)})`
);
}
// ─── USB OTA ──────────────────────────────────────────────────────────────
private async otaViaUSB(deviceId: string, firmwarePath: string): Promise<void> {
// Delegate to serial-handler IPC
await this.win.webContents.executeJavaScript(
`window.zacus.serial.flash(${JSON.stringify(deviceId)}, ${JSON.stringify(firmwarePath)})`
);
}
// ─── Rollback ─────────────────────────────────────────────────────────────
async rollback(deviceId: string): Promise<boolean> {
try {
const response = await this.httpPost(`http://${deviceId}/ota/rollback`, '', 10000);
return response.status === 200;
} catch {
return false;
}
}
// ─── Reboot Detection ─────────────────────────────────────────────────────
private async waitForReboot(deviceId: string, timeoutMs = 60_000): Promise<void> {
if (deviceId.includes('/dev/')) return; // USB: no reboot detection
const start = Date.now();
let deviceGoneOnce = false;
while (Date.now() - start < timeoutMs) {
await sleep(2000);
try {
await this.httpGet(`http://${deviceId}/version`, 2000);
if (deviceGoneOnce) {
// Device came back — reboot complete
return;
}
} catch {
deviceGoneOnce = true;
this.sendProgress(deviceId, 100, 'rebooting');
}
}
if (!deviceGoneOnce) {
// Device never rebooted but responded throughout — might be fine
return;
}
throw new Error('Device did not come back online after reboot');
}
// ─── Firmware Cache ───────────────────────────────────────────────────────
async importFirmware(sourcePath: string): Promise<string> {
const name = basename(sourcePath);
// Basic ELF header validation for ESP32 binaries
const { readFile: rf } = await import('fs/promises');
const header = await rf(sourcePath);
// ESP32 app images start with 0xE9 (magic byte)
if (header[0] !== 0xE9) {
throw new Error('Invalid ESP32 firmware: bad magic byte');
}
const destPath = join(this.firmwareCacheDir, name);
const content = await rf(sourcePath);
await writeFile(destPath, content);
return destPath;
}
// ─── Serial Version Query ─────────────────────────────────────────────────
private async queryVersionViaSerial(_port: string): Promise<DeviceVersion | null> {
// Send version query command and wait for JSON response
// This assumes the firmware has a CLI that responds to "version\n"
// Implemented via serial IPC — simplified here
return null;
}
// ─── HTTP Helpers ─────────────────────────────────────────────────────────
private httpGet(url: string, timeoutMs: number): Promise<string> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timeout')), timeoutMs);
http.get(url, (res) => {
clearTimeout(timer);
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
res.on('error', reject);
}).on('error', (e) => {
clearTimeout(timer);
reject(e);
});
});
}
private async httpPost(url: string, body: string, _timeoutMs: number): Promise<{ status: number; data: string }> {
const options = new URL(url);
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: options.hostname,
port: options.port || 80,
path: options.pathname,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => resolve({
status: res.statusCode ?? 0,
data: Buffer.concat(chunks).toString('utf-8'),
}));
}
);
req.on('error', reject);
req.write(body);
req.end();
});
}
// ─── Event Helpers ────────────────────────────────────────────────────────
private sendProgress(deviceId: string, percent: number, stage: string): void {
this.win.webContents.send('ota:progress', { deviceId, percent, stage });
}
private sendEvent(channel: string, data: unknown): void {
this.win.webContents.send(channel, data);
}
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
+139
View File
@@ -0,0 +1,139 @@
import { contextBridge, ipcRenderer } from 'electron';
// Type definitions
export interface SerialPort {
path: string;
name: string;
vendorId?: string;
productId?: string;
}
export interface ZacusDevice {
id: string;
name: string;
type: 'puzzle' | 'hub';
firmwareVersion: string;
batteryPct: number;
connectionType: 'usb' | 'ble' | 'wifi';
lastSeen: number;
ip?: string;
mac?: string;
}
export interface OTAProgressEvent {
deviceId: string;
percent: number;
stage: 'uploading' | 'verifying' | 'rebooting';
}
// Expose safe API to renderer via contextBridge
contextBridge.exposeInMainWorld('zacus', {
// === Serial ===
serial: {
list: () => ipcRenderer.invoke('serial:list') as Promise<SerialPort[]>,
connect: (port: string, baud: number) =>
ipcRenderer.invoke('serial:connect', { port, baud }) as Promise<boolean>,
disconnect: (port: string) =>
ipcRenderer.invoke('serial:disconnect', { port }) as Promise<void>,
write: (port: string, data: string) =>
ipcRenderer.invoke('serial:write', { port, data }) as Promise<void>,
flash: (port: string, firmwarePath: string) =>
ipcRenderer.invoke('serial:flash', { port, firmwarePath }) as Promise<void>,
onData: (callback: (port: string, data: string) => void) => {
ipcRenderer.on('serial:data', (_e, { port, data }) => callback(port, data));
},
onPlugged: (callback: (port: string) => void) => {
ipcRenderer.on('serial:plugged', (_e, port) => callback(port));
},
onUnplugged: (callback: (port: string) => void) => {
ipcRenderer.on('serial:unplugged', (_e, port) => callback(port));
},
},
// === BLE ===
ble: {
scan: () => ipcRenderer.invoke('ble:scan') as Promise<void>,
stopScan: () => ipcRenderer.invoke('ble:stop-scan') as Promise<void>,
connect: (deviceId: string) =>
ipcRenderer.invoke('ble:connect', deviceId) as Promise<boolean>,
disconnect: (deviceId: string) =>
ipcRenderer.invoke('ble:disconnect', deviceId) as Promise<void>,
write: (deviceId: string, characteristic: string, data: string) =>
ipcRenderer.invoke('ble:write', { deviceId, characteristic, data }) as Promise<void>,
dfu: (deviceId: string, firmwarePath: string) =>
ipcRenderer.invoke('ble:dfu', { deviceId, firmwarePath }) as Promise<void>,
onDiscovered: (callback: (device: ZacusDevice) => void) => {
ipcRenderer.on('ble:discovered', (_e, device) => callback(device));
},
onData: (callback: (deviceId: string, characteristic: string, data: string) => void) => {
ipcRenderer.on('ble:data', (_e, { deviceId, characteristic, data }) =>
callback(deviceId, characteristic, data));
},
},
// === WiFi ===
wifi: {
discover: () =>
ipcRenderer.invoke('wifi:discover') as Promise<ZacusDevice[]>,
wsConnect: (url: string) =>
ipcRenderer.invoke('wifi:ws-connect', url) as Promise<boolean>,
wsSend: (data: string) =>
ipcRenderer.invoke('wifi:ws-send', data) as Promise<void>,
wsDisconnect: () =>
ipcRenderer.invoke('wifi:ws-disconnect') as Promise<void>,
http: (url: string, method: string, body?: string, headers?: Record<string, string>) =>
ipcRenderer.invoke('wifi:http', { url, method, body, headers }) as Promise<{
status: number;
data: string;
}>,
onWsMessage: (callback: (data: string) => void) => {
ipcRenderer.on('wifi:ws-message', (_e, data) => callback(data));
},
onServiceFound: (callback: (service: ZacusDevice) => void) => {
ipcRenderer.on('wifi:service-found', (_e, service) => callback(service));
},
},
// === OTA ===
ota: {
check: (deviceId: string) =>
ipcRenderer.invoke('ota:check', deviceId) as Promise<{
current: string;
available: string;
needsUpdate: boolean;
}>,
update: (deviceId: string, method: 'wifi' | 'ble' | 'usb', firmwarePath: string) =>
ipcRenderer.invoke('ota:update', { deviceId, method, firmwarePath }) as Promise<void>,
rollback: (deviceId: string) =>
ipcRenderer.invoke('ota:rollback', deviceId) as Promise<boolean>,
onProgress: (callback: (event: OTAProgressEvent) => void) => {
ipcRenderer.on('ota:progress', (_e, event) => callback(event));
},
onComplete: (callback: (deviceId: string, success: boolean, error?: string) => void) => {
ipcRenderer.on('ota:complete', (_e, { deviceId, success, error }) =>
callback(deviceId, success, error));
},
},
// === Files ===
file: {
open: (filters?: Array<{ name: string; extensions: string[] }>) =>
ipcRenderer.invoke('file:open', { filters }) as Promise<string | null>,
save: (data: string, defaultPath?: string) =>
ipcRenderer.invoke('file:save', { data, defaultPath }) as Promise<string | null>,
recent: () => ipcRenderer.invoke('file:recent') as Promise<string[]>,
addRecent: (filePath: string) =>
ipcRenderer.invoke('file:add-recent', filePath) as Promise<void>,
},
// === Menu events ===
menu: {
on: (event: string, callback: (...args: unknown[]) => void) => {
ipcRenderer.on(`menu:${event}`, (_e, ...args) => callback(...args));
},
},
// === Notifications ===
notify: (title: string, body: string) =>
ipcRenderer.invoke('notify', { title, body }) as Promise<void>,
});
+88
View File
@@ -0,0 +1,88 @@
import React, { useState, useEffect, useCallback } from 'react';
import { EditorTab } from './tabs/EditorTab';
import { DashboardTab } from './tabs/DashboardTab';
import { SimulationTab } from './tabs/SimulationTab';
import { DevToolsTab } from './tabs/DevToolsTab';
import './styles/App.css';
type Tab = 'editor' | 'dashboard' | 'simulation' | 'devtools';
interface TabConfig {
id: Tab;
label: string;
icon: string;
shortcut: string;
}
const TABS: TabConfig[] = [
{ id: 'editor', label: 'Editor', icon: '\u2B21', shortcut: '\u2318 1' },
{ id: 'dashboard', label: 'Game Master', icon: '\u25CE', shortcut: '\u2318 2' },
{ id: 'simulation', label: 'Simulation', icon: '\u25B3', shortcut: '\u2318 3' },
{ id: 'devtools', label: 'Dev Tools', icon: '\u2699', shortcut: '\u2318 4' },
];
export default function App(): React.JSX.Element {
const [activeTab, setActiveTab] = useState<Tab>('editor');
const [deviceStatus, setDeviceStatus] = useState<'connected' | 'partial' | 'disconnected'>('disconnected');
// Handle menu events from main process
useEffect(() => {
window.zacus.menu.on('tab', (tab: unknown) => {
if (typeof tab === 'string' && TABS.some(t => t.id === tab)) {
setActiveTab(tab as Tab);
}
});
}, []);
const handleTabClick = useCallback((tab: Tab) => {
setActiveTab(tab);
}, []);
const statusColor = {
connected: 'var(--success)',
partial: 'var(--warning)',
disconnected: 'var(--text-dim)',
}[deviceStatus];
return (
<div className="app-layout">
{/* Sidebar */}
<aside className="sidebar">
<div className="sidebar-top titlebar-drag" />
<nav className="sidebar-nav">
{TABS.map(tab => (
<button
key={tab.id}
className={`sidebar-btn ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => handleTabClick(tab.id)}
title={`${tab.label} (${tab.shortcut})`}
>
<span className="sidebar-icon">{tab.icon}</span>
<span className="sidebar-label">{tab.label}</span>
</button>
))}
</nav>
<div className="sidebar-bottom">
<div
className="device-status-dot"
style={{ backgroundColor: statusColor }}
title={`Devices: ${deviceStatus}`}
/>
</div>
</aside>
{/* Main content */}
<main className="main-content">
<div className="titlebar-drag" />
<div className="tab-content">
{activeTab === 'editor' && <EditorTab />}
{activeTab === 'dashboard' && <DashboardTab onDeviceStatusChange={setDeviceStatus} />}
{activeTab === 'simulation' && <SimulationTab />}
{activeTab === 'devtools' && <DevToolsTab />}
</div>
</main>
</div>
);
}
@@ -0,0 +1,126 @@
import React, { useState, useEffect } from 'react';
interface BatteryReading {
deviceId: string;
name: string;
pct: number;
pack: string;
remainingHours: number;
}
interface BatteryStatusMessage {
type: string;
device: string;
battery_pct: number;
remaining_hours?: number;
}
function BatteryBar({ pct }: { pct: number }): React.JSX.Element {
const color =
pct >= 50 ? 'var(--success, #22c55e)'
: pct >= 20 ? 'var(--warning, #f59e0b)'
: 'var(--error, #ef4444)';
return (
<div className="battery-bar-wrapper">
<div
className="battery-bar"
style={{ width: `${pct}%`, backgroundColor: color }}
/>
</div>
);
}
const INITIAL_READINGS: BatteryReading[] = [
{ deviceId: 'box3', name: 'BOX-3', pct: 82, pack: 'Anker #1', remainingHours: 4.0 },
{ deviceId: 'p1_son', name: 'P1 Son', pct: 61, pack: 'Pack A', remainingHours: 3.1 },
{ deviceId: 'p5_mor', name: 'P5 Morse', pct: 94, pack: 'Pack A', remainingHours: 5.2 },
{ deviceId: 'p6_nfc', name: 'P6 NFC', pct: 78, pack: 'Pack B', remainingHours: 4.0 },
{ deviceId: 'p7_cof', name: 'P7 Coffre', pct: 31, pack: 'Pack B', remainingHours: 1.5 },
];
export function BatteryDashboard(): React.JSX.Element {
const [readings, setReadings] = useState<BatteryReading[]>(INITIAL_READINGS);
// Listen for live battery status updates via WebSocket
useEffect(() => {
window.zacus.wifi.onWsMessage(rawData => {
try {
const msg = JSON.parse(rawData) as BatteryStatusMessage;
if (msg.type === 'battery_status') {
setReadings(prev => prev.map(r =>
r.deviceId === msg.device
? {
...r,
pct: msg.battery_pct,
remainingHours: msg.remaining_hours ?? msg.battery_pct / 20,
}
: r
));
}
} catch { /* not battery data */ }
});
}, []);
const lowest = readings.reduce(
(min, r) => r.pct < min.pct ? r : min,
readings[0]
);
const criticalDevices = readings.filter(r => r.pct < 20);
const warningDevices = readings.filter(r => r.pct >= 20 && r.pct < 25);
return (
<div className="battery-dashboard">
<div className="panel-header">
<h2>Battery Dashboard</h2>
<span className="text-dim">{readings.length} devices monitored</span>
</div>
{criticalDevices.length > 0 && (
<div className="alert error">
Critical: {criticalDevices.map(d => `${d.name} (${d.pct}%)`).join(', ')} replace immediately
</div>
)}
{warningDevices.length > 0 && criticalDevices.length === 0 && (
<div className="alert warning">
Low: {warningDevices.map(d => `${d.name} (${d.pct}%)`).join(', ')} replace soon
</div>
)}
{lowest && lowest.pct < 25 && criticalDevices.length === 0 && warningDevices.length === 0 && (
<div className="alert warning">
{lowest.name}: {lowest.pct}% lowest battery
</div>
)}
<div className="battery-list">
{readings.map(r => (
<div key={r.deviceId} className="battery-row">
<div className="battery-name">{r.name}</div>
<div className="battery-bar-container">
<BatteryBar pct={r.pct} />
<span className={`battery-pct ${r.pct < 20 ? 'error' : r.pct < 50 ? 'warn' : ''}`}>
{r.pct}%
</span>
</div>
<div className="battery-meta">
<span className="text-dim">{r.pack}</span>
<span className={r.remainingHours < 2 ? 'warn' : 'text-dim'}>
~{r.remainingHours.toFixed(1)}h remaining
</span>
{r.pct < 20 && <span className="badge error"> CRITICAL</span>}
{r.pct >= 20 && r.pct < 25 && <span className="badge warning"> LOW</span>}
</div>
</div>
))}
</div>
<div className="battery-summary">
<span className="text-dim">
Avg: {Math.round(readings.reduce((s, r) => s + r.pct, 0) / readings.length)}% |{' '}
Min: {lowest?.pct}% ({lowest?.name}) |{' '}
Est. runtime: ~{Math.min(...readings.map(r => r.remainingHours)).toFixed(1)}h
</span>
</div>
</div>
);
}
@@ -0,0 +1,149 @@
import React, { useState, useEffect, useCallback } from 'react';
const CONNECTION_ICONS: Record<ZacusDevice['connectionType'], string> = {
usb: '🔌',
ble: '📶',
wifi: '📡',
};
export function DeviceManager(): React.JSX.Element {
const [devices, setDevices] = useState<ZacusDevice[]>([]);
const [scanning, setScanning] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
// Discover via all transports in parallel
const scan = useCallback(async () => {
setScanning(true);
try {
const [wifiDevices, serialPorts] = await Promise.all([
window.zacus.wifi.discover(),
window.zacus.serial.list(),
]);
const serialDevices: ZacusDevice[] = serialPorts.map(p => ({
id: p.path,
name: p.name ?? p.path.split('/').pop() ?? 'Unknown',
type: 'puzzle' as const,
firmwareVersion: '?',
batteryPct: -1,
connectionType: 'usb' as const,
lastSeen: Date.now(),
}));
setDevices([...wifiDevices, ...serialDevices]);
await window.zacus.ble.scan();
} finally {
setScanning(false);
}
}, []);
// BLE discovered — merge into device list
useEffect(() => {
window.zacus.ble.onDiscovered(device => {
setDevices(prev => {
const existing = prev.findIndex(d => d.id === device.id);
if (existing >= 0) {
const copy = [...prev];
copy[existing] = { ...copy[existing], ...device, connectionType: 'ble' };
return copy;
}
return [...prev, { ...device, connectionType: 'ble' as const }];
});
});
}, []);
// WiFi mDNS discovered — merge into device list
useEffect(() => {
window.zacus.wifi.onServiceFound(service => {
setDevices(prev => {
const existing = prev.findIndex(d => d.id === service.id);
if (existing >= 0) {
const copy = [...prev];
copy[existing] = { ...copy[existing], ...service };
return copy;
}
return [...prev, service];
});
});
}, []);
useEffect(() => { scan(); }, [scan]);
const handleConnect = useCallback(async (device: ZacusDevice) => {
setSelectedId(device.id);
if (device.connectionType === 'wifi' && device.ip) {
await window.zacus.wifi.wsConnect(`ws://${device.ip}/ws`);
} else if (device.connectionType === 'ble') {
await window.zacus.ble.connect(device.id);
} else if (device.connectionType === 'usb') {
await window.zacus.serial.connect(device.id, 115200);
}
}, []);
return (
<div className="device-manager">
<div className="panel-header">
<h2>Devices</h2>
<button className="btn primary" onClick={scan} disabled={scanning}>
{scanning ? 'Scanning…' : '↺ Scan'}
</button>
</div>
<div className="device-grid">
{devices.length === 0 && (
<div className="empty-state">
No devices found. Connect USB or enable WiFi.
</div>
)}
{devices.map(device => (
<div
key={device.id}
className={`device-card ${selectedId === device.id ? 'selected' : ''}`}
onClick={() => setSelectedId(device.id)}
>
<div className="device-header">
<span className="device-icon">{CONNECTION_ICONS[device.connectionType]}</span>
<span className="device-name">{device.name}</span>
<span className="device-type badge">{device.type}</span>
</div>
<div className="device-details">
<span className="text-dim">fw v{device.firmwareVersion}</span>
{device.batteryPct >= 0 && (
<span className={device.batteryPct < 20 ? 'warn' : 'text-dim'}>
🔋 {device.batteryPct}%
</span>
)}
{device.ip && (
<span className="text-dim">📡 {device.ip}</span>
)}
{device.mac && (
<span className="text-dim mono">{device.mac}</span>
)}
</div>
<div className="device-meta">
<span className="text-dim">
Last seen {new Date(device.lastSeen).toLocaleTimeString()}
</span>
</div>
<div className="device-actions">
<button
className="btn small primary"
onClick={e => { e.stopPropagation(); handleConnect(device); }}
>
Connect
</button>
{device.connectionType === 'ble' && (
<button
className="btn small"
onClick={e => { e.stopPropagation(); window.zacus.ble.disconnect(device.id); }}
>
Disconnect
</button>
)}
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,224 @@
import React, { useState, useEffect, useCallback } from 'react';
interface FirmwareEntry {
name: string;
version: string;
path: string;
size: number;
builtAt: number;
}
interface DeviceRow {
device: ZacusDevice;
currentVersion: string;
availableVersion: string;
needsUpdate: boolean;
status: 'idle' | 'checking' | 'updating' | 'done' | 'error';
progress: number;
progressStage: string;
error?: string;
}
type OTAMethod = 'wifi' | 'ble' | 'usb';
export function FirmwareManager(): React.JSX.Element {
const [devices, setDevices] = useState<DeviceRow[]>([]);
const [firmwares, setFirmwares] = useState<FirmwareEntry[]>([]);
const [selectedMethod, setSelectedMethod] = useState<OTAMethod>('wifi');
const [building, setBuilding] = useState(false);
// Subscribe to OTA progress/complete events
useEffect(() => {
window.zacus.ota.onProgress(({ deviceId, percent, stage }) => {
setDevices(prev => prev.map(row =>
row.device.id === deviceId
? { ...row, status: 'updating', progress: percent, progressStage: stage }
: row
));
});
window.zacus.ota.onComplete((deviceId, success, error) => {
setDevices(prev => prev.map(row =>
row.device.id === deviceId
? { ...row, status: success ? 'done' : 'error', progress: 100, error }
: row
));
});
}, []);
// Check all devices for updates
const checkAll = useCallback(async () => {
const checked = await Promise.all(
devices.map(async row => {
setDevices(prev => prev.map(r =>
r.device.id === row.device.id ? { ...r, status: 'checking' } : r
));
const info = await window.zacus.ota.check(row.device.id);
return {
...row,
currentVersion: info.current,
availableVersion: info.available,
needsUpdate: info.needsUpdate,
status: 'idle' as const,
};
})
);
setDevices(checked);
}, [devices]);
const updateDevice = useCallback(async (row: DeviceRow) => {
const fw = firmwares.find(f => f.name.startsWith(row.device.name.toLowerCase().split(' ')[0]));
if (!fw) {
alert(`No firmware found for ${row.device.name}. Import a .bin first.`);
return;
}
await window.zacus.ota.update(row.device.id, selectedMethod, fw.path);
}, [firmwares, selectedMethod]);
const updateAll = useCallback(async () => {
const pending = devices.filter(r => r.needsUpdate && r.status === 'idle');
await Promise.allSettled(pending.map(row => updateDevice(row)));
}, [devices, updateDevice]);
const importFirmware = useCallback(async () => {
const path = await window.zacus.file.open([
{ name: 'ESP32 Firmware', extensions: ['bin'] },
]);
if (!path) return;
// Firmware import handled by main process via file:open
// Display it in the cache list
const name = path.split('/').pop() ?? 'firmware.bin';
setFirmwares(prev => [...prev, {
name,
version: 'imported',
path,
size: 0,
builtAt: Date.now(),
}]);
}, []);
const rollbackDevice = useCallback(async (row: DeviceRow) => {
if (!confirm(`Rollback ${row.device.name} to previous firmware?`)) return;
const ok = await window.zacus.ota.rollback(row.device.id);
if (!ok) alert('Rollback failed');
}, []);
const buildFromSource = useCallback(async () => {
setBuilding(true);
try {
await window.zacus.build.run();
} finally {
setBuilding(false);
}
}, []);
return (
<div className="firmware-manager">
<div className="panel-header">
<h2>Firmware Manager</h2>
<div className="toolbar-group">
<select
className="select"
value={selectedMethod}
onChange={e => setSelectedMethod(e.target.value as OTAMethod)}
>
<option value="wifi">WiFi OTA (~30s/MB)</option>
<option value="ble">BLE DFU (~2min/MB)</option>
<option value="usb">USB Serial (~15s/MB)</option>
</select>
<button className="btn" onClick={checkAll}>Check Updates</button>
<button className="btn primary" onClick={updateAll}>Update All</button>
<button className="btn" onClick={importFirmware}>Import .bin</button>
<button className="btn" onClick={buildFromSource} disabled={building}>
{building ? 'Building...' : 'Build from Source'}
</button>
</div>
</div>
{/* Update Matrix */}
<table className="update-matrix">
<thead>
<tr>
<th>Device</th>
<th>Connection</th>
<th>Current</th>
<th>Available</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{devices.map(row => (
<tr key={row.device.id} className={row.needsUpdate ? 'needs-update' : ''}>
<td>{row.device.name}</td>
<td>
<span className="badge">{row.device.connectionType.toUpperCase()}</span>
</td>
<td className="mono">{row.currentVersion}</td>
<td className="mono">
{row.availableVersion}
{row.needsUpdate && <span className="badge warning">UPDATE</span>}
</td>
<td>
{row.status === 'updating' ? (
<div className="progress-container">
<div
className="progress-bar"
style={{ width: `${row.progress}%` }}
/>
<span className="progress-text">
{row.progressStage} {row.progress}%
</span>
</div>
) : (
<span className={`status-badge ${row.status}`}>{row.status}</span>
)}
{row.error && <div className="error-text">{row.error}</div>}
</td>
<td>
<button
className="btn small primary"
onClick={() => updateDevice(row)}
disabled={row.status === 'updating' || !row.needsUpdate}
>
Update
</button>
<button
className="btn small"
onClick={() => rollbackDevice(row)}
disabled={row.status === 'updating'}
>
Rollback
</button>
</td>
</tr>
))}
{devices.length === 0 && (
<tr>
<td colSpan={6} className="empty-state">
No devices connected. Scan for devices in the Dashboard tab.
</td>
</tr>
)}
</tbody>
</table>
{/* Firmware Cache */}
<div className="firmware-cache">
<h3>Firmware Cache</h3>
{firmwares.length === 0 ? (
<div className="empty-state">No firmwares cached. Build from source or import a .bin.</div>
) : (
firmwares.map(fw => (
<div key={fw.path} className="firmware-entry">
<span className="fw-name">{fw.name}</span>
<span className="fw-version mono">v{fw.version}</span>
<span className="fw-size">{(fw.size / 1024).toFixed(0)} KB</span>
<span className="fw-date">{new Date(fw.builtAt).toLocaleDateString()}</span>
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,219 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
interface LogEvent {
timestamp: number;
source: 'serial' | 'ws' | 'npc' | 'puzzle' | 'system';
device?: string;
type: string;
payload: unknown;
}
const SOURCE_COLORS: Record<LogEvent['source'], string> = {
serial: '#60a5fa',
ws: '#34d399',
npc: '#a78bfa',
puzzle: '#f59e0b',
system: '#94a3b8',
};
export function LogRecorder(): React.JSX.Element {
const [recording, setRecording] = useState(false);
const [events, setEvents] = useState<LogEvent[]>([]);
const [filterSource, setFilterSource] = useState<'all' | LogEvent['source']>('all');
const sessionRef = useRef<LogEvent[]>([]);
const logEndRef = useRef<HTMLDivElement>(null);
const record = useCallback((event: LogEvent) => {
sessionRef.current.push(event);
setEvents(prev => [...prev, event]);
}, []);
useEffect(() => {
if (!recording) return;
window.zacus.serial.onData((port, data) => {
record({
timestamp: Date.now(),
source: 'serial',
device: port,
type: 'serial_data',
payload: data,
});
});
window.zacus.wifi.onWsMessage(data => {
try {
record({
timestamp: Date.now(),
source: 'ws',
type: 'ws_message',
payload: JSON.parse(data),
});
} catch {
record({
timestamp: Date.now(),
source: 'ws',
type: 'ws_raw',
payload: data,
});
}
});
}, [recording, record]);
// Auto-scroll to bottom on new events
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [events]);
const toggleRecording = useCallback(() => {
if (!recording) {
// Starting a new recording — clear previous session
sessionRef.current = [];
setEvents([]);
}
setRecording(r => !r);
}, [recording]);
const exportJSON = useCallback(async () => {
if (events.length === 0) return;
const json = JSON.stringify(
{
session: {
startedAt: events[0]?.timestamp,
endedAt: events[events.length - 1]?.timestamp,
eventCount: events.length,
events,
},
},
null,
2
);
await window.zacus.file.save(json, `zacus_session_${Date.now()}.json`);
}, [events]);
const exportMarkdown = useCallback(async () => {
if (events.length === 0) return;
const lines = [
'# Zacus Session Log',
`Generated: ${new Date().toISOString()}`,
`Events: ${events.length}`,
'',
'## Timeline',
'',
...events.map(e => {
const time = new Date(e.timestamp).toISOString().slice(11, 23);
const dev = e.device ? ` (${e.device.split('/').pop()})` : '';
const pl = JSON.stringify(e.payload).slice(0, 80);
return `- **${time}** [${e.source}${dev}] \`${e.type}\`: \`${pl}\``;
}),
];
await window.zacus.file.save(lines.join('\n'), `zacus_session_${Date.now()}.md`);
}, [events]);
const visibleEvents = filterSource === 'all'
? events
: events.filter(e => e.source === filterSource);
const sources = ['serial', 'ws', 'npc', 'puzzle', 'system'] as const;
return (
<div className="log-recorder">
<div className="panel-header">
<h2>Log Recorder</h2>
<div className="toolbar-group">
<button
className={`btn ${recording ? 'error' : 'primary'}`}
onClick={toggleRecording}
>
{recording ? '⏹ Stop' : '● Record'}
</button>
<button
className="btn"
onClick={exportJSON}
disabled={events.length === 0}
>
Export JSON
</button>
<button
className="btn"
onClick={exportMarkdown}
disabled={events.length === 0}
>
Export Markdown
</button>
<button
className="btn"
onClick={() => { setEvents([]); sessionRef.current = []; }}
disabled={events.length === 0}
>
Clear
</button>
</div>
</div>
{recording && (
<div className="recording-indicator">
<span className="rec-dot" /> Recording {events.length} events captured
</div>
)}
<div className="source-filter">
<button
className={`btn small ${filterSource === 'all' ? 'active' : ''}`}
onClick={() => setFilterSource('all')}
>
All ({events.length})
</button>
{sources.map(src => {
const count = events.filter(e => e.source === src).length;
return (
<button
key={src}
className={`btn small ${filterSource === src ? 'active' : ''}`}
style={{ color: SOURCE_COLORS[src] }}
onClick={() => setFilterSource(src)}
>
{src} ({count})
</button>
);
})}
</div>
<div className="event-log">
{visibleEvents.slice(-200).map((e, i) => (
<div
key={i}
className={`event-row event-${e.source}`}
style={{ borderLeftColor: SOURCE_COLORS[e.source] }}
>
<span className="event-time">
{new Date(e.timestamp).toISOString().slice(11, 23)}
</span>
<span
className="event-source"
style={{ color: SOURCE_COLORS[e.source] }}
>
[{e.source}]
</span>
{e.device && (
<span className="event-device">
{e.device.split('/').pop()}
</span>
)}
<span className="event-type">{e.type}</span>
<span className="event-payload">
{JSON.stringify(e.payload).slice(0, 120)}
</span>
</div>
))}
<div ref={logEndRef} />
</div>
{events.length === 0 && !recording && (
<div className="empty-state">
Press Record to start capturing serial and WebSocket events.
</div>
)}
</div>
);
}
@@ -0,0 +1,207 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
interface MeshNode {
id: string;
name: string;
rssi: number;
latencyMs: number;
packetLoss: number;
lastSeen: number;
online: boolean;
}
interface MeshTopologyMessage {
type: string;
nodes: Array<{
name: string;
rssi: number;
latency_ms: number;
packet_loss: number;
}>;
}
const PUZZLE_NODES = ['BOX-3', 'P1 Son', 'P2', 'P4 Radio', 'P5 Morse', 'P6 NFC', 'P7 Coffre'];
function rssiColor(rssi: number): string {
if (rssi > -60) return '#22c55e'; // strong — green
if (rssi > -75) return '#f59e0b'; // medium — amber
return '#ef4444'; // weak — red
}
export function MeshDiagnostic(): React.JSX.Element {
const [nodes, setNodes] = useState<MeshNode[]>(
PUZZLE_NODES.map((name, i) => ({
id: `node-${i}`,
name,
rssi: 0,
latencyMs: 0,
packetLoss: 0,
lastSeen: 0,
online: false,
}))
);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Listen for mesh topology messages from BOX-3 WebSocket
useEffect(() => {
window.zacus.wifi.onWsMessage(rawData => {
try {
const msg = JSON.parse(rawData) as MeshTopologyMessage;
if (msg.type === 'mesh_topology') {
setNodes(prev => prev.map(node => {
const update = msg.nodes.find(n => n.name === node.name);
if (!update) return node;
return {
...node,
rssi: update.rssi,
latencyMs: update.latency_ms,
packetLoss: update.packet_loss,
lastSeen: Date.now(),
online: true,
};
}));
}
} catch { /* not a JSON message */ }
});
}, []);
// Draw mesh topology on canvas
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const W = canvas.width;
const H = canvas.height;
ctx.clearRect(0, 0, W, H);
// BOX-3 at center top
const masterX = W / 2;
const masterY = 60;
// Puzzle nodes in a row below (skip index 0 = BOX-3)
const puzzleNodes = nodes.slice(1);
const spacing = W / (puzzleNodes.length + 1);
ctx.font = '11px monospace';
ctx.textAlign = 'center';
puzzleNodes.forEach((node, i) => {
const x = spacing * (i + 1);
const y = H - 70;
// Connection line
ctx.beginPath();
ctx.moveTo(masterX, masterY + 24);
ctx.lineTo(x, y - 18);
ctx.strokeStyle = node.online ? rssiColor(node.rssi) : '#333';
ctx.lineWidth = node.online ? 2 : 1;
ctx.stroke();
// RSSI label at line midpoint
if (node.online) {
ctx.fillStyle = '#94a3b8';
ctx.fillText(
`${node.rssi}dB`,
(masterX + x) / 2,
(masterY + 24 + y - 18) / 2 - 6
);
}
// Node circle
ctx.beginPath();
ctx.arc(x, y, 18, 0, Math.PI * 2);
ctx.fillStyle = node.online ? rssiColor(node.rssi) : '#374151';
ctx.fill();
ctx.strokeStyle = '#1f2937';
ctx.lineWidth = 2;
ctx.stroke();
// Node name label
ctx.fillStyle = '#e2e8f0';
ctx.font = '10px monospace';
ctx.fillText(node.name.slice(0, 6), x, y + 34);
if (node.online) {
ctx.fillStyle = '#94a3b8';
ctx.fillText(`${node.latencyMs}ms`, x, y + 46);
}
});
// BOX-3 master node
ctx.beginPath();
ctx.arc(masterX, masterY, 24, 0, Math.PI * 2);
ctx.fillStyle = '#7c3aed';
ctx.fill();
ctx.strokeStyle = '#4c1d95';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = '#e2e8f0';
ctx.font = 'bold 11px monospace';
ctx.fillText('BOX-3', masterX, masterY + 4);
}, [nodes]);
useEffect(() => { draw(); }, [draw]);
const onlineCount = nodes.filter(n => n.online).length;
return (
<div className="mesh-diagnostic">
<div className="panel-header">
<h2>Mesh Diagnostic</h2>
<span className="text-dim">
ESP-NOW via BOX-3 WebSocket {onlineCount}/{nodes.length} online
</span>
</div>
<canvas
ref={canvasRef}
width={700}
height={280}
className="mesh-canvas"
/>
<table className="mesh-table">
<thead>
<tr>
<th>Device</th>
<th>Status</th>
<th>RSSI</th>
<th>Latency</th>
<th>Loss %</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{nodes.map(node => (
<tr key={node.id}>
<td>{node.name}</td>
<td>
<span className={`status-dot ${node.online ? 'online' : 'offline'}`} />
{node.online ? 'Online' : 'Offline'}
</td>
<td
className="mono"
style={{ color: node.online ? rssiColor(node.rssi) : undefined }}
>
{node.online ? `${node.rssi} dBm` : '—'}
</td>
<td className="mono">
{node.online ? `${node.latencyMs} ms` : '—'}
</td>
<td className={node.online && node.packetLoss > 5 ? 'warn mono' : 'mono'}>
{node.online ? `${node.packetLoss.toFixed(1)}%` : '—'}
</td>
<td className="text-dim">
{node.lastSeen
? new Date(node.lastSeen).toLocaleTimeString()
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,158 @@
import React, { useState, useCallback } from 'react';
interface NVSSetting {
key: string;
type: 'string' | 'uint8' | 'float' | 'blob';
value: string;
puzzle: string;
description: string;
}
const DEFAULT_SETTINGS: NVSSetting[] = [
{ key: 'wifi_ssid', type: 'string', value: 'ZacusNet', puzzle: 'all', description: 'WiFi network SSID' },
{ key: 'wifi_pass', type: 'string', value: '', puzzle: 'all', description: 'WiFi password' },
{ key: 'gm_ip', type: 'string', value: '192.168.4.1', puzzle: 'all', description: 'Game Master IP address' },
{ key: 'code_digits', type: 'blob', value: '1 4', puzzle: 'all', description: 'Code digits (space-separated)' },
{ key: 'morse_msg', type: 'string', value: 'ZACUS', puzzle: 'p5', description: 'Morse code message' },
{ key: 'rf_freq', type: 'float', value: '107.5', puzzle: 'p4', description: 'Target FM frequency (MHz)' },
{ key: 'nfc_uid_0', type: 'string', value: '', puzzle: 'p6', description: 'NFC UID #1 (hex)' },
{ key: 'nfc_uid_1', type: 'string', value: '', puzzle: 'p6', description: 'NFC UID #2 (hex)' },
];
const PUZZLE_FILTERS = ['all', 'p4', 'p5', 'p6'];
export function NvsConfigurator(): React.JSX.Element {
const [settings, setSettings] = useState<NVSSetting[]>(DEFAULT_SETTINGS);
const [targetPort, setTargetPort] = useState('');
const [status, setStatus] = useState('');
const [puzzleFilter, setPuzzleFilter] = useState('all');
const [isWriting, setIsWriting] = useState(false);
const updateSetting = useCallback((key: string, value: string) => {
setSettings(prev => prev.map(s => s.key === key ? { ...s, value } : s));
}, []);
const writeAll = useCallback(async () => {
if (!targetPort) {
setStatus('Select a device port first');
return;
}
setIsWriting(true);
setStatus('Writing NVS settings…');
let ok = 0;
for (const setting of settings) {
// Send "nvs set <key> <value>\n" via serial
const cmd = `nvs set ${setting.key} ${setting.value}\n`;
await window.zacus.serial.write(targetPort, cmd);
await delay(200); // Wait for ESP32 to process
ok++;
}
setIsWriting(false);
setStatus(`Done: ${ok}/${settings.length} settings written`);
}, [settings, targetPort]);
const readAll = useCallback(async () => {
if (!targetPort) {
setStatus('Select a device port first');
return;
}
setStatus('Reading NVS…');
await window.zacus.serial.write(targetPort, 'nvs dump\n');
setStatus('Sent nvs dump — check Serial Monitor for output');
}, [targetPort]);
const resetToDefaults = useCallback(() => {
setSettings(DEFAULT_SETTINGS);
setStatus('Reset to defaults');
}, []);
const filteredSettings = puzzleFilter === 'all'
? settings
: settings.filter(s => s.puzzle === puzzleFilter || s.puzzle === 'all');
return (
<div className="nvs-configurator">
<div className="panel-header">
<h2>NVS Configurator</h2>
<div className="toolbar-group">
<input
className="input mono"
placeholder="/dev/cu.usbmodem…"
value={targetPort}
onChange={e => setTargetPort(e.target.value)}
/>
<button className="btn" onClick={readAll} disabled={!targetPort}>
Read
</button>
<button
className="btn primary"
onClick={writeAll}
disabled={!targetPort || isWriting}
>
{isWriting ? 'Writing…' : 'Write All'}
</button>
<button className="btn" onClick={resetToDefaults}>
Reset
</button>
</div>
</div>
{status && (
<div className={`status-bar ${status.startsWith('Done') ? 'success' : ''}`}>
{status}
</div>
)}
<div className="puzzle-filter">
{PUZZLE_FILTERS.map(f => (
<button
key={f}
className={`btn small ${puzzleFilter === f ? 'active' : ''}`}
onClick={() => setPuzzleFilter(f)}
>
{f === 'all' ? 'All puzzles' : f.toUpperCase()}
</button>
))}
</div>
<table className="nvs-table">
<thead>
<tr>
<th>Key</th>
<th>Type</th>
<th>Puzzle</th>
<th>Value</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{filteredSettings.map(s => (
<tr key={s.key}>
<td className="mono">{s.key}</td>
<td>
<span className="badge">{s.type}</span>
</td>
<td>{s.puzzle}</td>
<td>
<input
className="input mono"
type={s.key.includes('pass') ? 'password' : 'text'}
value={s.value}
onChange={e => updateSetting(s.key, e.target.value)}
/>
</td>
<td className="text-dim">{s.description}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
@@ -0,0 +1,210 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
interface LogEntry {
port: string;
timestamp: number;
text: string;
level: 'info' | 'warn' | 'error' | 'debug';
}
const LEVEL_COLORS: Record<LogEntry['level'], string> = {
error: '#ef4444',
warn: '#f59e0b',
info: '#94a3b8',
debug: '#475569',
};
function classifyLine(text: string): LogEntry['level'] {
if (/error|fail|crash/i.test(text)) return 'error';
if (/warn|caution/i.test(text)) return 'warn';
if (/debug|verbose/i.test(text)) return 'debug';
return 'info';
}
export function SerialMonitor(): React.JSX.Element {
const [ports, setPorts] = useState<string[]>([]);
const [connectedPorts, setConnectedPorts] = useState<Set<string>>(new Set());
const [logs, setLogs] = useState<LogEntry[]>([]);
const [filterPort, setFilterPort] = useState<string>('all');
const [filterText, setFilterText] = useState('');
const [filterLevel, setFilterLevel] = useState<'all' | LogEntry['level']>('all');
const [paused, setPaused] = useState(false);
const [sendText, setSendText] = useState('');
const logEndRef = useRef<HTMLDivElement>(null);
// Load port list on mount
useEffect(() => {
window.zacus.serial.list().then(serialPorts => {
setPorts(serialPorts.map(p => p.path));
});
}, []);
// Listen for incoming serial data and plug/unplug events
useEffect(() => {
window.zacus.serial.onData((port, data) => {
if (paused) return;
const lines = data.split('\n').filter(Boolean);
setLogs(prev => [
...prev.slice(-2000), // keep last 2000 lines
...lines.map(text => ({
port,
timestamp: Date.now(),
text: text.trim(),
level: classifyLine(text),
})),
]);
});
window.zacus.serial.onPlugged(port => {
setPorts(prev => [...new Set([...prev, port])]);
});
window.zacus.serial.onUnplugged(port => {
setConnectedPorts(prev => {
const s = new Set(prev);
s.delete(port);
return s;
});
});
}, [paused]);
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
if (!paused) {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [logs, paused]);
const connect = useCallback(async (port: string) => {
const ok = await window.zacus.serial.connect(port, 115200);
if (ok) setConnectedPorts(prev => new Set([...prev, port]));
}, []);
const disconnect = useCallback(async (port: string) => {
await window.zacus.serial.disconnect(port);
setConnectedPorts(prev => {
const s = new Set(prev);
s.delete(port);
return s;
});
}, []);
const sendCommand = useCallback(async () => {
if (!sendText.trim()) return;
// Send to all connected ports or the filtered port
const targets = filterPort !== 'all'
? [filterPort]
: [...connectedPorts];
for (const port of targets) {
await window.zacus.serial.write(port, sendText + '\n');
}
setSendText('');
}, [sendText, filterPort, connectedPorts]);
const visibleLogs = logs.filter(e => {
if (filterPort !== 'all' && e.port !== filterPort) return false;
if (filterLevel !== 'all' && e.level !== filterLevel) return false;
if (filterText && !e.text.toLowerCase().includes(filterText.toLowerCase())) return false;
return true;
});
const exportLogs = useCallback(() => {
const text = visibleLogs
.map(e => `[${new Date(e.timestamp).toISOString()}] [${e.port}] ${e.text}`)
.join('\n');
window.zacus.file.save(text, 'serial_log.txt');
}, [visibleLogs]);
return (
<div className="serial-monitor">
<div className="serial-toolbar">
<select
value={filterPort}
onChange={e => setFilterPort(e.target.value)}
className="select"
>
<option value="all">All ports</option>
{ports.map(p => (
<option key={p} value={p}>{p.split('/').pop()}</option>
))}
</select>
<select
value={filterLevel}
onChange={e => setFilterLevel(e.target.value as 'all' | LogEntry['level'])}
className="select"
>
<option value="all">All levels</option>
<option value="error">Error</option>
<option value="warn">Warn</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</select>
<input
className="input"
placeholder="Filter text…"
value={filterText}
onChange={e => setFilterText(e.target.value)}
/>
<button className="btn" onClick={() => setPaused(p => !p)}>
{paused ? '▶ Resume' : '⏸ Pause'}
</button>
<button className="btn" onClick={() => setLogs([])}>Clear</button>
<button className="btn" onClick={exportLogs} disabled={visibleLogs.length === 0}>
Export
</button>
</div>
<div className="port-list">
{ports.map(port => (
<button
key={port}
className={`btn small ${connectedPorts.has(port) ? 'active' : ''}`}
onClick={() => connectedPorts.has(port) ? disconnect(port) : connect(port)}
>
{connectedPorts.has(port) ? '● ' : '○ '}
{port.split('/').pop()}
</button>
))}
</div>
<div className="log-output">
{visibleLogs.map((entry, i) => (
<div
key={i}
className="log-line"
style={{ color: LEVEL_COLORS[entry.level] }}
>
<span className="log-time">
{new Date(entry.timestamp).toISOString().slice(11, 23)}
</span>
<span className="log-port">[{entry.port.split('/').pop()}]</span>
<span className="log-text">{entry.text}</span>
</div>
))}
<div ref={logEndRef} />
</div>
<div className="serial-send-bar">
<input
className="input mono"
placeholder="Send command…"
value={sendText}
onChange={e => setSendText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') sendCommand(); }}
disabled={connectedPorts.size === 0}
/>
<button
className="btn primary"
onClick={sendCommand}
disabled={connectedPorts.size === 0 || !sendText.trim()}
>
Send
</button>
</div>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
// Type declarations for the contextBridge API exposed by the preload script
interface SerialPort {
path: string;
name: string;
vendorId?: string;
productId?: string;
}
interface ZacusDevice {
id: string;
name: string;
type: 'puzzle' | 'hub';
firmwareVersion: string;
batteryPct: number;
connectionType: 'usb' | 'ble' | 'wifi';
lastSeen: number;
ip?: string;
mac?: string;
}
interface OTAProgressEvent {
deviceId: string;
percent: number;
stage: 'uploading' | 'verifying' | 'rebooting';
}
interface ZacusAPI {
serial: {
list: () => Promise<SerialPort[]>;
connect: (port: string, baud: number) => Promise<boolean>;
disconnect: (port: string) => Promise<void>;
write: (port: string, data: string) => Promise<void>;
flash: (port: string, firmwarePath: string) => Promise<void>;
onData: (callback: (port: string, data: string) => void) => void;
onPlugged: (callback: (port: string) => void) => void;
onUnplugged: (callback: (port: string) => void) => void;
};
ble: {
scan: () => Promise<void>;
stopScan: () => Promise<void>;
connect: (deviceId: string) => Promise<boolean>;
disconnect: (deviceId: string) => Promise<void>;
write: (deviceId: string, characteristic: string, data: string) => Promise<void>;
dfu: (deviceId: string, firmwarePath: string) => Promise<void>;
onDiscovered: (callback: (device: ZacusDevice) => void) => void;
onData: (callback: (deviceId: string, characteristic: string, data: string) => void) => void;
};
wifi: {
discover: () => Promise<ZacusDevice[]>;
wsConnect: (url: string) => Promise<boolean>;
wsSend: (data: string) => Promise<void>;
wsDisconnect: () => Promise<void>;
http: (url: string, method: string, body?: string, headers?: Record<string, string>) => Promise<{ status: number; data: string }>;
onWsMessage: (callback: (data: string) => void) => void;
onServiceFound: (callback: (service: ZacusDevice) => void) => void;
};
ota: {
check: (deviceId: string) => Promise<{ current: string; available: string; needsUpdate: boolean }>;
update: (deviceId: string, method: 'wifi' | 'ble' | 'usb', firmwarePath: string) => Promise<void>;
rollback: (deviceId: string) => Promise<boolean>;
onProgress: (callback: (event: OTAProgressEvent) => void) => void;
onComplete: (callback: (deviceId: string, success: boolean, error?: string) => void) => void;
};
file: {
open: (filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
save: (data: string, defaultPath?: string) => Promise<string | null>;
recent: () => Promise<string[]>;
addRecent: (filePath: string) => Promise<void>;
};
menu: {
on: (event: string, callback: (...args: unknown[]) => void) => void;
};
notify: (title: string, body: string) => Promise<void>;
}
declare global {
interface Window {
zacus: ZacusAPI;
}
}
export {};
+14
View File
@@ -0,0 +1,14 @@
<!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'; script-src 'self'; style-src 'self' 'unsafe-inline';" />
<title>Zacus Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+96
View File
@@ -0,0 +1,96 @@
.app-layout {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: var(--sidebar-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-top {
height: 52px;
}
.sidebar-nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 6px;
}
.sidebar-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
width: 52px;
height: 52px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.sidebar-btn:hover {
background: var(--accent-dim);
color: var(--text-primary);
}
.sidebar-btn.active {
background: var(--accent);
color: white;
}
.sidebar-icon {
font-size: 18px;
line-height: 1;
}
.sidebar-label {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
overflow: hidden;
max-width: 52px;
text-overflow: ellipsis;
}
.sidebar-bottom {
padding: 12px;
display: flex;
justify-content: center;
}
.device-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
transition: background-color 0.3s ease;
box-shadow: 0 0 6px currentColor;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-primary);
}
.tab-content {
flex: 1;
overflow: hidden;
position: relative;
}
+47
View File
@@ -0,0 +1,47 @@
:root {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-tertiary: #16213e;
--accent: #7c3aed;
--accent-hover: #6d28d9;
--accent-dim: rgba(124, 58, 237, 0.2);
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-dim: #475569;
--border: rgba(255, 255, 255, 0.08);
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
--sidebar-width: 64px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
/* macOS traffic light area */
.titlebar-drag {
-webkit-app-region: drag;
height: 52px;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--text-dim);
border-radius: 3px;
}
@@ -0,0 +1,41 @@
import React, { useEffect, useRef } from 'react';
const DASHBOARD_URL = import.meta.env.DEV
? 'http://localhost:5175'
: './apps/dashboard/index.html';
interface Props {
onDeviceStatusChange: (status: 'connected' | 'partial' | 'disconnected') => void;
}
export function DashboardTab({ onDeviceStatusChange }: Props): React.JSX.Element {
const webviewRef = useRef<Electron.WebviewTag>(null);
useEffect(() => {
const wv = webviewRef.current;
if (!wv) return;
// Listen for device status messages from dashboard app
const handleIpcMessage = (e: { channel: string; args: unknown[] }) => {
if (e.channel === 'device-status') {
onDeviceStatusChange(e.args[0] as 'connected' | 'partial' | 'disconnected');
}
};
wv.addEventListener('ipc-message', handleIpcMessage as EventListener);
return () => wv.removeEventListener('ipc-message', handleIpcMessage as EventListener);
}, [onDeviceStatusChange]);
return (
<div style={{ width: '100%', height: '100%' }}>
<webview
ref={webviewRef}
src={DASHBOARD_URL}
style={{ width: '100%', height: '100%' }}
allowpopups={'false' as unknown as boolean}
nodeintegration={'false' as unknown as boolean}
partition="persist:dashboard"
/>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
.devtools-layout {
display: flex;
height: 100%;
overflow: hidden;
}
.devtools-nav {
width: 110px;
flex-shrink: 0;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 6px;
}
.devtools-nav-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 7px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 12px;
font-weight: 500;
text-align: left;
transition: all 0.12s ease;
width: 100%;
}
.devtools-nav-btn:hover {
background: var(--accent-dim);
color: var(--text-primary);
}
.devtools-nav-btn.active {
background: var(--accent);
color: white;
}
.devtools-content {
flex: 1;
overflow: auto;
background: var(--bg-primary);
}
+58
View File
@@ -0,0 +1,58 @@
import React, { useState } from 'react';
import { DeviceManager } from '../devtools/DeviceManager';
import { SerialMonitor } from '../devtools/SerialMonitor';
import { FirmwareManager } from '../devtools/FirmwareManager';
import { NvsConfigurator } from '../devtools/NvsConfigurator';
import { MeshDiagnostic } from '../devtools/MeshDiagnostic';
import { BatteryDashboard } from '../devtools/BatteryDashboard';
import { LogRecorder } from '../devtools/LogRecorder';
import './DevToolsTab.css';
type DevPanel =
| 'devices'
| 'serial'
| 'firmware'
| 'nvs'
| 'mesh'
| 'battery'
| 'logs';
const PANELS: Array<{ id: DevPanel; label: string; icon: string }> = [
{ id: 'devices', label: 'Devices', icon: 'D' },
{ id: 'serial', label: 'Serial', icon: 'S' },
{ id: 'firmware', label: 'Firmware', icon: 'F' },
{ id: 'nvs', label: 'NVS', icon: 'N' },
{ id: 'mesh', label: 'Mesh', icon: 'M' },
{ id: 'battery', label: 'Battery', icon: 'B' },
{ id: 'logs', label: 'Logs', icon: 'L' },
];
export function DevToolsTab(): React.JSX.Element {
const [activePanel, setActivePanel] = useState<DevPanel>('devices');
return (
<div className="devtools-layout">
<nav className="devtools-nav">
{PANELS.map(panel => (
<button
key={panel.id}
className={`devtools-nav-btn ${activePanel === panel.id ? 'active' : ''}`}
onClick={() => setActivePanel(panel.id)}
>
<span>{panel.icon}</span>
<span>{panel.label}</span>
</button>
))}
</nav>
<div className="devtools-content">
{activePanel === 'devices' && <DeviceManager />}
{activePanel === 'serial' && <SerialMonitor />}
{activePanel === 'firmware' && <FirmwareManager />}
{activePanel === 'nvs' && <NvsConfigurator />}
{activePanel === 'mesh' && <MeshDiagnostic />}
{activePanel === 'battery' && <BatteryDashboard />}
{activePanel === 'logs' && <LogRecorder />}
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import React, { useEffect, useRef, useCallback } from 'react';
// EditorTab loads the frontend-v3 editor app in a webview.
// In development, it points to the Vite dev server.
// In production, it loads the built app from the renderer bundle.
const EDITOR_URL = import.meta.env.DEV
? 'http://localhost:5174' // frontend-v3/apps/editor dev server
: './apps/editor/index.html';
export function EditorTab(): React.JSX.Element {
const webviewRef = useRef<Electron.WebviewTag>(null);
// Forward menu events into the webview
useEffect(() => {
const menuEvents = ['save', 'open', 'compile', 'validate', 'export-sd'];
menuEvents.forEach(evt => {
window.zacus.menu.on(evt, () => {
webviewRef.current?.send(`menu:${evt}`);
});
});
}, []);
const handleNewWindow = useCallback((e: Event) => {
e.preventDefault();
}, []);
return (
<div style={{ width: '100%', height: '100%' }}>
<webview
ref={webviewRef}
src={EDITOR_URL}
style={{ width: '100%', height: '100%' }}
allowpopups={'false' as unknown as boolean}
onNewWindow={handleNewWindow as unknown as React.ReactEventHandler}
nodeintegration={'false' as unknown as boolean}
partition="persist:editor"
/>
</div>
);
}
@@ -0,0 +1,19 @@
import React from 'react';
const SIMULATION_URL = import.meta.env.DEV
? 'http://localhost:5176'
: './apps/simulation/index.html';
export function SimulationTab(): React.JSX.Element {
return (
<div style={{ width: '100%', height: '100%' }}>
<webview
src={SIMULATION_URL}
style={{ width: '100%', height: '100%' }}
allowpopups={'false' as unknown as boolean}
nodeintegration={'false' as unknown as boolean}
partition="persist:simulation"
/>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "dist/main",
"rootDir": "src/main",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/main/**/*", "src/preload/**/*"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"outDir": "dist/renderer",
"rootDir": "src/renderer",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src/renderer/**/*"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
root: 'src/renderer',
base: './',
build: {
outDir: resolve(__dirname, 'dist/renderer'),
emptyOutDir: true,
rollupOptions: {
input: resolve(__dirname, 'src/renderer/index.html'),
},
},
server: {
port: 5173,
},
});