1007 lines
40 KiB
TypeScript
1007 lines
40 KiB
TypeScript
/**
|
||
* 插件宿主(主进程):
|
||
* - 内置插件:代码注册(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 }
|