import { app, BrowserWindow, ipcMain, Menu, nativeTheme, protocol, shell } from 'electron' import { existsSync } from 'node:fs' import { join } from 'node:path' import { registerIpc } from './ipc' import { getPluginHost } from './plugins/plugin-host' import { getBrowserController } from './browser/browser-controller' import { getLogger, initLogger } from './logger' initLogger() const log = getLogger('main') process.env.APP_ROOT = join(__dirname, '..') // 外置插件页的特权协议(必须在 app ready 之前注册): // - plugin:/// → userData/plugins// 内文件(宿主做路径穿越防护) // - peer://assets/tokens.css → 主题 token 样式表(插件页 引用,保证深浅色/accent 连续) protocol.registerSchemesAsPrivileged([ { scheme: 'plugin', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } }, { scheme: 'peer', privileges: { standard: true, secure: true, supportFetchAPI: true } } ]) export const VITE_DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL ?? process.env.VITE_DEV_SERVER_URL export const RENDERER_DIST = join(process.env.APP_ROOT, 'renderer') /** 应用图标(build/icon.ico,由 scripts/make-icons.cjs 从 build/icon.svg 生成) */ const APP_ICON = join(app.getAppPath(), 'build', 'icon.ico') const agentService = registerIpc() log.info('main init', { version: app.getVersion(), packaged: app.isPackaged, devServer: VITE_DEV_SERVER_URL ?? '-' }) /** 窗口控制(自绘标题栏按钮走这三个 IPC;不使用系统 WCO, * 否则 overlay 的原生白色背景会盖住标题栏分隔线) */ function registerWindowControls(): void { ipcMain.handle('win:minimize', (event) => { BrowserWindow.fromWebContents(event.sender)?.minimize() }) ipcMain.handle('win:toggleMaximize', (event) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return if (win.isMaximized()) win.unmaximize() else win.maximize() }) ipcMain.handle('win:close', (event) => { BrowserWindow.fromWebContents(event.sender)?.close() }) } async function createWindow(): Promise { // 窗口底色跟随深浅色(加载/resize 时不闪白);构造函数里已按持久化偏好设置过 // nativeTheme.themeSource,这里用实际生效值 const dark = nativeTheme.shouldUseDarkColors const win = new BrowserWindow({ width: 1280, height: 840, minWidth: 960, minHeight: 640, show: false, backgroundColor: dark ? '#212121' : '#ffffff', title: 'Inkreach Agent', // 窗口/任务栏图标(Windows/Linux;macOS 打包时由 build 配置提供 icns) ...(existsSync(APP_ICON) ? { icon: APP_ICON } : {}), // 无原生标题栏/菜单栏,标题栏与窗口控制按钮全部由渲染层 TitleBar 自绘 titleBarStyle: 'hidden', webPreferences: { preload: join(__dirname, '../preload/index.cjs'), sandbox: false, contextIsolation: true, nodeIntegration: false } }) // 深浅色切换时同步窗口底色(避免暗色下 resize/加载闪白块) nativeTheme.on('updated', () => { win.setBackgroundColor(nativeTheme.shouldUseDarkColors ? '#212121' : '#ffffff') }) // 最大化状态同步给渲染层(切换「最大化/还原」字形) const sendWinState = (): void => win.webContents.send('win:state', win.isMaximized()) win.on('maximize', sendWinState) win.on('unmaximize', sendWinState) // 默认应用菜单已移除(File/Edit/View 不再显示),F12 补回 DevTools 开关 win.webContents.on('before-input-event', (_event, input) => { if (input.type === 'keyDown' && input.key === 'F12') { win.webContents.toggleDevTools() } }) // ready-to-show 在 Electron 中可能不止一次触发(实测:回合中途首次挂载受管浏览器 // WebContentsView 后会再次发出)。会话初始化只做一次——但失败后放开门闩, // 让后续的重复触发承担重试职责(该事件至少会再来一次)。 let sessionInitialized = false win.on('ready-to-show', () => { win.show() if (sessionInitialized) return sessionInitialized = true // pi 会话初始化放在窗口就绪后,错误可以第一时间在 UI 上呈现 agentService.ensureReady().catch((err: unknown) => { sessionInitialized = false log.error('pi session init failed', { err }) }) }) // 受管浏览器的原生 WebContentsView 挂在主窗口 contentView 上 getBrowserController().setOwnerWindow(win) // 窗口关闭时未决审批一律拒绝,避免 agent 永久挂起;受管浏览器同步清理 win.on('closed', () => { agentService.approvals.settleAll(false) getBrowserController().handleWindowClosed() }) // 外部链接交给系统默认浏览器,不在应用内开新窗口 win.webContents.setWindowOpenHandler(({ url }) => { void shell.openExternal(url) return { action: 'deny' } }) // 渲染层日志(含插件 renderer 模块 / iframe 页的 console)统一归档到日志文件 win.webContents.on('console-message', (details) => { const level = details.level === 'error' ? 'error' : details.level === 'warning' ? 'warn' : 'debug' if (level === 'debug' && !VITE_DEV_SERVER_URL) return log[level]('renderer console', { message: details.message, source: `${details.sourceId}:${details.lineNumber}` }) }) win.webContents.on('did-fail-load', (_e, code, desc, url) => { log.error('renderer load failed', { code, desc, url }) }) win.webContents.on('render-process-gone', (_e, details) => { log.error('renderer gone', { reason: details.reason }) }) if (process.env.ASO_DEVTOOLS === '1') { win.webContents.openDevTools({ mode: 'detach' }) } if (VITE_DEV_SERVER_URL) { await win.loadURL(VITE_DEV_SERVER_URL) } else { await win.loadFile(join(RENDERER_DIST, 'index.html')) } } // 确保应用单实例(第二实例激活已有窗口) if (!app.requestSingleInstanceLock()) { app.quit() } else { app.on('second-instance', () => { const win = BrowserWindow.getAllWindows()[0] if (win) { if (win.isMinimized()) win.restore() win.focus() } }) void app.whenReady().then(() => { // Windows 任务栏归组的 AppUserModelID if (process.platform === 'win32') { app.setAppUserModelId('com.inkreach.agent') } Menu.setApplicationMenu(null) log.info('app ready') // 插件宿主:扫描/装载 + 协议映射 + 插件启停后重建 agent 运行时 const pluginHost = getPluginHost() pluginHost.init() pluginHost.registerProtocols() pluginHost.setRuntimeRebuilder(() => agentService.rebuildRuntime()) void createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) void createWindow() }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) registerWindowControls() }