From b5cc361ce9693e940925cdb46b8e622ea04f34b0 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 3 Mar 2026 00:52:53 +0800 Subject: [PATCH] feat(electron): add main process, preload, store, protocol, and type definitions - Add ElectronAPI type definitions and runtime contract (isElectron guard) - Add electron-store with backend connection data model - Add centralized API base URL utility (api-base.ts) - Implement app:// custom protocol with API proxy - Implement preload script with full contextBridge API - Complete main process: BrowserWindow config, IPC handlers, window controls - Register app:// scheme as privileged for secure renderer access --- dashboard/electron.vite.config.ts | 61 +++----- dashboard/electron/main/index.ts | 139 ++++++++++++++--- dashboard/electron/main/protocol.ts | 89 +++++++++++ dashboard/electron/main/store.ts | 215 ++++++++++++++++++++++++++ dashboard/electron/preload/index.ts | 63 +++++++- dashboard/electron/resources/.gitkeep | 0 dashboard/package.json | 52 ++++++- dashboard/src/lib/api-base.ts | 119 ++++++++++++++ dashboard/src/lib/runtime.ts | 77 +++++++++ dashboard/src/types/electron.d.ts | 95 ++++++++++++ 10 files changed, 843 insertions(+), 67 deletions(-) create mode 100644 dashboard/electron/main/protocol.ts create mode 100644 dashboard/electron/main/store.ts create mode 100644 dashboard/electron/resources/.gitkeep create mode 100644 dashboard/src/lib/api-base.ts create mode 100644 dashboard/src/lib/runtime.ts create mode 100644 dashboard/src/types/electron.d.ts diff --git a/dashboard/electron.vite.config.ts b/dashboard/electron.vite.config.ts index 1e9c2b0f..b332bcfd 100644 --- a/dashboard/electron.vite.config.ts +++ b/dashboard/electron.vite.config.ts @@ -1,34 +1,40 @@ -import { defineConfig } from 'electron-vite' import react from '@vitejs/plugin-react' +import { defineConfig } from 'electron-vite' import path from 'path' export default defineConfig({ main: { entry: 'electron/main/index.ts', - vite: { - build: { - rollupOptions: { - external: ['electron'], - }, + build: { + target: 'node18', + lib: { + entry: 'electron/main/index.ts', }, - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - }, + rollupOptions: { + external: ['electron', 'electron-store'], + }, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), }, }, }, preload: { entry: 'electron/preload/index.ts', - vite: { - build: { - rollupOptions: { - external: ['electron'], + build: { + target: 'node18', + rollupOptions: { + input: path.resolve(__dirname, 'electron/preload/index.ts'), + output: { + entryFileNames: '[name].js', + format: 'cjs', }, }, }, }, renderer: { + root: '.', resolve: { alias: { '@': path.resolve(__dirname, './src'), @@ -49,15 +55,13 @@ export default defineConfig({ }, build: { rollupOptions: { + input: path.resolve(__dirname, 'index.html'), output: { manualChunks: { - // React core 'react-vendor': ['react', 'react-dom', 'react/jsx-runtime'], - // TanStack Router router: ['@tanstack/react-router', '@tanstack/react-virtual'], - // Radix UI core 'radix-core': [ '@radix-ui/react-dialog', '@radix-ui/react-select', @@ -67,8 +71,6 @@ export default defineConfig({ '@radix-ui/react-toast', '@radix-ui/react-tooltip', ], - - // Radix UI extras 'radix-extra': [ '@radix-ui/react-alert-dialog', '@radix-ui/react-avatar', @@ -83,13 +85,10 @@ export default defineConfig({ '@radix-ui/react-tabs', ], - // Icons icons: ['lucide-react'], - // Charts charts: ['recharts'], - // CodeMirror codemirror: [ '@uiw/react-codemirror', '@codemirror/lang-javascript', @@ -99,10 +98,8 @@ export default defineConfig({ '@codemirror/theme-one-dark', ], - // ReactFlow reactflow: ['reactflow', 'dagre'], - // Markdown markdown: [ 'react-markdown', 'remark-gfm', @@ -111,7 +108,6 @@ export default defineConfig({ 'katex', ], - // Uppy uppy: [ '@uppy/core', '@uppy/dashboard', @@ -119,14 +115,8 @@ export default defineConfig({ '@uppy/xhr-upload', ], - // Drag and drop - dnd: [ - '@dnd-kit/core', - '@dnd-kit/sortable', - '@dnd-kit/utilities', - ], + dnd: ['@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/utilities'], - // Utils utils: [ 'date-fns', 'clsx', @@ -135,12 +125,7 @@ export default defineConfig({ 'axios', ], - // Misc - misc: [ - 'react-joyride', - 'react-day-picker', - 'cmdk', - ], + misc: ['react-joyride', 'react-day-picker', 'cmdk'], }, }, }, diff --git a/dashboard/electron/main/index.ts b/dashboard/electron/main/index.ts index 4c30f9ae..4df1cf3f 100644 --- a/dashboard/electron/main/index.ts +++ b/dashboard/electron/main/index.ts @@ -1,7 +1,21 @@ -import { app, BrowserWindow, protocol } from 'electron' +import { app, BrowserWindow, ipcMain, protocol, session } from 'electron' import path from 'path' import { fileURLToPath } from 'url' +import { registerAppProtocol } from './protocol' +import { + addBackend, + getActiveBackend, + getBackends, + getWindowBounds, + isFirstLaunch, + markFirstLaunchComplete, + removeBackend, + setActiveBackend, + setWindowBounds, + updateBackend, +} from './store' + const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -11,67 +25,151 @@ let mainWindow: BrowserWindow | null = null * Register app:// custom protocol BEFORE app.whenReady() * This is critical for electron-vite to work correctly */ -function registerAppProtocol() { +function registerAppScheme() { protocol.registerSchemesAsPrivileged([ { scheme: 'app', privileges: { + corsEnabled: true, secure: true, - standard: true, allowServiceWorkers: true, + standard: true, + supportFetchAPI: true, + stream: true, }, }, ]) } +/** + * Register all IPC handlers for window control and store CRUD + */ +function registerIpcHandlers() { + // ── Window control ─────────────────────────────────────────────────────── + ipcMain.handle('electron:minimize-window', () => mainWindow?.minimize()) + ipcMain.handle('electron:maximize-window', () => { + if (mainWindow?.isMaximized()) mainWindow.unmaximize() + else mainWindow?.maximize() + }) + ipcMain.handle('electron:close-window', () => mainWindow?.close()) + ipcMain.handle('electron:is-maximized', () => mainWindow?.isMaximized() ?? false) + + // ── Backend CRUD ───────────────────────────────────────────────────────── + ipcMain.handle('electron:get-backends', () => getBackends()) + ipcMain.handle('electron:add-backend', (_e, conn) => addBackend(conn)) + ipcMain.handle('electron:update-backend', (_e, id, patch) => updateBackend(id, patch)) + ipcMain.handle('electron:remove-backend', (_e, id) => removeBackend(id)) + ipcMain.handle('electron:set-active-backend', (_e, id) => { + setActiveBackend(id) + const backend = getActiveBackend() + mainWindow?.webContents.send('electron:backend-changed', backend) + }) + ipcMain.handle('electron:get-active-backend', () => getActiveBackend()) + ipcMain.handle('electron:get-active-url', () => getActiveBackend()?.url ?? null) + + // ── App state ──────────────────────────────────────────────────────────── + ipcMain.handle('electron:is-first-launch', () => isFirstLaunch()) + ipcMain.handle('electron:mark-first-launch-complete', () => markFirstLaunchComplete()) + ipcMain.handle('electron:get-app-version', () => app.getVersion()) +} + /** * Create the main application window */ function createWindow() { + const isMac = process.platform === 'darwin' + + // Restore window bounds from store + const bounds = getWindowBounds() + mainWindow = new BrowserWindow({ - width: 1200, - height: 800, + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, minWidth: 800, minHeight: 600, + // macOS: hide native title bar but keep traffic light buttons + ...(isMac + ? { + titleBarStyle: 'hidden' as const, + trafficLightPosition: { x: 12, y: 8 }, + } + : {}), + // Windows/Linux: overlay title bar (custom title bar integrated) + ...(!isMac + ? { + titleBarOverlay: { + color: '#00000000', + symbolColor: '#ffffff', + height: 32, + }, + } + : {}), webPreferences: { preload: path.join(__dirname, '../preload/index.js'), nodeIntegration: false, contextIsolation: true, + sandbox: true, }, }) // Load the app using app:// protocol // electron-vite will handle serving the renderer from app://host/index.html - if (process.env.VITE_DEV_SERVER_URL) { + if (process.env.ELECTRON_RENDERER_URL) { // Development: load from electron-vite dev server - mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL) + mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) } else { // Production: load from bundled renderer mainWindow.loadURL('app://host/index.html') } + // Persist window size/position on close + mainWindow.on('close', () => { + if (mainWindow) { + const { x, y, width, height } = mainWindow.getBounds() + setWindowBounds({ x, y, width, height }) + } + }) + mainWindow.on('closed', () => { mainWindow = null }) -} -/** - * Register app:// protocol handler (for production) - */ -function registerAppProtocolHandler() { - protocol.handle('app', (request) => { - const filePath = new URL(request.url).pathname - return new Response( - `Cannot handle app:// requests. Renderer should be served by electron-vite.` - ) + // Push maximize/unmaximize events to renderer + mainWindow.on('maximize', () => { + mainWindow?.webContents.send('electron:window-maximized') + }) + mainWindow.on('unmaximize', () => { + mainWindow?.webContents.send('electron:window-unmaximized') }) } /** * App event: when app is ready */ -app.on('ready', () => { - registerAppProtocolHandler() +app.whenReady().then(() => { + registerAppProtocol() + + // Set Content Security Policy + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + 'Content-Security-Policy': [ + "default-src 'self' app:; " + + "script-src 'self' 'unsafe-inline' app:; " + + "style-src 'self' 'unsafe-inline' app:; " + + "img-src 'self' app: data: blob:; " + + "font-src 'self' app: data:; " + + "connect-src 'self' app: ws: wss: http: https:; " + + "worker-src 'self' blob:;" + ], + }, + }) + }) + + registerIpcHandlers() createWindow() }) @@ -94,5 +192,4 @@ app.on('activate', () => { } }) -// Register protocol BEFORE app.whenReady() -registerAppProtocol() +registerAppScheme() diff --git a/dashboard/electron/main/protocol.ts b/dashboard/electron/main/protocol.ts new file mode 100644 index 00000000..0fb995a9 --- /dev/null +++ b/dashboard/electron/main/protocol.ts @@ -0,0 +1,89 @@ +import { net, protocol } from 'electron' +import { readFile } from 'fs/promises' +import { dirname, extname, join } from 'path' +import { fileURLToPath } from 'url' + +import { getActiveBackend } from './store' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const MIME_TYPES: Record = { + '.html': 'text/html', + '.js': 'application/javascript', + '.mjs': 'application/javascript', + '.cjs': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.txt': 'text/plain', + '.webp': 'image/webp', +} + +export function registerAppProtocol(): void { + protocol.handle('app', async (request) => { + const url = new URL(request.url) + const pathname = url.pathname + + if (pathname.startsWith('/api/')) { + const backend = getActiveBackend() + const targetUrl = backend + ? `${backend.url.replace(/\/$/, '')}${pathname}${url.search}` + : null + + if (!targetUrl) { + return new Response(JSON.stringify({ error: 'No backend configured' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const headers = new Headers(request.headers) + headers.delete('host') + + return net.fetch(targetUrl, { + method: request.method, + headers, + body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body, + duplex: 'half', + }) + } + + // Dev mode: renderer is served by vite dev server, not app:// protocol + if (process.env.ELECTRON_RENDERER_URL) { + return new Response(null, { status: 204 }) + } + + const rendererDir = join(__dirname, '../renderer') + const safePath = decodeURIComponent(pathname) + .replace(/\.\./g, '') + .replace(/^\/+/, '') + + const resolvedPath = safePath === '' ? 'index.html' : safePath + const filePath = resolvedPath.endsWith('/') + ? join(rendererDir, resolvedPath, 'index.html') + : join(rendererDir, resolvedPath) + + const tryReadFile = async (path: string) => { + const ext = extname(path) + const mimeType = MIME_TYPES[ext] ?? 'application/octet-stream' + const data = await readFile(path) + return new Response(data, { headers: { 'Content-Type': mimeType } }) + } + + try { + return await tryReadFile(filePath) + } catch { + const indexPath = join(rendererDir, 'index.html') + return tryReadFile(indexPath) + } + }) +} diff --git a/dashboard/electron/main/store.ts b/dashboard/electron/main/store.ts new file mode 100644 index 00000000..65a810df --- /dev/null +++ b/dashboard/electron/main/store.ts @@ -0,0 +1,215 @@ +import { randomUUID } from 'crypto' + +import Store from 'electron-store' + +/** + * Backend connection data model + */ +export interface BackendConnection { + id: string + name: string + url: string + isDefault: boolean + lastConnected?: number +} + +/** + * Application settings data model + */ +export interface AppSettings { + backends: BackendConnection[] + activeBackendId: string | null + windowBounds: { + x: number + y: number + width: number + height: number + } + firstLaunchComplete: boolean +} + +/** + * JSON Schema for validating store contents + */ +const SCHEMA: Store.Schema = { + backends: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + url: { type: 'string' }, + isDefault: { type: 'boolean' }, + lastConnected: { type: 'number' }, + }, + required: ['id', 'name', 'url', 'isDefault'], + }, + }, + activeBackendId: { type: ['string', 'null'] }, + windowBounds: { + type: 'object', + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + width: { type: 'number' }, + height: { type: 'number' }, + }, + required: ['x', 'y', 'width', 'height'], + }, + firstLaunchComplete: { type: 'boolean' }, +} + +/** + * Default settings + */ +const DEFAULTS: AppSettings = { + backends: [], + activeBackendId: null, + windowBounds: { + x: 100, + y: 100, + width: 1280, + height: 800, + }, + firstLaunchComplete: false, +} + +/** + * Initialize electron-store with encryption and schema validation + */ +const store = new Store({ + schema: SCHEMA, + defaults: DEFAULTS, + encryptionKey: process.env.MAIBOT_STORE_KEY, +}) + +/** + * Get all backends + */ +export function getBackends(): BackendConnection[] { + return store.get('backends', []) +} + +/** + * Add a new backend connection + * Generates UUID for new backend + */ +export function addBackend( + conn: Omit, +): BackendConnection { + const newBackend: BackendConnection = { + ...conn, + id: randomUUID(), + } + + const backends = getBackends() + backends.push(newBackend) + store.set('backends', backends) + + return newBackend +} + +/** + * Update an existing backend connection + */ +export function updateBackend( + id: string, + patch: Partial>, +): void { + const backends = getBackends() + const index = backends.findIndex((b) => b.id === id) + + if (index === -1) { + throw new Error(`Backend with id ${id} not found`) + } + + backends[index] = { + ...backends[index], + ...patch, + } + + store.set('backends', backends) +} + +/** + * Remove a backend connection by id + */ +export function removeBackend(id: string): void { + const backends = getBackends() + const filtered = backends.filter((b) => b.id !== id) + + store.set('backends', filtered) + + // Clear active backend if it was the removed one + if (store.get('activeBackendId') === id) { + store.set('activeBackendId', null) + } +} + +/** + * Set the active backend + */ +export function setActiveBackend(id: string): void { + const backends = getBackends() + + if (!backends.find((b) => b.id === id)) { + throw new Error(`Backend with id ${id} not found`) + } + + store.set('activeBackendId', id) +} + +/** + * Get the currently active backend connection + */ +export function getActiveBackend(): BackendConnection | null { + const activeId = store.get('activeBackendId') + + if (!activeId) { + return null + } + + const backends = getBackends() + return backends.find((b) => b.id === activeId) || null +} + +/** + * Get window bounds + */ +export function getWindowBounds(): AppSettings['windowBounds'] { + return store.get('windowBounds', DEFAULTS.windowBounds) +} + +/** + * Set window bounds + */ +export function setWindowBounds(bounds: AppSettings['windowBounds']): void { + store.set('windowBounds', bounds) +} + +/** + * Check if this is the first launch + */ +export function isFirstLaunch(): boolean { + return !store.get('firstLaunchComplete', false) +} + +/** + * Mark first launch as complete + */ +export function markFirstLaunchComplete(): void { + store.set('firstLaunchComplete', true) +} + +/** + * Get complete app settings + */ +export function getSettings(): AppSettings { + return { + backends: getBackends(), + activeBackendId: store.get('activeBackendId', null), + windowBounds: getWindowBounds(), + firstLaunchComplete: store.get('firstLaunchComplete', false), + } +} diff --git a/dashboard/electron/preload/index.ts b/dashboard/electron/preload/index.ts index 39ec94b6..4f950e24 100644 --- a/dashboard/electron/preload/index.ts +++ b/dashboard/electron/preload/index.ts @@ -1,7 +1,56 @@ -/** - * Preload script for Electron renderer process - * This script runs in the main process before renderer loads - * Use contextBridge to safely expose APIs - * - * Wave 2 implementation will expose specific IPC methods here - */ +import { contextBridge, ipcRenderer } from 'electron' + +// Write __RUNTIME__ tag into the isolated world so renderer can detect Electron +contextBridge.exposeInMainWorld('__RUNTIME__', { + kind: 'electron' as const, + versions: process.versions as unknown as Record, + source: 'tag' as const, +}) + +// Expose the full ElectronAPI surface to the renderer process +contextBridge.exposeInMainWorld('electronAPI', { + // ── Platform detection ────────────────────────────────────────────────── + getPlatform: () => process.platform, + + // ── Window control ────────────────────────────────────────────────────── + minimizeWindow: () => ipcRenderer.invoke('electron:minimize-window'), + maximizeWindow: () => ipcRenderer.invoke('electron:maximize-window'), + closeWindow: () => ipcRenderer.invoke('electron:close-window'), + isMaximized: () => ipcRenderer.invoke('electron:is-maximized'), + + // ── Window event listeners ─────────────────────────────────────────────── + onWindowMaximized: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on('electron:window-maximized', listener) + return () => ipcRenderer.removeListener('electron:window-maximized', listener) + }, + onWindowUnmaximized: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on('electron:window-unmaximized', listener) + return () => ipcRenderer.removeListener('electron:window-unmaximized', listener) + }, + + // ── Backend CRUD ───────────────────────────────────────────────────────── + getBackends: () => ipcRenderer.invoke('electron:get-backends'), + addBackend: (conn: object) => ipcRenderer.invoke('electron:add-backend', conn), + updateBackend: (id: string, patch: object) => + ipcRenderer.invoke('electron:update-backend', id, patch), + removeBackend: (id: string) => ipcRenderer.invoke('electron:remove-backend', id), + setActiveBackend: (id: string) => + ipcRenderer.invoke('electron:set-active-backend', id), + getActiveBackend: () => ipcRenderer.invoke('electron:get-active-backend'), + getActiveBackendUrl: () => ipcRenderer.invoke('electron:get-active-url'), + + // ── App state ─────────────────────────────────────────────────────────── + isFirstLaunch: () => ipcRenderer.invoke('electron:is-first-launch'), + markFirstLaunchComplete: () => + ipcRenderer.invoke('electron:mark-first-launch-complete'), + getAppVersion: () => ipcRenderer.invoke('electron:get-app-version'), + + // ── Backend event listener ────────────────────────────────────────────── + onBackendChanged: (callback: (backend: { id: string; name: string; url: string; isDefault: boolean; lastConnected?: number } | null) => void) => { + const listener = (_event: unknown, backend: { id: string; name: string; url: string; isDefault: boolean; lastConnected?: number } | null) => callback(backend) + ipcRenderer.on('electron:backend-changed', listener) + return () => ipcRenderer.removeListener('electron:backend-changed', listener) + }, +}) diff --git a/dashboard/electron/resources/.gitkeep b/dashboard/electron/resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/dashboard/package.json b/dashboard/package.json index c30afc0a..119b2d27 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -14,7 +14,56 @@ "test:ui": "vitest --ui", "electron:dev": "electron-vite dev", "electron:build": "electron-vite build", - "electron:preview": "electron-vite preview" + "electron:preview": "electron-vite preview", + "electron:dist": "electron-vite build && electron-builder", + "electron:dist:mac": "electron-vite build && electron-builder --mac", + "electron:dist:win": "electron-vite build && electron-builder --win", + "electron:dist:linux": "electron-vite build && electron-builder --linux" + }, + "build": { + "appId": "org.maibot.dashboard", + "productName": "MaiBot Dashboard", + "directories": { + "output": "dist-electron", + "buildResources": "electron/resources" + }, + "files": [ + "out/**/*", + "package.json" + ], + "mac": { + "category": "public.app-category.utilities", + "target": [ + { "target": "dmg", "arch": ["x64", "arm64"] } + ], + "icon": "electron/resources/icon.icns", + "darkModeSupport": true + }, + "win": { + "target": [ + { "target": "nsis", "arch": ["x64"] } + ], + "icon": "electron/resources/icon.ico" + }, + "linux": { + "target": [ + { "target": "AppImage", "arch": ["x64"] } + ], + "icon": "electron/resources/icon.png", + "category": "Utility" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true + }, + "dmg": { + "contents": [ + { "x": 130, "y": 220 }, + { "x": 410, "y": 220, "type": "link", "path": "/Applications" } + ] + } }, "dependencies": { "@codemirror/lang-css": "^6.3.1", @@ -94,6 +143,7 @@ "autoprefixer": "^10.4.22", "electron": "^40.6.1", "electron-builder": "^26.8.1", + "electron-store": "^8.1.0", "electron-vite": "^5.0.0", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/dashboard/src/lib/api-base.ts b/dashboard/src/lib/api-base.ts new file mode 100644 index 00000000..8d066829 --- /dev/null +++ b/dashboard/src/lib/api-base.ts @@ -0,0 +1,119 @@ +/** + * Centralized API base URL utility + * Provides single source of truth for all URL construction across the application + * Handles environment-specific configuration (Electron, Browser DEV, Browser PROD) + */ + +import type { BackendConnection } from '@/types/electron' + +import { isElectron } from './runtime' + +/** + * Get API base URL for HTTP/HTTPS requests + * - Electron: User-configured backend URL from main process + * - Browser DEV: Empty string (Vite proxy handles /api prefix) + * - Browser PROD: Empty string (same-origin deployment) + */ +export async function getApiBaseUrl(): Promise { + if (isElectron()) { + // Electron: Get configured backend URL from IPC + const backendUrl = await window.electronAPI?.getActiveBackendUrl() + return backendUrl ?? '' + } + + // Browser (DEV & PROD): Return empty string + // In DEV: Vite proxy forwards /api requests to backend + // In PROD: API is deployed on same origin as frontend + return '' +} + +/** + * Get WebSocket base URL + * - Electron: Convert HTTP/HTTPS URL to WS/WSS + * - Browser DEV: ws://127.0.0.1:8001 (hardcoded, same as log-websocket.ts) + * - Browser PROD: Construct WS URL from window.location + */ +export async function getWsBaseUrl(): Promise { + if (isElectron()) { + // Electron: Convert API URL protocol to WS protocol + const apiUrl = await getApiBaseUrl() + if (!apiUrl) { + return '' + } + + // Convert http -> ws, https -> wss + return apiUrl.replace(/^https?/, (match) => { + return match === 'https' ? 'wss' : 'ws' + }) + } + + // Browser DEV: Use hardcoded WebSocket server + if (import.meta.env.DEV) { + return 'ws://127.0.0.1:8001' + } + + // Browser PROD: Construct WS URL from current location + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + return `${protocol}//${host}` +} + +/** + * Get synchronous API base URL for axios baseURL configuration + * Note: axios instance baseURL is set at module initialization time (synchronous). + * Since window.electronAPI.getActiveBackendUrl() is async, this function returns + * empty string. The actual Electron backend URL will be injected via axios request + * interceptor (Task 7) to support dynamic backend switching at runtime. + */ +export function getAxiosBaseUrl(): string { + // Always return empty string: + // - Browser: Vite proxy / same-origin handles paths + // - Electron: axios interceptor injects dynamic baseURL + return '' +} + +/** + * Resolve full API path by prepending base URL if needed + * - Electron: Prepends configured backend URL + * - Browser: Path remains unchanged (proxy/same-origin handling) + */ +export async function resolveApiPath(path: string): Promise { + if (isElectron()) { + const baseUrl = await getApiBaseUrl() + return baseUrl ? `${baseUrl}${path}` : path + } + + // Browser: Path is used as-is + return path +} + +/** + * Subscribe to backend URL changes + * Electron: Listens to IPC backend change events + * Browser: No-op (backend cannot change at runtime) + * + * @param callback Function called when backend URL changes + * @returns Unsubscribe function + */ +export function onBackendUrlChanged( + callback: (newUrl: string | null) => void +): () => void { + if (!isElectron()) { + // Browser: No-op, return empty unsubscribe function + return () => {} + } + + // Electron: Register IPC listener and return unsubscribe function + if (!window.electronAPI?.onBackendChanged) { + return () => {} + } + + // Wrap callback to extract URL from BackendConnection + const wrappedCallback = (backend: BackendConnection | null) => { + const url = backend?.url ?? null + callback(url) + } + + // Get and return the unsubscribe function from preload + return window.electronAPI.onBackendChanged(wrappedCallback) +} diff --git a/dashboard/src/lib/runtime.ts b/dashboard/src/lib/runtime.ts new file mode 100644 index 00000000..b6d781c1 --- /dev/null +++ b/dashboard/src/lib/runtime.ts @@ -0,0 +1,77 @@ +/** + * Runtime environment detection and information + * Provides unified interface for checking execution environment (Electron vs Browser) + */ + +/** + * Type of runtime environment + */ +export type RuntimeKind = 'electron' | 'browser' + +/** + * Runtime information object + */ +export interface RuntimeInfo { + /** Type of runtime (electron or browser) */ + kind: RuntimeKind + /** Version information (electron versions, etc) */ + versions?: Record + /** User agent string */ + userAgent?: string + /** Source of runtime detection (tag means set by preload, fallback means default for browser) */ + source: 'tag' | 'fallback' +} + +/** + * Build default browser runtime info + * Used as fallback when not in Electron environment + */ +function buildBrowserRuntime(): RuntimeInfo { + return { + kind: 'browser', + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined, + source: 'fallback', + } +} + +/** + * Get current runtime information + * Reads from globalThis.__RUNTIME__ if available (set by Electron preload) + * Falls back to browser runtime if not running in Electron + */ +export function getRuntime(): RuntimeInfo { + // Check if running in Electron (preload sets __RUNTIME__) + if (typeof globalThis !== 'undefined' && globalThis.__RUNTIME__) { + return globalThis.__RUNTIME__ + } + + // Fallback to browser runtime + return buildBrowserRuntime() +} + +/** + * Check if running in Electron environment + * Safe to use across browser and Electron - always returns boolean + */ +export function isElectron(): boolean { + return getRuntime().kind === 'electron' +} + +/** + * Get platform information + * In Electron: calls window.electronAPI.getPlatform() for actual platform + * In browser: returns 'browser' as identifier + */ +export function getPlatform(): string { + if (!isElectron()) { + return 'browser' + } + + // Safe to access electronAPI because isElectron() confirms it's available + if (typeof window !== 'undefined' && window.electronAPI?.getPlatform) { + return window.electronAPI.getPlatform() + } + + // Fallback if electronAPI unavailable + return 'unknown' +} diff --git a/dashboard/src/types/electron.d.ts b/dashboard/src/types/electron.d.ts new file mode 100644 index 00000000..ca042cdc --- /dev/null +++ b/dashboard/src/types/electron.d.ts @@ -0,0 +1,95 @@ +/** + * Electron API type definitions + * Declares Window.electronAPI and globalThis.__RUNTIME__ for frontend use + */ + +import type { RuntimeInfo } from '@/lib/runtime' + +/** + * Backend connection configuration + */ +export interface BackendConnection { + /** Unique identifier */ + id: string + /** Display name */ + name: string + /** Connection URL */ + url: string + /** Whether this is the default backend */ + isDefault: boolean + /** Last connection timestamp */ + lastConnected?: number +} + +/** + * Electron IPC API exposed to renderer process + * All methods communicate via IPC bridges to main process + */ +export interface ElectronAPI { + // Window control + /** Minimize the application window */ + minimizeWindow(): void + /** Maximize the application window */ + maximizeWindow(): void + /** Close the application window */ + closeWindow(): void + /** Check if window is currently maximized */ + isMaximized(): Promise + + // Window event listeners + /** Register callback for window maximized event */ + onWindowMaximized(callback: () => void): () => void + /** Register callback for window unmaximized event */ + onWindowUnmaximized(callback: () => void): () => void + + // Backend management + /** Get list of all configured backends */ + getBackends(): Promise + /** Add a new backend connection */ + addBackend(conn: Omit): Promise + /** Update an existing backend configuration */ + updateBackend(id: string, patch: Partial): Promise + /** Remove a backend by ID */ + removeBackend(id: string): Promise + /** Set the active backend */ + setActiveBackend(id: string): Promise + /** Get the currently active backend */ + getActiveBackend(): Promise + /** Get the active backend's URL for API requests */ + getActiveBackendUrl(): Promise + + // Application state + /** Mark that first-launch setup has been completed */ + markFirstLaunchComplete(): Promise + /** Check if this is the first launch */ + isFirstLaunch(): Promise + /** Get application version */ + getAppVersion(): Promise + + // Backend event listener + /** Register callback for backend change events */ + onBackendChanged(callback: (backend: BackendConnection | null) => void): () => void + + // Platform detection + /** Get platform identifier (darwin, win32, linux) */ + getPlatform(): string +} + +// Extend Window interface to include electronAPI +declare global { + interface Window { + /** Electron API bridge for main process communication */ + electronAPI?: ElectronAPI + } + + /** + * Global runtime information + * Set by Electron preload, undefined in browser + */ + namespace globalThis { + var __RUNTIME__: RuntimeInfo | undefined + } +} + +// Ensure this file is treated as a module +export {}