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