feat: 插件声明式设置体系——registerSettings schema + 标准控件表单 + 一级导航入口开关
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user