Files
inkreach-agent/apps/desktop/src/main/plugins/plugin-host.ts
T

561 lines
21 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.
/**
* 插件宿主(主进程):
* - 内置插件:代码注册(manifest + 第一方工厂 + 组件 key),编译进 bundle
* - 外置插件:userData/plugins/<id>、项目 .pi/extensions/<id>、全局 ~/.pi/agent/extensions/<id>
* (均为 manifest.json + 可选 main.mjs / entry.renderer / entry.page)
* - 同 id 高 semver 胜、平级先扫者胜(userData > 项目 > 全局);外置遮蔽组件级内置需
* manifest.replacesNative
* - 聚合为一个 InlineExtension 挂进 pi extensionFactories:插件工厂收到
* { pi, ui, storage, manifest },pi 为真实 ExtensionAPI(工具/事件/命令走原生路径)
* - 内联 UI(状态胶囊/工具栏按钮)数据存主进程(单一事实源),变更广播渲染层
* - plugin:// 协议把插件目录映射给 iframe 页与渲染层 renderer 模块(含路径穿越防护)
* - entry.renderer(渲染层 React 组件模块)在渲染层经动态 import 装载,React 用宿主实例
*
* 外置装载是 build 级开关:EXTERNAL_PLUGINS_ENABLED——开发版(未打包)开启,
* 用户版(打包)关闭(M5 ed25519 验签 + 开发者模式落地后再放开;QA 可用
* ASO_EXTERNAL_PLUGINS=1 豁免)。关闭时仅内置插件生效。
*
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
* ed25519 验签 + 开发者模式豁免)。
*/
import { app, BrowserWindow, net, protocol } from 'electron'
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { getAgentDir } from '@earendil-works/pi-coding-agent'
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
import type { HarnessPluginFactory, PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
export const HARNESS_CONTRACT_VERSION = '0.1.0'
/** 外置插件装载开关(build 级):开发版开启、用户版关闭;ASO_EXTERNAL_PLUGINS=1 强制开启 */
export const EXTERNAL_PLUGINS_ENABLED = !app.isPackaged || process.env.ASO_EXTERNAL_PLUGINS === '1'
interface ResolvedPlugin {
manifest: PluginManifest
source: 'builtin' | 'external'
/** 外置来源目录类别(管理页分组展示用) */
origin?: 'user' | 'project' | 'global'
/** 外置插件根目录(plugin:// 映射根) */
dir?: string
/** 内置:第一方工厂 / 渲染层组件 key */
builtinFactory?: HarnessPluginFactory
componentKey?: string
/** 外置 main.mjs 动态导入的工厂(reload 时替换) */
externalFactory?: HarnessPluginFactory
/** 装载/激活错误(管理页展示;不阻塞其他插件) */
error?: string
}
/** 最小 semver(x.y.z 数值三元组;忽略预发布段) */
function parseSemver(v: string): [number, number, number] | null {
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim())
if (!m) return null
return [Number(m[1]), Number(m[2]), Number(m[3])]
}
function compareSemver(a: string, b: string): number {
const pa = parseSemver(a)
const pb = parseSemver(b)
if (!pa || !pb) return 0
for (let i = 0; i < 3; i++) {
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1
}
return 0
}
class PluginHost {
private resolved = new Map<string, ResolvedPlugin>()
private disabled = new Set<string>()
/** 项目级扩展目录(.pi/extensions,随工作区切换;由 agent-service 同步) */
private projectExtensionsDir: string | null = null
/** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */
private rebuildRuntime: (() => Promise<void>) | null = null
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
private statusItems = new Map<string, Map<string, StatusItem>>()
private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>()
/** 插件 KV 存储(pluginId → key → value) */
private storage = new Map<string, Map<string, unknown>>()
private storageSaveTimer: NodeJS.Timeout | null = null
private get externalRoot(): string {
return join(app.getPath('userData'), 'plugins')
}
private get settingsFile(): string {
return join(app.getPath('userData'), 'aso-plugin-settings.json')
}
private get storageFile(): string {
return join(app.getPath('userData'), 'aso-plugin-storage.json')
}
// ---- 生命周期 ----
init(): void {
try {
mkdirSync(this.externalRoot, { recursive: true })
} catch {
// 已存在或创建失败都继续(扫描时再报)
}
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as { disabled?: unknown }
if (Array.isArray(raw.disabled)) this.disabled = new Set(raw.disabled.filter((d): d is string => typeof d === 'string'))
} catch {
// 无设置文件 = 全部启用
}
try {
const raw = JSON.parse(readFileSync(this.storageFile, 'utf8')) as Record<string, unknown>
for (const [pid, kv] of Object.entries(raw)) {
if (kv && typeof kv === 'object' && !Array.isArray(kv)) {
this.storage.set(pid, new Map(Object.entries(kv as Record<string, unknown>)))
}
}
} catch {
// 无存储文件 = 空
}
void this.scan()
}
setRuntimeRebuilder(fn: () => Promise<void>): void {
this.rebuildRuntime = fn
}
/** 同步项目级扩展目录(工作区切换时变更即重扫描;仅通知 UI,不重建 runtime) */
setProjectExtensionsDir(dir: string | null): void {
const next = dir ?? null
if ((this.projectExtensionsDir ?? null) === next) return
this.projectExtensionsDir = next
void this.scan()
}
/** 扫描内置注册表 + 外置目录并合并(不动已激活状态;reload 才重建)。
* 外置源优先级:userData/plugins > 项目 .pi/extensions > 全局 ~/.pi/agent/extensions。 */
async scan(): Promise<void> {
const byId = new Map<string, ResolvedPlugin>()
for (const builtin of BUILTIN_PLUGINS) {
byId.set(builtin.manifest.id, {
manifest: builtin.manifest,
source: 'builtin',
builtinFactory: builtin.main,
componentKey: builtin.component
})
}
if (EXTERNAL_PLUGINS_ENABLED) {
const roots: Array<{ root: string; origin: 'user' | 'project' | 'global' }> = [
{ root: this.externalRoot, origin: 'user' }
]
if (this.projectExtensionsDir) roots.push({ root: this.projectExtensionsDir, origin: 'project' })
roots.push({ root: join(getAgentDir(), 'extensions'), origin: 'global' })
for (const { root, origin } of roots) {
if (!existsSync(root)) continue
let entries: import('node:fs').Dirent[]
try {
entries = readdirSync(root, { withFileTypes: true })
} catch {
entries = []
}
for (const entry of entries) {
if (!entry.isDirectory()) continue
const dir = join(root, entry.name)
const manifest = this.readManifest(dir)
if (!manifest) continue
const existing = byId.get(manifest.id)
if (existing) {
const cmp = compareSemver(manifest.version, existing.manifest.version)
// 仅严格更高版本可遮蔽;平级先扫者胜
if (cmp <= 0) continue
// 组件级内置只能被显式声明降级替代的外置遮蔽
if (existing.componentKey && !manifest.replacesNative) {
existing.error = `外置 ${manifest.version} 未声明 replacesNative,继续使用内置 ${existing.manifest.version}`
continue
}
}
if (compareSemver(manifest.minContract, HARNESS_CONTRACT_VERSION) > 0) {
byId.set(manifest.id, {
manifest,
source: 'external',
dir,
origin,
error: `需要契约版本 >= ${manifest.minContract}(当前 ${HARNESS_CONTRACT_VERSION})`
})
continue
}
byId.set(manifest.id, { manifest, source: 'external', dir, origin })
}
}
}
this.resolved = byId
await this.loadExternalMains()
this.send('plugins:listChanged', null)
}
private readManifest(dir: string): PluginManifest | null {
const file = join(dir, 'manifest.json')
try {
const raw = JSON.parse(readFileSync(file, 'utf8')) as Partial<PluginManifest>
if (typeof raw.id !== 'string' || typeof raw.name !== 'string' || typeof raw.version !== 'string') return null
if (!/^[a-z][a-z0-9-]*$/.test(raw.id)) return null
if (!parseSemver(raw.version) || !parseSemver(raw.minContract ?? '0.0.0')) return null
return {
id: raw.id,
name: raw.name,
version: raw.version,
minContract: raw.minContract ?? '0.0.0',
description: typeof raw.description === 'string' ? raw.description : undefined,
icon: typeof raw.icon === 'string' ? raw.icon : undefined,
order: typeof raw.order === 'number' ? raw.order : undefined,
panel: raw.panel === true,
entry:
raw.entry && typeof raw.entry === 'object'
? {
page: typeof raw.entry.page === 'string' ? raw.entry.page : undefined,
main: typeof raw.entry.main === 'string' ? raw.entry.main : undefined,
renderer: typeof raw.entry.renderer === 'string' ? raw.entry.renderer : undefined
}
: undefined,
replacesNative: raw.replacesNative === true,
updateURL: typeof raw.updateURL === 'string' ? raw.updateURL : undefined
}
} catch {
return null
}
}
/** 动态导入外置 main.mjs(cache-bust 保证重载拿到新代码) */
private async loadExternalMains(): Promise<void> {
for (const plugin of this.resolved.values()) {
if (plugin.source !== 'external' || !plugin.manifest.entry?.main || plugin.error) continue
const file = resolve(plugin.dir!, plugin.manifest.entry.main)
try {
const url = pathToFileURL(file).href + `?t=${Date.now()}`
const mod = (await import(url)) as { default?: unknown }
if (typeof mod.default !== 'function') {
plugin.error = 'main.mjs 默认导出必须是插件工厂函数'
continue
}
plugin.externalFactory = mod.default as HarnessPluginFactory
plugin.error = undefined
} catch (err) {
plugin.externalFactory = undefined
plugin.error = `main 加载失败: ${err instanceof Error ? err.message : String(err)}`
}
}
}
/** 重新扫描 + 重新导入 + 清 UI 状态 + 重建 runtime(= pi /reload 等效) */
async reload(): Promise<void> {
this.statusItems.clear()
this.toolbarButtons.clear()
await this.scan()
this.send('plugins:uiState', this.uiState())
if (this.rebuildRuntime) await this.rebuildRuntime()
}
async setEnabled(id: string, enabled: boolean): Promise<void> {
if (enabled) this.disabled.delete(id)
else this.disabled.add(id)
try {
writeFileSync(this.settingsFile, JSON.stringify({ disabled: [...this.disabled] }, null, 2), 'utf8')
} catch (err) {
console.error('[plugins] save settings failed:', err)
}
await this.reload()
}
// ---- 查询 ----
isEnabled(id: string): boolean {
return !this.disabled.has(id)
}
list(): PluginInfo[] {
return [...this.resolved.values()]
.map((p) => ({
manifest: p.manifest,
source: p.source,
origin: p.origin,
enabled: this.isEnabled(p.manifest.id),
hasMain: Boolean(p.builtinFactory ?? p.externalFactory),
hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page),
hasComponent: Boolean(p.componentKey),
hasRenderer: Boolean(p.source === 'external' && p.manifest.entry?.renderer),
error: p.error
}))
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
}
getComponentKey(id: string): string | undefined {
const p = this.resolved.get(id)
return p?.componentKey
}
/** 外置插件来源目录(管理页「打开目录」用) */
pluginDirs(): { user: string[]; project: string[]; global: string[] } {
return {
user: [this.externalRoot],
project: this.projectExtensionsDir ? [this.projectExtensionsDir] : [],
global: [join(getAgentDir(), 'extensions')]
}
}
/** 读取外置插件渲染层模块源码。渲染层取回后以 Blob URL import——
* dev 下页面源是 http://localhost,直接 import(plugin://) 会被 CORS 拦截。 */
readRendererSource(id: string): string {
if (!EXTERNAL_PLUGINS_ENABLED) throw new Error('第三方插件装载已关闭(用户版构建)')
const plugin = this.resolved.get(id)
const file =
plugin?.source === 'external' && plugin.manifest.entry?.renderer
? resolve(plugin.dir!, plugin.manifest.entry.renderer)
: null
if (!file || !existsSync(file)) throw new Error(`插件 ${id} 未提供渲染层模块`)
if (statSync(file).size > 8 * 1024 * 1024) throw new Error('渲染层模块超过 8MB 上限')
return readFileSync(file, 'utf8')
}
uiState(): PluginsUiState {
const status: PluginStatusEntry[] = []
for (const [pluginId, items] of this.statusItems) {
for (const [itemId, item] of items) status.push({ pluginId, itemId, item })
}
const toolbar: PluginToolbarEntry[] = []
for (const [pluginId, buttons] of this.toolbarButtons) {
for (const [buttonId, button] of buttons) toolbar.push({ pluginId, buttonId, button })
}
return { status, toolbar }
}
// ---- 内联 UI(数据驱动;单一事实源在主进程) ----
setStatusItem(pluginId: string, itemId: string, item: StatusItem | null): void {
if (!this.resolved.has(pluginId)) return
let items = this.statusItems.get(pluginId)
if (!items) {
items = new Map()
this.statusItems.set(pluginId, items)
}
if (item) items.set(itemId, item)
else items.delete(itemId)
if (items.size === 0) this.statusItems.delete(pluginId)
this.send('plugins:uiState', this.uiState())
}
registerToolbarButton(pluginId: string, button: ToolbarButtonDef): void {
if (!this.resolved.has(pluginId)) return
let buttons = this.toolbarButtons.get(pluginId)
if (!buttons) {
buttons = new Map()
this.toolbarButtons.set(pluginId, buttons)
}
buttons.set(button.id, button)
this.send('plugins:uiState', this.uiState())
}
removeToolbarButton(pluginId: string, buttonId: string): void {
const buttons = this.toolbarButtons.get(pluginId)
if (!buttons) return
buttons.delete(buttonId)
if (buttons.size === 0) this.toolbarButtons.delete(pluginId)
this.send('plugins:uiState', this.uiState())
}
/** 清空某插件的全部内联 UI(禁用/重载路径) */
private clearPluginUi(pluginId: string): void {
this.statusItems.delete(pluginId)
this.toolbarButtons.delete(pluginId)
this.send('plugins:uiState', this.uiState())
}
// ---- 深链 ----
openView(view: string, params?: unknown): void {
this.send('plugins:view', { view, params })
}
// ---- 插件 KV 存储 ----
storageFor(pluginId: string) {
return {
get: <T>(key: string): T | undefined => {
const kv = this.storage.get(pluginId)
const v = kv?.get(key)
return v === undefined ? undefined : (structuredClone(v) as T)
},
set: (key: string, value: unknown): void => {
let kv = this.storage.get(pluginId)
if (!kv) {
kv = new Map()
this.storage.set(pluginId, kv)
}
kv.set(key, structuredClone(value))
this.scheduleStorageSave()
},
delete: (key: string): void => {
this.storage.get(pluginId)?.delete(key)
this.scheduleStorageSave()
}
}
}
private scheduleStorageSave(): void {
if (this.storageSaveTimer) return
this.storageSaveTimer = setTimeout(() => {
this.storageSaveTimer = null
const doc: Record<string, Record<string, unknown>> = {}
for (const [pid, kv] of this.storage) doc[pid] = Object.fromEntries(kv)
try {
writeFileSync(this.storageFile, JSON.stringify(doc, null, 2), 'utf8')
} catch (err) {
console.error('[plugins] save storage failed:', err)
}
}, 300)
}
// ---- 聚合 pi extension ----
/** 挂进 AgentService 的 extensionFactories;工厂内激活全部已启用插件 */
createAggregatedExtension(): InlineExtension {
return {
name: 'aso-plugins',
factory: (pi: ExtensionAPI) => {
const disposers: Array<() => void> = []
for (const plugin of this.resolved.values()) {
if (!this.isEnabled(plugin.manifest.id) || plugin.error) continue
const factory = plugin.builtinFactory ?? plugin.externalFactory
if (!factory) continue
try {
const result = factory(this.buildApi(plugin, pi))
if (typeof result === 'function') {
disposers.push(result)
} else if (result && typeof (result as Promise<unknown>).then === 'function') {
void (result as Promise<void | (() => void)>).then((d) => {
if (typeof d === 'function') disposers.push(d)
})
}
} catch (err) {
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
this.clearPluginUi(plugin.manifest.id)
this.send('plugins:listChanged', null)
}
}
pi.on('session_shutdown', () => {
for (const dispose of disposers) {
try {
dispose()
} catch (err) {
console.error('[plugins] dispose error:', err)
}
}
disposers.length = 0
})
}
}
}
private buildApi(plugin: ResolvedPlugin, pi: ExtensionAPI) {
const id = plugin.manifest.id
return {
pi,
manifest: plugin.manifest,
storage: this.storageFor(id),
ui: {
openView: (view: string, params?: unknown) => this.openView(view, params),
setStatusItem: (itemId: string, item: StatusItem) => this.setStatusItem(id, itemId, item),
removeStatusItem: (itemId: string) => this.setStatusItem(id, itemId, null),
registerToolbarButton: (button: ToolbarButtonDef) => this.registerToolbarButton(id, button),
removeToolbarButton: (buttonId: string) => this.removeToolbarButton(id, buttonId)
}
}
}
// ---- plugin:// 协议 ----
/** plugin://<id>/<rel> → 插件目录内文件;路径必须落在该插件根内(防穿越) */
resolvePluginFile(id: string, rel: string): string | null {
const plugin = this.resolved.get(id)
if (!plugin || plugin.source !== 'external' || !plugin.dir) return null
const root = resolve(plugin.dir)
const target = resolve(root, rel)
if (target !== root && !target.startsWith(root + sep)) return null
return target
}
registerProtocols(): void {
protocol.handle('plugin', (request) => this.serveFile(() => {
const url = new URL(request.url)
const id = url.hostname
const rel = decodeURIComponent(url.pathname).replace(/^\/+/, '')
const file = this.resolvePluginFile(id, rel)
return file ?? ''
}))
// peer:// 只暴露固定白名单资产(tokens.css 主题变量表,插件页 <link> 引用)
protocol.handle('peer', (request) => this.serveFile(() => {
const url = new URL(request.url)
const asset = `${url.hostname}${url.pathname}`.replace(/\/+$/, '')
if (asset !== 'assets/tokens.css') return ''
return this.tokensCssPath()
}))
}
private tokensCssPath(): string {
// dev:源文件;prod:构建期拷贝到 out/renderer/tokens.css(vite closeBundle 插件)
const src = resolve(app.getAppPath(), 'src', 'renderer', 'src', 'styles', 'beautifului.css')
if (existsSync(src)) return src
return resolve(app.getAppPath(), 'out', 'renderer', 'tokens.css')
}
private async serveFile(resolvePath: () => string): Promise<Response> {
let file = ''
try {
file = resolvePath()
} catch {
file = ''
}
if (!file || !existsSync(file)) return new Response('not found', { status: 404 })
try {
const res = await net.fetch(pathToFileURL(file).toString())
// .mjs/.js 必须以 JS MIME 返回——渲染层动态 import(ESM 模块加载)强校验 content-type
const lower = file.toLowerCase()
const contentType =
lower.endsWith('.mjs') || lower.endsWith('.js')
? 'text/javascript'
: (res.headers.get('content-type') ?? 'application/octet-stream')
// no-store:热重载换 src 即取新文件,避免缓存妨碍开发预览
return new Response(res.body, {
status: res.status,
headers: {
'content-type': contentType,
'cache-control': 'no-store'
}
})
} catch (err) {
console.error('[plugins] serve failed:', file, err)
return new Response('serve error', { status: 500 })
}
}
// ---- 渲染层广播 ----
private send(channel: string, payload: unknown): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(channel, payload)
}
}
}
let host: PluginHost | null = null
export function getPluginHost(): PluginHost {
if (!host) host = new PluginHost()
return host
}
export type { BuiltinPlugin }