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:
DrSmoothl
2026-03-03 00:52:53 +08:00
parent e6862e227b
commit b5cc361ce9
10 changed files with 843 additions and 67 deletions

View File

@@ -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'],
},
},
},

View File

@@ -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()

View 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)
}
})
}

View 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),
}
}

View File

@@ -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)
},
})

View File

View 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",

View 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)
}

View 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
View 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 {}