Files
inkreach-agent/apps/desktop/src/main/index.ts
T

181 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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://<id>/<file> → userData/plugins/<id>/ 内文件(宿主做路径穿越防护)
// - peer://assets/tokens.css → 主题 token 样式表(插件页 <link> 引用,保证深浅色/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<void> {
// 窗口底色跟随深浅色(加载/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()
}