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
This commit is contained in:
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
89
dashboard/electron/main/protocol.ts
Normal file
89
dashboard/electron/main/protocol.ts
Normal file
@@ -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<string, string> = {
|
||||
'.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)
|
||||
}
|
||||
})
|
||||
}
|
||||
215
dashboard/electron/main/store.ts
Normal file
215
dashboard/electron/main/store.ts
Normal file
@@ -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<AppSettings> = {
|
||||
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<AppSettings>({
|
||||
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, 'id'>,
|
||||
): 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<Omit<BackendConnection, 'id'>>,
|
||||
): 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),
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>,
|
||||
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)
|
||||
},
|
||||
})
|
||||
|
||||
0
dashboard/electron/resources/.gitkeep
Normal file
0
dashboard/electron/resources/.gitkeep
Normal file
@@ -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",
|
||||
|
||||
119
dashboard/src/lib/api-base.ts
Normal file
119
dashboard/src/lib/api-base.ts
Normal file
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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)
|
||||
}
|
||||
77
dashboard/src/lib/runtime.ts
Normal file
77
dashboard/src/lib/runtime.ts
Normal file
@@ -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<string, string>
|
||||
/** 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'
|
||||
}
|
||||
95
dashboard/src/types/electron.d.ts
vendored
Normal file
95
dashboard/src/types/electron.d.ts
vendored
Normal file
@@ -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<boolean>
|
||||
|
||||
// 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<BackendConnection[]>
|
||||
/** Add a new backend connection */
|
||||
addBackend(conn: Omit<BackendConnection, 'id'>): Promise<BackendConnection>
|
||||
/** Update an existing backend configuration */
|
||||
updateBackend(id: string, patch: Partial<BackendConnection>): Promise<void>
|
||||
/** Remove a backend by ID */
|
||||
removeBackend(id: string): Promise<void>
|
||||
/** Set the active backend */
|
||||
setActiveBackend(id: string): Promise<void>
|
||||
/** Get the currently active backend */
|
||||
getActiveBackend(): Promise<BackendConnection | null>
|
||||
/** Get the active backend's URL for API requests */
|
||||
getActiveBackendUrl(): Promise<string | null>
|
||||
|
||||
// Application state
|
||||
/** Mark that first-launch setup has been completed */
|
||||
markFirstLaunchComplete(): Promise<void>
|
||||
/** Check if this is the first launch */
|
||||
isFirstLaunch(): Promise<boolean>
|
||||
/** Get application version */
|
||||
getAppVersion(): Promise<string>
|
||||
|
||||
// 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 {}
|
||||
Reference in New Issue
Block a user