feat: 插件声明式设置体系——registerSettings schema + 标准控件表单 + 一级导航入口开关

This commit is contained in:
Rick Ye
2026-09-11 01:45:32 +08:00
parent 8349db9947
commit 2ad1743e10
10 changed files with 887 additions and 13 deletions
+192 -7
View File
@@ -24,12 +24,21 @@ import { join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { getAgentDir } from '@earendil-works/pi-coding-agent'
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
import type { HarnessPluginFactory, PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
import type {
HarnessPluginFactory,
PluginManifest,
PluginSettingsSchema,
PluginSettingValues,
PluginSettingsSchemas,
PluginSettingsValuesMap,
StatusItem,
ToolbarButtonDef
} from '@aso/plugin-sdk'
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
export const HARNESS_CONTRACT_VERSION = '0.1.0'
export const HARNESS_CONTRACT_VERSION = '0.2.0'
/** 外置插件装载开关(build 级):开发版开启、用户版关闭;ASO_EXTERNAL_PLUGINS=1 强制开启 */
export const EXTERNAL_PLUGINS_ENABLED = !app.isPackaged || process.env.ASO_EXTERNAL_PLUGINS === '1'
@@ -72,6 +81,14 @@ class PluginHost {
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) */
@@ -93,6 +110,10 @@ class PluginHost {
return join(app.getPath('userData'), 'aso-plugin-storage.json')
}
private get valuesFile(): string {
return join(app.getPath('userData'), 'aso-plugin-values.json')
}
// ---- 生命周期 ----
init(): void {
@@ -102,11 +123,23 @@ class PluginHost {
// 已存在或创建失败都继续(扫描时再报)
}
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as { disabled?: unknown }
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 {
// 无值文件 = 空
}
try {
const raw = JSON.parse(readFileSync(this.storageFile, 'utf8')) as Record<string, unknown>
for (const [pid, kv] of Object.entries(raw)) {
@@ -192,6 +225,14 @@ class PluginHost {
}
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()
this.send('plugins:listChanged', null)
}
@@ -261,12 +302,108 @@ class PluginHost {
async setEnabled(id: string, enabled: boolean): Promise<void> {
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)) return
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] }, null, 2), 'utf8')
writeFileSync(
this.settingsFile,
JSON.stringify({ disabled: [...this.disabled], settingsNav: [...this.settingsNavSet] }, null, 2),
'utf8'
)
} catch (err) {
console.error('[plugins] save settings failed:', err)
}
await this.reload()
}
// ---- 插件设置表单(声明式 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) {
console.error('[plugins] settings listener error:', err)
}
}
}
settingsAction(id: string, key: string): void {
for (const fn of this.settingsActions.get(id) ?? []) {
try {
fn(key)
} catch (err) {
console.error('[plugins] settings action error:', 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) {
console.error('[plugins] save values failed:', err)
}
}, 300)
}
// ---- 查询 ----
@@ -286,6 +423,8 @@ class PluginHost {
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,
settingsNav: this.settingsNavSet.has(p.manifest.id) && (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
error: p.error
}))
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
@@ -365,11 +504,14 @@ class PluginHost {
this.send('plugins:uiState', this.uiState())
}
/** 清空某插件的全部内联 UI(禁用/重载路径) */
/** 清空某插件的全部内联 UI(禁用/重载路径);设置 schema 一并撤下(导航项随之消失) */
private clearPluginUi(pluginId: string): void {
this.statusItems.delete(pluginId)
this.toolbarButtons.delete(pluginId)
this.settingsSchemas.delete(pluginId)
this.dropSettingsCallbacks(pluginId)
this.send('plugins:uiState', this.uiState())
this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot())
}
// ---- 深链 ----
@@ -429,6 +571,7 @@ class PluginHost {
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))
if (typeof result === 'function') {
@@ -460,6 +603,22 @@ class PluginHost {
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
})()
return {
pi,
manifest: plugin.manifest,
@@ -469,7 +628,33 @@ class PluginHost {
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)
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)
}
}
}
}