feat(plugins): 依赖互调 + SQLite 存储,契约 0.3.0
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
|
||||
* ed25519 验签 + 开发者模式豁免)。
|
||||
*/
|
||||
import { app, BrowserWindow, net, protocol } from 'electron'
|
||||
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'
|
||||
@@ -31,19 +31,21 @@ import type {
|
||||
PluginSettingValues,
|
||||
PluginSettingsSchemas,
|
||||
PluginSettingsValuesMap,
|
||||
PluginStorageSchema,
|
||||
StatusItem,
|
||||
ToolbarButtonDef
|
||||
} from '@aso/plugin-sdk'
|
||||
import { withTimeout } from '@aso/plugin-sdk'
|
||||
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
||||
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.2.0'
|
||||
export const HARNESS_CONTRACT_VERSION = '0.3.0'
|
||||
|
||||
/** 插件激活限时(异步工厂的兜底超时):挂死的插件不得阻塞 runtime 重建主流程 */
|
||||
export const PLUGIN_ACTIVATION_TIMEOUT_MS = 10_000
|
||||
@@ -84,6 +86,34 @@ function compareSemver(a: string, b: string): number {
|
||||
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>()
|
||||
@@ -102,9 +132,12 @@ class PluginHost {
|
||||
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
|
||||
private statusItems = new Map<string, Map<string, StatusItem>>()
|
||||
private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>()
|
||||
/** 插件 KV 存储(pluginId → key → value) */
|
||||
private storage = new Map<string, Map<string, unknown>>()
|
||||
private storageSaveTimer: NodeJS.Timeout | null = null
|
||||
/** 跨插件服务注册表(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')
|
||||
@@ -114,10 +147,6 @@ class PluginHost {
|
||||
return join(app.getPath('userData'), 'aso-plugin-settings.json')
|
||||
}
|
||||
|
||||
private get storageFile(): string {
|
||||
return join(app.getPath('userData'), 'aso-plugin-storage.json')
|
||||
}
|
||||
|
||||
private get valuesFile(): string {
|
||||
return join(app.getPath('userData'), 'aso-plugin-values.json')
|
||||
}
|
||||
@@ -130,6 +159,7 @@ class PluginHost {
|
||||
} catch {
|
||||
// 已存在或创建失败都继续(扫描时再报)
|
||||
}
|
||||
this.storageService = new PluginStorageService(join(app.getPath('userData'), 'plugin-data'))
|
||||
log.info('plugin host init', {
|
||||
externalRoot: this.externalRoot,
|
||||
externalLoadingEnabled: EXTERNAL_PLUGINS_ENABLED
|
||||
@@ -152,16 +182,6 @@ class PluginHost {
|
||||
} catch {
|
||||
// 无值文件 = 空
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(this.storageFile, 'utf8')) as Record<string, unknown>
|
||||
for (const [pid, kv] of Object.entries(raw)) {
|
||||
if (kv && typeof kv === 'object' && !Array.isArray(kv)) {
|
||||
this.storage.set(pid, new Map(Object.entries(kv as Record<string, unknown>)))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 无存储文件 = 空
|
||||
}
|
||||
void this.scan()
|
||||
}
|
||||
|
||||
@@ -281,6 +301,8 @@ class PluginHost {
|
||||
}
|
||||
: undefined,
|
||||
replacesNative: raw.replacesNative === true,
|
||||
dependencies: sanitizeDeps(raw.dependencies),
|
||||
optionalDependencies: sanitizeDeps(raw.optionalDependencies),
|
||||
updateURL: typeof raw.updateURL === 'string' ? raw.updateURL : undefined
|
||||
}
|
||||
} catch {
|
||||
@@ -312,13 +334,16 @@ class PluginHost {
|
||||
}
|
||||
}
|
||||
|
||||
/** 重新扫描 + 重新导入 + 清 UI 状态 + 重建 runtime(= pi /reload 等效) */
|
||||
/** 重新扫描 + 重新导入 + 清 UI/服务/存储声明 + 重建 runtime(= pi /reload 等效) */
|
||||
async reload(): Promise<void> {
|
||||
log.info('reload begin')
|
||||
this.statusItems.clear()
|
||||
this.toolbarButtons.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')
|
||||
}
|
||||
@@ -452,11 +477,32 @@ class PluginHost {
|
||||
hasRenderer: Boolean(p.source === 'external' && p.manifest.entry?.renderer),
|
||||
hasSettings: (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
|
||||
settingsNav: this.settingsNavSet.has(p.manifest.id) && (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
|
||||
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
|
||||
@@ -531,14 +577,17 @@ class PluginHost {
|
||||
this.send('plugins:uiState', this.uiState())
|
||||
}
|
||||
|
||||
/** 清空某插件的全部内联 UI(禁用/重载路径);设置 schema 一并撤下(导航项随之消失) */
|
||||
/** 清空某插件的内联 UI/服务注册/存储声明(禁用/重载/激活失败路径);设置 schema 一并撤下(导航项随之消失) */
|
||||
private clearPluginUi(pluginId: string): void {
|
||||
this.statusItems.delete(pluginId)
|
||||
this.toolbarButtons.delete(pluginId)
|
||||
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)
|
||||
}
|
||||
|
||||
// ---- 深链 ----
|
||||
@@ -547,87 +596,168 @@ class PluginHost {
|
||||
this.send('plugins:view', { view, params })
|
||||
}
|
||||
|
||||
// ---- 插件 KV 存储 ----
|
||||
// ---- 插件数据目录(设置页「打开数据目录 / 清除数据」) ----
|
||||
|
||||
storageFor(pluginId: string) {
|
||||
return {
|
||||
get: <T>(key: string): T | undefined => {
|
||||
const kv = this.storage.get(pluginId)
|
||||
const v = kv?.get(key)
|
||||
return v === undefined ? undefined : (structuredClone(v) as T)
|
||||
},
|
||||
set: (key: string, value: unknown): void => {
|
||||
let kv = this.storage.get(pluginId)
|
||||
if (!kv) {
|
||||
kv = new Map()
|
||||
this.storage.set(pluginId, kv)
|
||||
}
|
||||
kv.set(key, structuredClone(value))
|
||||
this.scheduleStorageSave()
|
||||
},
|
||||
delete: (key: string): void => {
|
||||
this.storage.get(pluginId)?.delete(key)
|
||||
this.scheduleStorageSave()
|
||||
/** 插件数据目录路径(插件不存在返回 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)
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleStorageSave(): void {
|
||||
if (this.storageSaveTimer) return
|
||||
this.storageSaveTimer = setTimeout(() => {
|
||||
this.storageSaveTimer = null
|
||||
const doc: Record<string, Record<string, unknown>> = {}
|
||||
for (const [pid, kv] of this.storage) doc[pid] = Object.fromEntries(kv)
|
||||
try {
|
||||
writeFileSync(this.storageFile, JSON.stringify(doc, null, 2), 'utf8')
|
||||
} catch (err) {
|
||||
log.error('save storage failed', { err })
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// ---- 聚合 pi extension ----
|
||||
|
||||
/** 挂进 AgentService 的 extensionFactories;工厂内激活全部已启用插件 */
|
||||
/** 挂进 AgentService 的 extensionFactories;拓扑波次激活全部已启用插件 */
|
||||
createAggregatedExtension(): InlineExtension {
|
||||
return {
|
||||
name: 'aso-plugins',
|
||||
factory: (pi: ExtensionAPI) => {
|
||||
const disposers: Array<() => void> = []
|
||||
for (const plugin of this.resolved.values()) {
|
||||
if (!this.isEnabled(plugin.manifest.id) || plugin.error) continue
|
||||
const factory = plugin.builtinFactory ?? plugin.externalFactory
|
||||
if (!factory) continue
|
||||
this.resetSettingsCallbacks(plugin.manifest.id)
|
||||
try {
|
||||
const result = factory(this.buildApi(plugin, pi))
|
||||
const pluginId = plugin.manifest.id
|
||||
if (typeof result === 'function') {
|
||||
disposers.push(result)
|
||||
log.info('plugin activated', { pluginId, source: plugin.source })
|
||||
} else if (result && typeof (result as Promise<unknown>).then === 'function') {
|
||||
// 异步工厂:限时兜底(挂死不拖垮 runtime 重建),reject 必须接住(不得成为 unhandled rejection)
|
||||
void withTimeout(result as Promise<void | (() => void)>, PLUGIN_ACTIVATION_TIMEOUT_MS, `插件 ${pluginId} 激活`)
|
||||
.then((d) => {
|
||||
if (typeof d === 'function') disposers.push(d)
|
||||
log.info('plugin activated', { pluginId, source: plugin.source, async: true })
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
||||
log.error('plugin activation failed', { pluginId, err })
|
||||
this.clearPluginUi(pluginId)
|
||||
this.send('plugins:listChanged', null)
|
||||
})
|
||||
} else {
|
||||
log.info('plugin activated', { pluginId, source: plugin.source })
|
||||
}
|
||||
} catch (err) {
|
||||
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
||||
log.error('plugin activation failed', { pluginId: plugin.manifest.id, err })
|
||||
this.clearPluginUi(plugin.manifest.id)
|
||||
this.send('plugins:listChanged', null)
|
||||
}
|
||||
}
|
||||
this.services.clear()
|
||||
/** 本轮激活成功集合(波次推进与依赖方前置判定的依据) */
|
||||
const active = new Set<string>()
|
||||
pi.on('session_shutdown', () => {
|
||||
for (const dispose of disposers) {
|
||||
try {
|
||||
@@ -638,6 +768,31 @@ class PluginHost {
|
||||
}
|
||||
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)))
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -660,10 +815,39 @@ class PluginHost {
|
||||
}
|
||||
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: this.storageFor(id),
|
||||
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 }),
|
||||
|
||||
Reference in New Issue
Block a user