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,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
Reference in New Issue
Block a user