181 lines
6.8 KiB
TypeScript
181 lines
6.8 KiB
TypeScript
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()
|
||
}
|