/** * 插件宿主(主进程): * - 内置插件:代码注册(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, shell } 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, PluginSettingsSchema, PluginSettingValues, PluginSettingsSchemas, PluginSettingsValuesMap, PluginStorageSchema, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk' import { PLUGIN_ID_PATTERN, withTimeout } from '@aso/plugin-sdk' import type { PluginDepStatus, PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types' import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin' import { PluginStorageService } from './plugin-storage' import { getLogger } from '../logger' const log = getLogger('plugin-host') const pluginLog = getLogger('plugin') /** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */ export const HARNESS_CONTRACT_VERSION = '0.3.0' /** 插件激活限时(异步工厂的兜底超时):挂死的插件不得阻塞 runtime 重建主流程 */ export const PLUGIN_ACTIVATION_TIMEOUT_MS = 10_000 /** 外置插件装载开关(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 } /** semver 范围判定:支持 `*` / `^x.y.z` / `~x.y.z` / `>=x.y.z` / 精确 `x.y.z`(忽略预发布段) */ function satisfiesRange(version: string, range: string): boolean { const v = parseSemver(version) if (!v) return false const r = range.trim() if (!r || r === '*') return true const m = /^(>=|\^|~)?\s*(\d+)\.(\d+)\.(\d+)$/.exec(r) if (!m) return false const cmp = compareSemver(version, `${m[2]}.${m[3]}.${m[4]}`) if (m[1] === '^') return cmp >= 0 && v[0] === Number(m[2]) if (m[1] === '~') return cmp >= 0 && v[0] === Number(m[2]) && v[1] === Number(m[3]) if (m[1] === '>=') return cmp >= 0 return cmp === 0 } /** 依赖声明清洗:键须为合法插件 id、值须为受支持的范围语法;不合法条目丢弃(激活期按缺失处理) */ function sanitizeDeps(raw: unknown): Record | undefined { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined const out: Record = {} for (const [id, range] of Object.entries(raw as Record)) { if (!PLUGIN_ID_PATTERN.test(id) || typeof range !== 'string') continue const trimmed = range.trim() if (!/^(\*|(>=|\^|~)?\d+\.\d+\.\d+)$/.test(trimmed)) continue out[id] = trimmed } return Object.keys(out).length > 0 ? out : undefined } class PluginHost { private resolved = new Map() private disabled = new Set() /** 项目级扩展目录(.pi/extensions,随工作区切换;由 agent-service 同步) */ private projectExtensionsDir: string | null = null /** 插件设置:声明 schema(激活时重新声明)/ 用户改过的值 / 一级导航开关 */ private settingsSchemas = new Map() private settingsValues = new Map() private settingsNavSet = new Set() /** 设置页动作/值变更的激活期回调(每次插件激活时重置) */ private settingsActions = new Map void>>() private settingsListeners = new Map void>>() private valuesSaveTimer: NodeJS.Timeout | null = null /** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */ private rebuildRuntime: (() => Promise) | null = null /** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */ private statusItems = new Map>() private toolbarButtons = new Map>() /** 状态条目最近写入时间(悬浮胶囊"最新消息"判定),key = pluginId\0itemId */ private statusUpdatedAt = new Map() /** 跨插件服务注册表(pluginId → serviceName → impl);每次 runtime 重建时清空重建 */ private services = new Map>() /** 已声明的存储 schema(api.storage.declareSchema;管理页 Tooltip 与 Agent 内省数据源) */ private storageSchemas = new Map() /** 插件 SQLite 存储服务(共享 worker;init 时创建) */ private storageService: PluginStorageService | 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 valuesFile(): string { return join(app.getPath('userData'), 'aso-plugin-values.json') } // ---- 生命周期 ---- init(): void { try { mkdirSync(this.externalRoot, { recursive: true }) } catch { // 已存在或创建失败都继续(扫描时再报) } this.storageService = new PluginStorageService(join(app.getPath('userData'), 'plugin-data')) log.info('plugin host init', { externalRoot: this.externalRoot, externalLoadingEnabled: EXTERNAL_PLUGINS_ENABLED }) try { const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as { disabled?: unknown; settingsNav?: unknown } if (Array.isArray(raw.disabled)) this.disabled = new Set(raw.disabled.filter((d): d is string => typeof d === 'string')) if (Array.isArray(raw.settingsNav)) this.settingsNavSet = new Set(raw.settingsNav.filter((d): d is string => typeof d === 'string')) } catch { // 无设置文件 = 全部启用 } try { const raw = JSON.parse(readFileSync(this.valuesFile, 'utf8')) as Record for (const [pid, kv] of Object.entries(raw)) { if (kv && typeof kv === 'object' && !Array.isArray(kv)) { this.settingsValues.set(pid, { ...(kv as PluginSettingValues) }) } } } 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 // 清掉已卸载插件的设置 schema(值保留在盘上,重装后自动回来) for (const id of [...this.settingsSchemas.keys()]) { if (!byId.has(id)) { this.settingsSchemas.delete(id) this.dropSettingsCallbacks(id) } } this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot()) await this.loadExternalMains() const externalCount = [...byId.values()].filter((p) => p.source === 'external').length log.info('scan complete', { builtin: byId.size - externalCount, external: externalCount, disabled: this.disabled.size, projects: this.projectExtensionsDir ?? '-' }) 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, dependencies: sanitizeDeps(raw.dependencies), optionalDependencies: sanitizeDeps(raw.optionalDependencies), 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 默认导出必须是插件工厂函数' log.error('external main invalid export', { pluginId: plugin.manifest.id, file }) continue } plugin.externalFactory = mod.default as HarnessPluginFactory plugin.error = undefined log.info('external main loaded', { pluginId: plugin.manifest.id, version: plugin.manifest.version }) } catch (err) { plugin.externalFactory = undefined plugin.error = `main 加载失败: ${err instanceof Error ? err.message : String(err)}` log.error('external main load failed', { pluginId: plugin.manifest.id, file, err }) } } } /** 重新扫描 + 重新导入 + 清 UI/服务/存储声明 + 重建 runtime(= pi /reload 等效) */ async reload(): Promise { log.info('reload begin') this.statusItems.clear() this.toolbarButtons.clear() this.statusUpdatedAt.clear() this.services.clear() this.storageSchemas.clear() await this.scan() this.send('plugins:uiState', this.uiState()) this.send('plugins:listChanged', null) if (this.rebuildRuntime) await this.rebuildRuntime() log.info('reload done') } async setEnabled(id: string, enabled: boolean): Promise { log.info('set plugin enabled', { pluginId: id, enabled }) if (enabled) this.disabled.delete(id) else this.disabled.add(id) this.persistPluginPrefs() await this.reload() } /** 设置入口是否固定到设置页一级导航(仅声明了设置表单的插件可开) */ async setSettingsNav(id: string, on: boolean): Promise { const plugin = this.resolved.get(id) // 可固定入口 = 声明了设置表单,或内置插件带宿主专属设置页(如浏览器) if (!plugin || (!this.settingsSchemas.has(id) && !this.builtinSettingsPage(id))) return log.info('set settings nav', { pluginId: id, on }) if (on) this.settingsNavSet.add(id) else this.settingsNavSet.delete(id) this.persistPluginPrefs() this.send('plugins:listChanged', null) } private persistPluginPrefs(): void { try { writeFileSync( this.settingsFile, JSON.stringify({ disabled: [...this.disabled], settingsNav: [...this.settingsNavSet] }, null, 2), 'utf8' ) } catch (err) { log.error('save plugin prefs failed', { err }) } } // ---- 插件设置表单(声明式 schema + 值持久化 + 动作/变更回推) ---- /** 激活期回调容器重置(每次插件工厂执行前调用) */ private resetSettingsCallbacks(id: string): void { this.settingsActions.delete(id) this.settingsListeners.delete(id) } private dropSettingsCallbacks(id: string): void { this.settingsActions.delete(id) this.settingsListeners.delete(id) } private settingsSchemasSnapshot(): PluginSettingsSchemas { return Object.fromEntries(this.settingsSchemas) } private settingsValuesSnapshot(): PluginSettingsValuesMap { const out: PluginSettingsValuesMap = {} for (const [pid, schema] of this.settingsSchemas) { const stored = this.settingsValues.get(pid) ?? {} // 默认值兜底:未设置过的键回落 schema default const defaults: PluginSettingValues = {} for (const item of schema.items) { if ('default' in item && item.default !== undefined && !(item.key in stored)) defaults[item.key] = item.default } out[pid] = { ...defaults, ...stored } } return out } settingsSchemasList(): PluginSettingsSchemas { return this.settingsSchemasSnapshot() } settingsValuesList(): PluginSettingsValuesMap { return this.settingsValuesSnapshot() } setSettingValue(id: string, key: string, value: unknown): void { if (!this.settingsSchemas.has(id)) return const kv = { ...(this.settingsValues.get(id) ?? {}), [key]: value } this.settingsValues.set(id, kv) this.scheduleValuesSave() this.send('plugins:settingsValues', this.settingsValuesSnapshot()) for (const fn of this.settingsListeners.get(id) ?? []) { try { fn({ ...(this.settingsValues.get(id) ?? {}) }) } catch (err) { log.error('settings listener error', { pluginId: id, err }) } } } settingsAction(id: string, key: string): void { log.debug('settings action', { pluginId: id, key }) for (const fn of this.settingsActions.get(id) ?? []) { try { fn(key) } catch (err) { log.error('settings action error', { pluginId: id, key, err }) } } } private scheduleValuesSave(): void { if (this.valuesSaveTimer) return this.valuesSaveTimer = setTimeout(() => { this.valuesSaveTimer = null const doc: Record = {} for (const [pid, kv] of this.settingsValues) doc[pid] = kv try { writeFileSync(this.valuesFile, JSON.stringify(doc, null, 2), 'utf8') } catch (err) { log.error('save values failed', { err }) } }, 300) } // ---- 查询 ---- isEnabled(id: string): boolean { return !this.disabled.has(id) } /** 内置插件是否带宿主专属二级设置页(不走声明式表单,设置页特判渲染) */ private builtinSettingsPage(id: string): boolean { return BUILTIN_PLUGINS.some((b) => b.manifest.id === id && b.settingsPage === true) } 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), hasSettings: (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0 || this.builtinSettingsPage(p.manifest.id), settingsNav: this.settingsNavSet.has(p.manifest.id) && ((this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0 || this.builtinSettingsPage(p.manifest.id)), dependencies: this.depStatus(p), storageSchema: this.storageSchemas.get(p.manifest.id), error: p.error })) .sort((a, b) => a.manifest.id.localeCompare(b.manifest.id)) } /** 依赖清单求值(管理页 Tooltip 数据源):必需 + 可选,逐项实时状态 */ private depStatus(p: ResolvedPlugin): PluginDepStatus[] { const rows: PluginDepStatus[] = [] const add = (rec: Record | undefined, required: boolean): void => { for (const [depId, range] of Object.entries(rec ?? {})) { const dep = this.resolved.get(depId) let status: PluginDepStatus['status'] = 'ok' if (!dep) status = 'missing' else if (!this.isEnabled(depId)) status = 'disabled' else if (dep.error) status = 'error' else if (!satisfiesRange(dep.manifest.version, range)) status = 'version' rows.push({ id: depId, range, required, status }) } } add(p.manifest.dependencies, true) add(p.manifest.optionalDependencies, false) return rows } 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, updatedAt: this.statusUpdatedAt.get(`${pluginId}\0${itemId}`) ?? Date.now() }) } } 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) } const stampKey = `${pluginId}\0${itemId}` if (item) { items.set(itemId, item) this.statusUpdatedAt.set(stampKey, Date.now()) } else { items.delete(itemId) this.statusUpdatedAt.delete(stampKey) } 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/服务注册/存储声明(禁用/重载/激活失败路径);设置 schema 一并撤下(导航项随之消失) */ private clearPluginUi(pluginId: string): void { this.statusItems.delete(pluginId) this.toolbarButtons.delete(pluginId) for (const key of [...this.statusUpdatedAt.keys()]) { if (key.startsWith(`${pluginId}\0`)) this.statusUpdatedAt.delete(key) } this.settingsSchemas.delete(pluginId) this.dropSettingsCallbacks(pluginId) this.services.delete(pluginId) this.storageSchemas.delete(pluginId) this.send('plugins:uiState', this.uiState()) this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot()) this.send('plugins:listChanged', null) } // ---- 深链 ---- openView(view: string, params?: unknown): void { this.send('plugins:view', { view, params }) } // ---- 插件数据目录(设置页「打开数据目录 / 清除数据」) ---- /** 插件数据目录路径(插件不存在返回 null) */ pluginDataDir(id: string): string | null { if (!this.resolved.has(id) || !this.storageService) return null return this.storageService.dataDir(id) } /** 打开插件数据目录(资源管理器中显示;目录不存在则先建) */ openPluginDataDir(id: string): boolean { const dir = this.pluginDataDir(id) if (!dir) return false try { mkdirSync(dir, { recursive: true }) } catch { // 已存在或创建失败都继续(showItemInFolder 对存在路径展示) } shell.showItemInFolder(dir) return true } /** 清除插件全部存储数据(连接关闭 + 删数据目录;重装后插件从零开始) */ async clearPluginData(id: string): Promise { if (!this.resolved.has(id)) throw new Error(`插件 ${id} 不存在`) if (!this.storageService) throw new Error('存储服务未初始化') await this.storageService.clearData(id) log.info('plugin data cleared by user', { pluginId: id }) } /** 必需依赖静态预检(激活前):返回错误文案,null = 全部就绪。 * 软绑定:缺失/停用/版本不符只让本插件激活失败并提示行动,不自动安装、不级联停用。 */ private checkRequiredDeps(p: ResolvedPlugin): string | null { const deps = p.manifest.dependencies if (!deps) return null for (const [depId, range] of Object.entries(deps)) { const dep = this.resolved.get(depId) if (!dep) return `缺少依赖插件 ${depId}(${range}),请安装后重载` if (!this.isEnabled(depId)) return `依赖插件 ${depId} 已停用,请先启用` if (dep.error) return `依赖插件 ${depId} 装载失败,无法激活` if (!(dep.builtinFactory ?? dep.externalFactory)) return `依赖插件 ${depId} 未提供 main 工厂` if (!satisfiesRange(dep.manifest.version, range)) { return `依赖插件 ${depId} 需要 ${range},当前 v${dep.manifest.version}` } } return null } /** Kahn 分层拓扑排序(边 = 必需依赖 → 依赖方):依赖先激活、依赖者后激活,同波并行。 * 存在环时给环成员写明环路并返回 null(整环不激活)。 */ private topoWaves(plugins: ResolvedPlugin[]): ResolvedPlugin[][] | null { const byId = new Map(plugins.map((p) => [p.manifest.id, p])) const depsOf = new Map() for (const p of plugins) { depsOf.set( p.manifest.id, Object.keys(p.manifest.dependencies ?? {}).filter((d) => byId.has(d)) ) } // DFS 环检测:环成员集体报错(写明 a → b → a 环路) const state = new Map() const stack: string[] = [] let hasCycle = false const visit = (id: string): void => { const st = state.get(id) if (st === 2) return if (st === 1) { const start = stack.indexOf(id) const cycle = [...stack.slice(start), id].join(' → ') for (const member of stack.slice(start)) { const member2 = byId.get(member) if (member2) member2.error = `循环依赖: ${cycle}` } hasCycle = true return } state.set(id, 1) stack.push(id) for (const d of depsOf.get(id) ?? []) visit(d) stack.pop() state.set(id, 2) } for (const id of byId.keys()) visit(id) if (hasCycle) return null const indeg = new Map() const dependents = new Map() for (const [id, deps] of depsOf) { indeg.set(id, deps.length) for (const d of deps) { const arr = dependents.get(d) ?? [] arr.push(id) dependents.set(d, arr) } } let frontier = [...indeg.entries()].filter(([, n]) => n === 0).map(([id]) => id) const waves: ResolvedPlugin[][] = [] let remaining = plugins.length while (frontier.length > 0) { waves.push(frontier.map((id) => byId.get(id)!)) remaining -= frontier.length const next: string[] = [] for (const id of frontier) { for (const dep of dependents.get(id) ?? []) { const n = (indeg.get(dep) ?? 0) - 1 indeg.set(dep, n) if (n === 0) next.push(dep) } } frontier = next } // 兜底:理论上环检测已拦;remaining > 0 说明存在未归零节点 return remaining > 0 ? null : waves } /** 激活单个插件(前置:其全部必需依赖已在本轮 active)。异步工厂限时兜底;失败写 error 并清理。 */ private async activatePlugin( p: ResolvedPlugin, pi: ExtensionAPI, disposers: Array<() => void>, active: Set ): Promise { const id = p.manifest.id const factory = p.builtinFactory ?? p.externalFactory if (!factory) return for (const depId of Object.keys(p.manifest.dependencies ?? {})) { if (!active.has(depId)) { p.error = `依赖插件 ${depId} 未能激活(详见该插件错误信息)` this.clearPluginUi(id) return } } this.resetSettingsCallbacks(id) const markActive = (d: unknown): void => { if (typeof d === 'function') disposers.push(d as () => void) active.add(id) log.info('plugin activated', { pluginId: id, source: p.source }) } try { const result = factory(this.buildApi(p, pi)) if (result && typeof (result as Promise).then === 'function') { // 异步工厂:限时兜底(挂死不拖垮后续波次),reject 必须接住(不得成为 unhandled rejection) const d = await withTimeout(result as Promise void)>, PLUGIN_ACTIVATION_TIMEOUT_MS, `插件 ${id} 激活`) markActive(d) } else { markActive(result) } } catch (err) { p.error = `激活失败: ${err instanceof Error ? err.message : String(err)}` log.error('plugin activation failed', { pluginId: id, err }) this.clearPluginUi(id) } } /** 挂进 AgentService 的 extensionFactories;拓扑波次激活全部已启用插件 */ createAggregatedExtension(): InlineExtension { return { name: 'aso-plugins', factory: (pi: ExtensionAPI) => { const disposers: Array<() => void> = [] this.services.clear() /** 本轮激活成功集合(波次推进与依赖方前置判定的依据) */ const active = new Set() pi.on('session_shutdown', () => { for (const dispose of disposers) { try { dispose() } catch (err) { log.error('plugin dispose error', { err }) } } disposers.length = 0 }) const candidates = [...this.resolved.values()].filter( (p) => this.isEnabled(p.manifest.id) && !p.error && (p.builtinFactory ?? p.externalFactory) ) // 静态预检:必需依赖不就绪的插件本轮直接判失败 let staticFailures = 0 for (const p of candidates) { const failed = this.checkRequiredDeps(p) if (failed) { p.error = failed staticFailures++ } } if (staticFailures > 0) this.send('plugins:listChanged', null) // 拓扑波次激活;环成员已在 topoWaves 里写明 error const waves = this.topoWaves(candidates.filter((p) => !p.error)) if (!waves || waves.length === 0) { if (!waves) this.send('plugins:listChanged', null) return } void (async () => { for (const wave of waves) { await Promise.all(wave.map((p) => this.activatePlugin(p, pi, disposers, active))) } })() } } } private buildApi(plugin: ResolvedPlugin, pi: ExtensionAPI) { const id = plugin.manifest.id const actions = (() => { let set = this.settingsActions.get(id) if (!set) { set = new Set() this.settingsActions.set(id, set) } return set })() const listeners = (() => { let set = this.settingsListeners.get(id) if (!set) { set = new Set() this.settingsListeners.set(id, set) } return set })() if (!this.storageService) throw new Error('插件存储服务未初始化(宿主 init 未执行)') const storage = this.storageService.forPlugin(id, (schema) => { if (!schema || !Array.isArray(schema.tables)) return this.storageSchemas.set(id, schema) this.send('plugins:listChanged', null) }) return { pi, manifest: plugin.manifest, storage, /** 注册跨插件服务(按名键控;重载/rebuild 时随 services.clear() 清空) */ service: (name: string, impl: unknown): void => { let svc = this.services.get(id) if (!svc) { svc = new Map() this.services.set(id, svc) } svc.set(name, impl) }, /** 取依赖插件的服务对象;依赖声明即访问授权——未声明直接抛错 */ requirePlugin: (targetId: string): Record | undefined => { const required = plugin.manifest.dependencies?.[targetId] const optional = plugin.manifest.optionalDependencies?.[targetId] if (required === undefined && optional === undefined) { throw new Error(`插件 ${id} 未声明对 ${targetId} 的依赖(dependencies / optionalDependencies)`) } const svc = this.services.get(targetId) if (!svc) { if (optional !== undefined) return undefined throw new Error(`依赖插件 ${targetId} 尚未就绪`) } return Object.fromEntries(svc) }, log: { debug: (message: string, kv?: Record) => pluginLog.debug(message, { pluginId: id, ...kv }), info: (message: string, kv?: Record) => pluginLog.info(message, { pluginId: id, ...kv }), warn: (message: string, kv?: Record) => pluginLog.warn(message, { pluginId: id, ...kv }), error: (message: string, kv?: Record) => pluginLog.error(message, { pluginId: id, ...kv }) }, 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), registerSettings: (schema: PluginSettingsSchema) => { if (!schema || !Array.isArray(schema.items)) return if (schema.items.length === 0) this.settingsSchemas.delete(id) else this.settingsSchemas.set(id, schema) this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot()) this.send('plugins:listChanged', null) }, getSettingValues: (): PluginSettingValues => { const schema = this.settingsSchemas.get(id) const stored = this.settingsValues.get(id) ?? {} const defaults: PluginSettingValues = {} if (schema) { for (const item of schema.items) { if ('default' in item && item.default !== undefined && !(item.key in stored)) defaults[item.key] = item.default } } return { ...defaults, ...stored } }, onSettingsChange: (listener: (values: PluginSettingValues) => void) => { listeners.add(listener) return () => listeners.delete(listener) }, onSettingsAction: (listener: (key: string) => void) => { actions.add(listener) return () => actions.delete(listener) } } } } // ---- 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) { log.error('plugin:// 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 }