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 react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'electron-vite'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
entry: 'electron/main/index.ts',
|
entry: 'electron/main/index.ts',
|
||||||
vite: {
|
build: {
|
||||||
build: {
|
target: 'node18',
|
||||||
rollupOptions: {
|
lib: {
|
||||||
external: ['electron'],
|
entry: 'electron/main/index.ts',
|
||||||
},
|
|
||||||
},
|
},
|
||||||
resolve: {
|
rollupOptions: {
|
||||||
alias: {
|
external: ['electron', 'electron-store'],
|
||||||
'@': path.resolve(__dirname, './src'),
|
},
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
entry: 'electron/preload/index.ts',
|
entry: 'electron/preload/index.ts',
|
||||||
vite: {
|
build: {
|
||||||
build: {
|
target: 'node18',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['electron'],
|
input: path.resolve(__dirname, 'electron/preload/index.ts'),
|
||||||
|
output: {
|
||||||
|
entryFileNames: '[name].js',
|
||||||
|
format: 'cjs',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
|
root: '.',
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
@@ -49,15 +55,13 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
input: path.resolve(__dirname, 'index.html'),
|
||||||
output: {
|
output: {
|
||||||
manualChunks: {
|
manualChunks: {
|
||||||
// React core
|
|
||||||
'react-vendor': ['react', 'react-dom', 'react/jsx-runtime'],
|
'react-vendor': ['react', 'react-dom', 'react/jsx-runtime'],
|
||||||
|
|
||||||
// TanStack Router
|
|
||||||
router: ['@tanstack/react-router', '@tanstack/react-virtual'],
|
router: ['@tanstack/react-router', '@tanstack/react-virtual'],
|
||||||
|
|
||||||
// Radix UI core
|
|
||||||
'radix-core': [
|
'radix-core': [
|
||||||
'@radix-ui/react-dialog',
|
'@radix-ui/react-dialog',
|
||||||
'@radix-ui/react-select',
|
'@radix-ui/react-select',
|
||||||
@@ -67,8 +71,6 @@ export default defineConfig({
|
|||||||
'@radix-ui/react-toast',
|
'@radix-ui/react-toast',
|
||||||
'@radix-ui/react-tooltip',
|
'@radix-ui/react-tooltip',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Radix UI extras
|
|
||||||
'radix-extra': [
|
'radix-extra': [
|
||||||
'@radix-ui/react-alert-dialog',
|
'@radix-ui/react-alert-dialog',
|
||||||
'@radix-ui/react-avatar',
|
'@radix-ui/react-avatar',
|
||||||
@@ -83,13 +85,10 @@ export default defineConfig({
|
|||||||
'@radix-ui/react-tabs',
|
'@radix-ui/react-tabs',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Icons
|
|
||||||
icons: ['lucide-react'],
|
icons: ['lucide-react'],
|
||||||
|
|
||||||
// Charts
|
|
||||||
charts: ['recharts'],
|
charts: ['recharts'],
|
||||||
|
|
||||||
// CodeMirror
|
|
||||||
codemirror: [
|
codemirror: [
|
||||||
'@uiw/react-codemirror',
|
'@uiw/react-codemirror',
|
||||||
'@codemirror/lang-javascript',
|
'@codemirror/lang-javascript',
|
||||||
@@ -99,10 +98,8 @@ export default defineConfig({
|
|||||||
'@codemirror/theme-one-dark',
|
'@codemirror/theme-one-dark',
|
||||||
],
|
],
|
||||||
|
|
||||||
// ReactFlow
|
|
||||||
reactflow: ['reactflow', 'dagre'],
|
reactflow: ['reactflow', 'dagre'],
|
||||||
|
|
||||||
// Markdown
|
|
||||||
markdown: [
|
markdown: [
|
||||||
'react-markdown',
|
'react-markdown',
|
||||||
'remark-gfm',
|
'remark-gfm',
|
||||||
@@ -111,7 +108,6 @@ export default defineConfig({
|
|||||||
'katex',
|
'katex',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Uppy
|
|
||||||
uppy: [
|
uppy: [
|
||||||
'@uppy/core',
|
'@uppy/core',
|
||||||
'@uppy/dashboard',
|
'@uppy/dashboard',
|
||||||
@@ -119,14 +115,8 @@ export default defineConfig({
|
|||||||
'@uppy/xhr-upload',
|
'@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: [
|
utils: [
|
||||||
'date-fns',
|
'date-fns',
|
||||||
'clsx',
|
'clsx',
|
||||||
@@ -135,12 +125,7 @@ export default defineConfig({
|
|||||||
'axios',
|
'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 path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
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 __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
@@ -11,67 +25,151 @@ let mainWindow: BrowserWindow | null = null
|
|||||||
* Register app:// custom protocol BEFORE app.whenReady()
|
* Register app:// custom protocol BEFORE app.whenReady()
|
||||||
* This is critical for electron-vite to work correctly
|
* This is critical for electron-vite to work correctly
|
||||||
*/
|
*/
|
||||||
function registerAppProtocol() {
|
function registerAppScheme() {
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{
|
{
|
||||||
scheme: 'app',
|
scheme: 'app',
|
||||||
privileges: {
|
privileges: {
|
||||||
|
corsEnabled: true,
|
||||||
secure: true,
|
secure: true,
|
||||||
standard: true,
|
|
||||||
allowServiceWorkers: 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
|
* Create the main application window
|
||||||
*/
|
*/
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
|
const isMac = process.platform === 'darwin'
|
||||||
|
|
||||||
|
// Restore window bounds from store
|
||||||
|
const bounds = getWindowBounds()
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1200,
|
x: bounds.x,
|
||||||
height: 800,
|
y: bounds.y,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
minWidth: 800,
|
minWidth: 800,
|
||||||
minHeight: 600,
|
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: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, '../preload/index.js'),
|
preload: path.join(__dirname, '../preload/index.js'),
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load the app using app:// protocol
|
// Load the app using app:// protocol
|
||||||
// electron-vite will handle serving the renderer from app://host/index.html
|
// 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
|
// Development: load from electron-vite dev server
|
||||||
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
|
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||||
} else {
|
} else {
|
||||||
// Production: load from bundled renderer
|
// Production: load from bundled renderer
|
||||||
mainWindow.loadURL('app://host/index.html')
|
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.on('closed', () => {
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Push maximize/unmaximize events to renderer
|
||||||
* Register app:// protocol handler (for production)
|
mainWindow.on('maximize', () => {
|
||||||
*/
|
mainWindow?.webContents.send('electron:window-maximized')
|
||||||
function registerAppProtocolHandler() {
|
})
|
||||||
protocol.handle('app', (request) => {
|
mainWindow.on('unmaximize', () => {
|
||||||
const filePath = new URL(request.url).pathname
|
mainWindow?.webContents.send('electron:window-unmaximized')
|
||||||
return new Response(
|
|
||||||
`Cannot handle app:// requests. Renderer should be served by electron-vite.`
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App event: when app is ready
|
* App event: when app is ready
|
||||||
*/
|
*/
|
||||||
app.on('ready', () => {
|
app.whenReady().then(() => {
|
||||||
registerAppProtocolHandler()
|
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()
|
createWindow()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,5 +192,4 @@ app.on('activate', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Register protocol BEFORE app.whenReady()
|
registerAppScheme()
|
||||||
registerAppProtocol()
|
|
||||||
|
|||||||
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 @@
|
|||||||
/**
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
* Preload script for Electron renderer process
|
|
||||||
* This script runs in the main process before renderer loads
|
// Write __RUNTIME__ tag into the isolated world so renderer can detect Electron
|
||||||
* Use contextBridge to safely expose APIs
|
contextBridge.exposeInMainWorld('__RUNTIME__', {
|
||||||
*
|
kind: 'electron' as const,
|
||||||
* Wave 2 implementation will expose specific IPC methods here
|
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",
|
"test:ui": "vitest --ui",
|
||||||
"electron:dev": "electron-vite dev",
|
"electron:dev": "electron-vite dev",
|
||||||
"electron:build": "electron-vite build",
|
"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": {
|
"dependencies": {
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
@@ -94,6 +143,7 @@
|
|||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"electron": "^40.6.1",
|
"electron": "^40.6.1",
|
||||||
"electron-builder": "^26.8.1",
|
"electron-builder": "^26.8.1",
|
||||||
|
"electron-store": "^8.1.0",
|
||||||
"electron-vite": "^5.0.0",
|
"electron-vite": "^5.0.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.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