/** * 插件宿主(主进程): * - 内置插件:代码注册(manifest + 第一方工厂 + 组件 key),编译进 bundle * - 外置插件:userData/plugins/、项目 .pi/extensions/、全局 ~/.pi/agent/extensions/ * (均为 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() private disabled = new Set() /** 项目级扩展目录(.pi/extensions,随工作区切换;由 agent-service 同步) */ private projectExtensionsDir: string | null = null /** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */ private rebuildRuntime: (() => Promise) | null = null /** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */ private statusItems = new Map>() private toolbarButtons = new Map>() /** 插件 KV 存储(pluginId → key → value) */ private storage = new Map>() 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 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))) } } } catch { // 无存储文件 = 空 } void this.scan() } setRuntimeRebuilder(fn: () => Promise): 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 { const byId = new Map() 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 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 { 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 { 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 { 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: (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> = {} 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).then === 'function') { void (result as Promise 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:/// → 插件目录内文件;路径必须落在该插件根内(防穿越) */ 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 主题变量表,插件页 引用) 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 { 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 }