diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..9344bf4 --- /dev/null +++ b/desktop/.gitignore @@ -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 diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml new file mode 100644 index 0000000..c6a0620 --- /dev/null +++ b/desktop/electron-builder.yml @@ -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 diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..ba5fc02 --- /dev/null +++ b/desktop/package.json @@ -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" + } +} diff --git a/desktop/resources/entitlements.plist b/desktop/resources/entitlements.plist new file mode 100644 index 0000000..1c71875 --- /dev/null +++ b/desktop/resources/entitlements.plist @@ -0,0 +1,41 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + + com.apple.security.cs.allow-dyld-environment-variables + + + + com.apple.security.network.client + + com.apple.security.network.server + + + + com.apple.security.device.bluetooth + + + + com.apple.security.device.usb + + + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + + + com.apple.security.cs.allow-execution + + + diff --git a/desktop/scripts/build-frontends.sh b/desktop/scripts/build-frontends.sh new file mode 100755 index 0000000..d8fc6f1 --- /dev/null +++ b/desktop/scripts/build-frontends.sh @@ -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" diff --git a/desktop/src/main/auto-updater.ts b/desktop/src/main/auto-updater.ts new file mode 100644 index 0000000..b92cbc2 --- /dev/null +++ b/desktop/src/main/auto-updater.ts @@ -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); + }); +} diff --git a/desktop/src/main/file-handler.ts b/desktop/src/main/file-handler.ts new file mode 100644 index 0000000..54f6a8e --- /dev/null +++ b/desktop/src/main/file-handler.ts @@ -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(); + } + }); +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts new file mode 100644 index 0000000..6f2552c --- /dev/null +++ b/desktop/src/main/index.ts @@ -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(); + }); +}); diff --git a/desktop/src/main/menu.ts b/desktop/src/main/menu.ts new file mode 100644 index 0000000..9691031 --- /dev/null +++ b/desktop/src/main/menu.ts @@ -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); +} diff --git a/desktop/src/main/ota-manager.ts b/desktop/src/main/ota-manager.ts new file mode 100644 index 0000000..3f467b0 --- /dev/null +++ b/desktop/src/main/ota-manager.ts @@ -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(); + private firmwareCacheDir: string; + private lastOTATime = new Map(); + 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 { + 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 { + // 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 { + // 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; + // 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 { + // 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 { + 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((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 { + 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 { + // 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 { + // 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts new file mode 100644 index 0000000..55701b4 --- /dev/null +++ b/desktop/src/preload/index.ts @@ -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, + connect: (port: string, baud: number) => + ipcRenderer.invoke('serial:connect', { port, baud }) as Promise, + disconnect: (port: string) => + ipcRenderer.invoke('serial:disconnect', { port }) as Promise, + write: (port: string, data: string) => + ipcRenderer.invoke('serial:write', { port, data }) as Promise, + flash: (port: string, firmwarePath: string) => + ipcRenderer.invoke('serial:flash', { port, firmwarePath }) as Promise, + 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, + stopScan: () => ipcRenderer.invoke('ble:stop-scan') as Promise, + connect: (deviceId: string) => + ipcRenderer.invoke('ble:connect', deviceId) as Promise, + disconnect: (deviceId: string) => + ipcRenderer.invoke('ble:disconnect', deviceId) as Promise, + write: (deviceId: string, characteristic: string, data: string) => + ipcRenderer.invoke('ble:write', { deviceId, characteristic, data }) as Promise, + dfu: (deviceId: string, firmwarePath: string) => + ipcRenderer.invoke('ble:dfu', { deviceId, firmwarePath }) as Promise, + 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, + wsConnect: (url: string) => + ipcRenderer.invoke('wifi:ws-connect', url) as Promise, + wsSend: (data: string) => + ipcRenderer.invoke('wifi:ws-send', data) as Promise, + wsDisconnect: () => + ipcRenderer.invoke('wifi:ws-disconnect') as Promise, + http: (url: string, method: string, body?: string, headers?: Record) => + 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, + rollback: (deviceId: string) => + ipcRenderer.invoke('ota:rollback', deviceId) as Promise, + 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, + save: (data: string, defaultPath?: string) => + ipcRenderer.invoke('file:save', { data, defaultPath }) as Promise, + recent: () => ipcRenderer.invoke('file:recent') as Promise, + addRecent: (filePath: string) => + ipcRenderer.invoke('file:add-recent', filePath) as Promise, + }, + + // === 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, +}); diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx new file mode 100644 index 0000000..6cf736c --- /dev/null +++ b/desktop/src/renderer/App.tsx @@ -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('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 ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+
+
+ {activeTab === 'editor' && } + {activeTab === 'dashboard' && } + {activeTab === 'simulation' && } + {activeTab === 'devtools' && } +
+
+
+ ); +} diff --git a/desktop/src/renderer/devtools/BatteryDashboard.tsx b/desktop/src/renderer/devtools/BatteryDashboard.tsx new file mode 100644 index 0000000..195041f --- /dev/null +++ b/desktop/src/renderer/devtools/BatteryDashboard.tsx @@ -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 ( +
+
+
+ ); +} + +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(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 ( +
+
+

Battery Dashboard

+ {readings.length} devices monitored +
+ + {criticalDevices.length > 0 && ( +
+ ⚠️ Critical: {criticalDevices.map(d => `${d.name} (${d.pct}%)`).join(', ')} — replace immediately +
+ )} + {warningDevices.length > 0 && criticalDevices.length === 0 && ( +
+ ⚠️ Low: {warningDevices.map(d => `${d.name} (${d.pct}%)`).join(', ')} — replace soon +
+ )} + {lowest && lowest.pct < 25 && criticalDevices.length === 0 && warningDevices.length === 0 && ( +
+ ⚠️ {lowest.name}: {lowest.pct}% — lowest battery +
+ )} + +
+ {readings.map(r => ( +
+
{r.name}
+
+ + + {r.pct}% + +
+
+ {r.pack} + + ~{r.remainingHours.toFixed(1)}h remaining + + {r.pct < 20 && ⚠️ CRITICAL} + {r.pct >= 20 && r.pct < 25 && ⚠️ LOW} +
+
+ ))} +
+ +
+ + 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 + +
+
+ ); +} diff --git a/desktop/src/renderer/devtools/DeviceManager.tsx b/desktop/src/renderer/devtools/DeviceManager.tsx new file mode 100644 index 0000000..78bf52a --- /dev/null +++ b/desktop/src/renderer/devtools/DeviceManager.tsx @@ -0,0 +1,149 @@ +import React, { useState, useEffect, useCallback } from 'react'; + +const CONNECTION_ICONS: Record = { + usb: '🔌', + ble: '📶', + wifi: '📡', +}; + +export function DeviceManager(): React.JSX.Element { + const [devices, setDevices] = useState([]); + const [scanning, setScanning] = useState(false); + const [selectedId, setSelectedId] = useState(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 ( +
+
+

Devices

+ +
+ +
+ {devices.length === 0 && ( +
+ No devices found. Connect USB or enable WiFi. +
+ )} + {devices.map(device => ( +
setSelectedId(device.id)} + > +
+ {CONNECTION_ICONS[device.connectionType]} + {device.name} + {device.type} +
+
+ fw v{device.firmwareVersion} + {device.batteryPct >= 0 && ( + + 🔋 {device.batteryPct}% + + )} + {device.ip && ( + 📡 {device.ip} + )} + {device.mac && ( + {device.mac} + )} +
+
+ + Last seen {new Date(device.lastSeen).toLocaleTimeString()} + +
+
+ + {device.connectionType === 'ble' && ( + + )} +
+
+ ))} +
+
+ ); +} diff --git a/desktop/src/renderer/devtools/FirmwareManager.tsx b/desktop/src/renderer/devtools/FirmwareManager.tsx new file mode 100644 index 0000000..234d080 --- /dev/null +++ b/desktop/src/renderer/devtools/FirmwareManager.tsx @@ -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([]); + const [firmwares, setFirmwares] = useState([]); + const [selectedMethod, setSelectedMethod] = useState('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 ( +
+
+

Firmware Manager

+
+ + + + + +
+
+ + {/* Update Matrix */} + + + + + + + + + + + + + {devices.map(row => ( + + + + + + + + + ))} + {devices.length === 0 && ( + + + + )} + +
DeviceConnectionCurrentAvailableStatusActions
{row.device.name} + {row.device.connectionType.toUpperCase()} + {row.currentVersion} + {row.availableVersion} + {row.needsUpdate && UPDATE} + + {row.status === 'updating' ? ( +
+
+ + {row.progressStage} {row.progress}% + +
+ ) : ( + {row.status} + )} + {row.error &&
{row.error}
} +
+ + +
+ No devices connected. Scan for devices in the Dashboard tab. +
+ + {/* Firmware Cache */} +
+

Firmware Cache

+ {firmwares.length === 0 ? ( +
No firmwares cached. Build from source or import a .bin.
+ ) : ( + firmwares.map(fw => ( +
+ {fw.name} + v{fw.version} + {(fw.size / 1024).toFixed(0)} KB + {new Date(fw.builtAt).toLocaleDateString()} +
+ )) + )} +
+
+ ); +} diff --git a/desktop/src/renderer/devtools/LogRecorder.tsx b/desktop/src/renderer/devtools/LogRecorder.tsx new file mode 100644 index 0000000..a05bd1e --- /dev/null +++ b/desktop/src/renderer/devtools/LogRecorder.tsx @@ -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 = { + 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([]); + const [filterSource, setFilterSource] = useState<'all' | LogEvent['source']>('all'); + const sessionRef = useRef([]); + const logEndRef = useRef(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 ( +
+
+

Log Recorder

+
+ + + + +
+
+ + {recording && ( +
+ Recording — {events.length} events captured +
+ )} + +
+ + {sources.map(src => { + const count = events.filter(e => e.source === src).length; + return ( + + ); + })} +
+ +
+ {visibleEvents.slice(-200).map((e, i) => ( +
+ + {new Date(e.timestamp).toISOString().slice(11, 23)} + + + [{e.source}] + + {e.device && ( + + {e.device.split('/').pop()} + + )} + {e.type} + + {JSON.stringify(e.payload).slice(0, 120)} + +
+ ))} +
+
+ + {events.length === 0 && !recording && ( +
+ Press Record to start capturing serial and WebSocket events. +
+ )} +
+ ); +} diff --git a/desktop/src/renderer/devtools/MeshDiagnostic.tsx b/desktop/src/renderer/devtools/MeshDiagnostic.tsx new file mode 100644 index 0000000..91a7c0c --- /dev/null +++ b/desktop/src/renderer/devtools/MeshDiagnostic.tsx @@ -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( + PUZZLE_NODES.map((name, i) => ({ + id: `node-${i}`, + name, + rssi: 0, + latencyMs: 0, + packetLoss: 0, + lastSeen: 0, + online: false, + })) + ); + const canvasRef = useRef(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 ( +
+
+

Mesh Diagnostic

+ + ESP-NOW via BOX-3 WebSocket — {onlineCount}/{nodes.length} online + +
+ + + + + + + + + + + + + + + + {nodes.map(node => ( + + + + + + + + + ))} + +
DeviceStatusRSSILatencyLoss %Last seen
{node.name} + + {node.online ? 'Online' : 'Offline'} + + {node.online ? `${node.rssi} dBm` : '—'} + + {node.online ? `${node.latencyMs} ms` : '—'} + 5 ? 'warn mono' : 'mono'}> + {node.online ? `${node.packetLoss.toFixed(1)}%` : '—'} + + {node.lastSeen + ? new Date(node.lastSeen).toLocaleTimeString() + : '—'} +
+
+ ); +} diff --git a/desktop/src/renderer/devtools/NvsConfigurator.tsx b/desktop/src/renderer/devtools/NvsConfigurator.tsx new file mode 100644 index 0000000..8779488 --- /dev/null +++ b/desktop/src/renderer/devtools/NvsConfigurator.tsx @@ -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(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 \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 ( +
+
+

NVS Configurator

+
+ setTargetPort(e.target.value)} + /> + + + +
+
+ + {status && ( +
+ {status} +
+ )} + +
+ {PUZZLE_FILTERS.map(f => ( + + ))} +
+ + + + + + + + + + + + + {filteredSettings.map(s => ( + + + + + + + + ))} + +
KeyTypePuzzleValueDescription
{s.key} + {s.type} + {s.puzzle} + updateSetting(s.key, e.target.value)} + /> + {s.description}
+
+ ); +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/desktop/src/renderer/devtools/SerialMonitor.tsx b/desktop/src/renderer/devtools/SerialMonitor.tsx new file mode 100644 index 0000000..abb6272 --- /dev/null +++ b/desktop/src/renderer/devtools/SerialMonitor.tsx @@ -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 = { + 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([]); + const [connectedPorts, setConnectedPorts] = useState>(new Set()); + const [logs, setLogs] = useState([]); + const [filterPort, setFilterPort] = useState('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(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 ( +
+
+ + + + + setFilterText(e.target.value)} + /> + + + + + +
+ +
+ {ports.map(port => ( + + ))} +
+ +
+ {visibleLogs.map((entry, i) => ( +
+ + {new Date(entry.timestamp).toISOString().slice(11, 23)} + + [{entry.port.split('/').pop()}] + {entry.text} +
+ ))} +
+
+ +
+ setSendText(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') sendCommand(); }} + disabled={connectedPorts.size === 0} + /> + +
+
+ ); +} diff --git a/desktop/src/renderer/global.d.ts b/desktop/src/renderer/global.d.ts new file mode 100644 index 0000000..6ff2aaf --- /dev/null +++ b/desktop/src/renderer/global.d.ts @@ -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; + connect: (port: string, baud: number) => Promise; + disconnect: (port: string) => Promise; + write: (port: string, data: string) => Promise; + flash: (port: string, firmwarePath: string) => Promise; + onData: (callback: (port: string, data: string) => void) => void; + onPlugged: (callback: (port: string) => void) => void; + onUnplugged: (callback: (port: string) => void) => void; + }; + ble: { + scan: () => Promise; + stopScan: () => Promise; + connect: (deviceId: string) => Promise; + disconnect: (deviceId: string) => Promise; + write: (deviceId: string, characteristic: string, data: string) => Promise; + dfu: (deviceId: string, firmwarePath: string) => Promise; + onDiscovered: (callback: (device: ZacusDevice) => void) => void; + onData: (callback: (deviceId: string, characteristic: string, data: string) => void) => void; + }; + wifi: { + discover: () => Promise; + wsConnect: (url: string) => Promise; + wsSend: (data: string) => Promise; + wsDisconnect: () => Promise; + http: (url: string, method: string, body?: string, headers?: Record) => 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; + rollback: (deviceId: string) => Promise; + 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; + save: (data: string, defaultPath?: string) => Promise; + recent: () => Promise; + addRecent: (filePath: string) => Promise; + }; + menu: { + on: (event: string, callback: (...args: unknown[]) => void) => void; + }; + notify: (title: string, body: string) => Promise; +} + +declare global { + interface Window { + zacus: ZacusAPI; + } +} + +export {}; diff --git a/desktop/src/renderer/index.html b/desktop/src/renderer/index.html new file mode 100644 index 0000000..5a47cd0 --- /dev/null +++ b/desktop/src/renderer/index.html @@ -0,0 +1,14 @@ + + + + + + + Zacus Studio + + +
+ + + diff --git a/desktop/src/renderer/main.tsx b/desktop/src/renderer/main.tsx new file mode 100644 index 0000000..dc3f4b5 --- /dev/null +++ b/desktop/src/renderer/main.tsx @@ -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( + + + +); diff --git a/desktop/src/renderer/styles/App.css b/desktop/src/renderer/styles/App.css new file mode 100644 index 0000000..75c2284 --- /dev/null +++ b/desktop/src/renderer/styles/App.css @@ -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; +} diff --git a/desktop/src/renderer/styles/global.css b/desktop/src/renderer/styles/global.css new file mode 100644 index 0000000..5dc2795 --- /dev/null +++ b/desktop/src/renderer/styles/global.css @@ -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; +} diff --git a/desktop/src/renderer/tabs/DashboardTab.tsx b/desktop/src/renderer/tabs/DashboardTab.tsx new file mode 100644 index 0000000..155d804 --- /dev/null +++ b/desktop/src/renderer/tabs/DashboardTab.tsx @@ -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(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 ( +
+ +
+ ); +} diff --git a/desktop/src/renderer/tabs/DevToolsTab.css b/desktop/src/renderer/tabs/DevToolsTab.css new file mode 100644 index 0000000..e35921f --- /dev/null +++ b/desktop/src/renderer/tabs/DevToolsTab.css @@ -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); +} diff --git a/desktop/src/renderer/tabs/DevToolsTab.tsx b/desktop/src/renderer/tabs/DevToolsTab.tsx new file mode 100644 index 0000000..cf46fb1 --- /dev/null +++ b/desktop/src/renderer/tabs/DevToolsTab.tsx @@ -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('devices'); + + return ( +
+ +
+ {activePanel === 'devices' && } + {activePanel === 'serial' && } + {activePanel === 'firmware' && } + {activePanel === 'nvs' && } + {activePanel === 'mesh' && } + {activePanel === 'battery' && } + {activePanel === 'logs' && } +
+
+ ); +} diff --git a/desktop/src/renderer/tabs/EditorTab.tsx b/desktop/src/renderer/tabs/EditorTab.tsx new file mode 100644 index 0000000..31ecfeb --- /dev/null +++ b/desktop/src/renderer/tabs/EditorTab.tsx @@ -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(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 ( +
+ +
+ ); +} diff --git a/desktop/src/renderer/tabs/SimulationTab.tsx b/desktop/src/renderer/tabs/SimulationTab.tsx new file mode 100644 index 0000000..36d1f66 --- /dev/null +++ b/desktop/src/renderer/tabs/SimulationTab.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/desktop/tsconfig.main.json b/desktop/tsconfig.main.json new file mode 100644 index 0000000..652cedc --- /dev/null +++ b/desktop/tsconfig.main.json @@ -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/**/*"] +} diff --git a/desktop/tsconfig.renderer.json b/desktop/tsconfig.renderer.json new file mode 100644 index 0000000..3217626 --- /dev/null +++ b/desktop/tsconfig.renderer.json @@ -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/**/*"] +} diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts new file mode 100644 index 0000000..e8980ea --- /dev/null +++ b/desktop/vite.config.ts @@ -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, + }, +});