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

1007 lines
40 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, 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<string, string> | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
const out: Record<string, string> = {}
for (const [id, range] of Object.entries(raw as Record<string, unknown>)) {
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<string, ResolvedPlugin>()
private disabled = new Set<string>()
/** 项目级扩展目录(.pi/extensions,随工作区切换;由 agent-service 同步) */
private projectExtensionsDir: string | null = null
/** 插件设置:声明 schema(激活时重新声明)/ 用户改过的值 / 一级导航开关 */
private settingsSchemas = new Map<string, PluginSettingsSchema>()
private settingsValues = new Map<string, PluginSettingValues>()
private settingsNavSet = new Set<string>()
/** 设置页动作/值变更的激活期回调(每次插件激活时重置) */
private settingsActions = new Map<string, Set<(key: string) => void>>()
private settingsListeners = new Map<string, Set<(values: PluginSettingValues) => void>>()
private valuesSaveTimer: NodeJS.Timeout | 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>>()
/** 状态条目最近写入时间(悬浮胶囊"最新消息"判定),key = pluginId\0itemId */
private statusUpdatedAt = new Map<string, number>()
/** 跨插件服务注册表(pluginId → serviceName → impl);每次 runtime 重建时清空重建 */
private services = new Map<string, Map<string, unknown>>()
/** 已声明的存储 schema(api.storage.declareSchema;管理页 Tooltip 与 Agent 内省数据源) */
private storageSchemas = new Map<string, PluginStorageSchema>()
/** 插件 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<string, unknown>
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>): 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
// 清掉已卸载插件的设置 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<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,
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<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 默认导出必须是插件工厂函数'
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<void> {
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<void> {
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<void> {
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<string, PluginSettingValues> = {}
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<string, string> | 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<void> {
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<string, string[]>()
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<string, 1 | 2>()
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<string, number>()
const dependents = new Map<string, string[]>()
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<string>
): Promise<void> {
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<unknown>).then === 'function') {
// 异步工厂:限时兜底(挂死不拖垮后续波次),reject 必须接住(不得成为 unhandled rejection)
const d = await withTimeout(result as Promise<void | (() => 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<string>()
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<string, unknown> | 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<string, unknown>) => pluginLog.debug(message, { pluginId: id, ...kv }),
info: (message: string, kv?: Record<string, unknown>) => pluginLog.info(message, { pluginId: id, ...kv }),
warn: (message: string, kv?: Record<string, unknown>) => pluginLog.warn(message, { pluginId: id, ...kv }),
error: (message: string, kv?: Record<string, unknown>) => 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://<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) {
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 }