From 1cb2b554c002a34ee8f2fd5847d3dd2b3a77abf1 Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Fri, 11 Sep 2026 14:56:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20=E4=BE=9D=E8=B5=96=E4=BA=92?= =?UTF-8?q?=E8=B0=83=20+=20SQLite=20=E5=AD=98=E5=82=A8=EF=BC=8C=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=200.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/electron.vite.config.ts | 20 +- apps/desktop/src/main/ipc.ts | 10 + apps/desktop/src/main/plugins/plugin-host.ts | 374 +++++++++++++----- .../src/main/plugins/plugin-storage.ts | 160 ++++++++ .../src/main/plugins/storage-worker.mjs | 102 +++++ apps/desktop/src/preload/index.ts | 2 + .../renderer/src/components/SettingsModal.tsx | 153 ++++++- apps/desktop/src/renderer/src/lib/api.ts | 2 + apps/desktop/src/shared/types.ts | 17 +- packages/plugin-sdk/src/index.ts | 1 + packages/plugin-sdk/src/manifest.ts | 5 + packages/plugin-sdk/src/plugin.ts | 16 +- packages/plugin-sdk/src/storage.ts | 57 +++ packages/plugin-template/manifest.json | 2 +- 14 files changed, 807 insertions(+), 114 deletions(-) create mode 100644 apps/desktop/src/main/plugins/plugin-storage.ts create mode 100644 apps/desktop/src/main/plugins/storage-worker.mjs create mode 100644 packages/plugin-sdk/src/storage.ts diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index ecccd98..f66550d 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -23,10 +23,28 @@ function copyTokensCss(): Plugin { } } +/** 构建期把插件存储 worker(免打包纯 ESM,只依赖 node 内置模块)拷到 out/main/ 旁: + * 主进程按 import.meta.url 定位它——必须与主进程产物同目录 */ +function copyStorageWorker(): Plugin { + return { + name: 'aso-copy-storage-worker', + closeBundle() { + const src = resolve(__dirname, 'src/main/plugins/storage-worker.mjs') + const outDir = resolve(__dirname, 'out/main') + try { + mkdirSync(outDir, { recursive: true }) + copyFileSync(src, resolve(outDir, 'storage-worker.mjs')) + } catch (err) { + this.warn(`storage-worker.mjs 拷贝失败: ${err instanceof Error ? err.message : String(err)}`) + } + } + } +} + export default defineConfig({ main: { // pi SDK 为纯 ESM 包,主进程输出 ESM(Electron ≥28 支持) - plugins: [externalizeDepsPlugin()], + plugins: [externalizeDepsPlugin(), copyStorageWorker()], build: { rollupOptions: { // electron 保持内置模块导入;pi 全家桶运行时从 node_modules 以 ESM 加载, diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 59f9026..0630c7d 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -470,6 +470,16 @@ export function registerIpc(): AgentService { if (typeof id !== 'string' || typeof enabled !== 'boolean') throw new Error('invalid plugin toggle') return getPluginHost().setEnabled(id, enabled) }) + // 打开插件私有数据目录(资源管理器中显示;不存在则先建) + handle('plugins:openDataDir', (_e, id: unknown) => { + if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id') + return getPluginHost().openPluginDataDir(id) + }) + // 清除插件全部存储数据(设置页二次确认后调用;连接关闭 + 删数据目录) + handle('plugins:clearData', (_e, id: unknown) => { + if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id') + return getPluginHost().clearPluginData(id) + }) handle('plugins:reload', () => getPluginHost().reload()) handle('plugins:openView', (_e, view: unknown, params: unknown) => { if (typeof view !== 'string' || !view) throw new Error('invalid view') diff --git a/apps/desktop/src/main/plugins/plugin-host.ts b/apps/desktop/src/main/plugins/plugin-host.ts index da24c65..6a2d2a2 100644 --- a/apps/desktop/src/main/plugins/plugin-host.ts +++ b/apps/desktop/src/main/plugins/plugin-host.ts @@ -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 | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined + const out: Record = {} + for (const [id, range] of Object.entries(raw as Record)) { + 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() private disabled = new Set() @@ -102,9 +132,12 @@ class PluginHost { /** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */ private statusItems = new Map>() private toolbarButtons = new Map>() - /** 插件 KV 存储(pluginId → key → value) */ - private storage = new Map>() - private storageSaveTimer: NodeJS.Timeout | null = null + /** 跨插件服务注册表(pluginId → serviceName → impl);每次 runtime 重建时清空重建 */ + private services = new Map>() + /** 已声明的存储 schema(api.storage.declareSchema;管理页 Tooltip 与 Agent 内省数据源) */ + private storageSchemas = new Map() + /** 插件 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 - 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))) - } - } - } 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 { 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 | 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: (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 { + 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() + 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() + 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() + const dependents = new Map() + 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 + ): Promise { + 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).then === 'function') { + // 异步工厂:限时兜底(挂死不拖垮后续波次),reject 必须接住(不得成为 unhandled rejection) + const d = await withTimeout(result as Promise 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> = {} - 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).then === 'function') { - // 异步工厂:限时兜底(挂死不拖垮 runtime 重建),reject 必须接住(不得成为 unhandled rejection) - void withTimeout(result as Promise 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() 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 | 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) => pluginLog.debug(message, { pluginId: id, ...kv }), info: (message: string, kv?: Record) => pluginLog.info(message, { pluginId: id, ...kv }), diff --git a/apps/desktop/src/main/plugins/plugin-storage.ts b/apps/desktop/src/main/plugins/plugin-storage.ts new file mode 100644 index 0000000..805c93e --- /dev/null +++ b/apps/desktop/src/main/plugins/plugin-storage.ts @@ -0,0 +1,160 @@ +/** + * 插件存储服务(契约 0.3.0):每插件一个 SQLite 库(userData/plugin-data//data.db)。 + * node:sqlite 的 DatabaseSync 是同步 API——全部连接放在共享 worker 线程里执行 + * (storage-worker.mjs,免打包纯 ESM),主进程只做异步 RPC,插件查询永不阻塞主进程。 + * + * KV(kv 表)由宿主管理:api.storage.kv 的底层,value JSON 编码; + * 插件自建表经 api.storage.db 的 run/query/exec/batch 执行(batch 以事务包裹)。 + */ +import { existsSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Worker } from 'node:worker_threads' +import type { PluginStorage, PluginStorageSchema } from '@aso/plugin-sdk' +import { getLogger } from '../logger' + +const log = getLogger('plugin-storage') + +/** 单个存储操作限时:本地 SQLite 正常毫秒级;超时视为 worker 挂死,不让插件无限等待 */ +const STORAGE_OP_TIMEOUT_MS = 5_000 + +interface PendingCall { + resolve: (value: unknown) => void + reject: (err: Error) => void +} + +export class PluginStorageService { + private worker: Worker | null = null + private pending = new Map() + private reqSeq = 0 + + constructor(private dataRoot: string) {} + + /** worker 文件与主进程产物同目录(构建期 copyStorageWorker 拷贝到 out/main/) */ + private workerPath(): string { + return join(dirname(fileURLToPath(import.meta.url)), 'storage-worker.mjs') + } + + private ensureWorker(): Worker { + if (this.worker) return this.worker + const file = this.workerPath() + if (!existsSync(file)) throw new Error(`存储 worker 缺失: ${file}`) + const w = new Worker(file) + w.on('message', (msg: { reqId: number; ok: boolean; result?: unknown; error?: string }) => { + const call = this.pending.get(msg.reqId) + if (!call) return + this.pending.delete(msg.reqId) + if (msg.ok) call.resolve(msg.result) + else call.reject(new Error(msg.error ?? '存储操作失败')) + }) + const failAll = (err: Error): void => { + for (const call of this.pending.values()) call.reject(err) + this.pending.clear() + this.worker = null + } + w.on('error', (err: Error) => { + log.error('storage worker error', { err: err.message }) + failAll(new Error(`存储 worker 崩溃: ${err.message}`)) + }) + w.on('exit', (code) => { + if (code !== 0) log.error('storage worker exited', { code }) + failAll(new Error(`存储 worker 已退出(code=${code})`)) + }) + // dataRoot 经 init 下发(worker 不依赖 electron app 路径) + this.worker = w + this.rawCall(w, 'init', { dataRoot: this.dataRoot }).catch((err) => log.error('storage worker init failed', { err })) + return w + } + + /** 不经 pending 表的内部调用(init 用) */ + private rawCall(w: Worker, op: string, payload: Record): Promise { + return new Promise((resolve, reject) => { + const reqId = ++this.reqSeq + const onMessage = (msg: { reqId: number; ok: boolean; result?: unknown; error?: string }): void => { + if (msg.reqId !== reqId) return + w.off('message', onMessage) + if (msg.ok) resolve(msg.result) + else reject(new Error(msg.error ?? '存储操作失败')) + } + w.on('message', onMessage) + w.postMessage({ reqId, op, ...payload }) + }) + } + + private call(op: string, payload: Record): Promise { + const w = this.ensureWorker() + const reqId = ++this.reqSeq + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.pending.delete(reqId)) reject(new Error(`存储操作超时(${op},${STORAGE_OP_TIMEOUT_MS}ms)`)) + }, STORAGE_OP_TIMEOUT_MS) + this.pending.set(reqId, { + resolve: (v) => { + clearTimeout(timer) + resolve(v as T) + }, + reject: (e) => { + clearTimeout(timer) + reject(e) + } + }) + w.postMessage({ reqId, op, ...payload }) + }) + } + + // ---- 宿主侧管理 ---- + + /** 插件数据目录(id 已按 manifest 规则校验,可安全作路径段) */ + dataDir(pluginId: string): string { + return join(this.dataRoot, pluginId) + } + + /** 关闭某插件的连接(清除数据前调用;一般重载无需关闭——连接跨重建复用) */ + async closePlugin(pluginId: string): Promise { + if (!this.worker) return + try { + await this.call('close', { pluginId }) + } catch (err) { + log.warn('close plugin db failed', { pluginId, err: err instanceof Error ? err.message : String(err) }) + } + } + + /** 清除插件全部数据:关闭连接 + 删整个数据目录(设置页「清除数据」显式动作) */ + async clearData(pluginId: string): Promise { + await this.closePlugin(pluginId) + await rm(this.dataDir(pluginId), { recursive: true, force: true }) + log.info('plugin data cleared', { pluginId }) + } + + /** 应用退出时收尾(WAL 已逐语句持久化,失败也不阻塞退出) */ + dispose(): void { + const w = this.worker + this.worker = null + for (const call of this.pending.values()) call.reject(new Error('存储服务已关闭')) + this.pending.clear() + void w?.terminate() + } + + // ---- 插件 API(api.storage) ---- + + /** 组装某插件的存储 API;onSchema 由宿主提供(收集 declareSchema 声明) */ + forPlugin(pluginId: string, onSchema: (schema: PluginStorageSchema) => void): PluginStorage { + return { + kv: { + get: (key) => this.call('kvGet', { pluginId, key }).then((v) => (v === null ? undefined : (v as never))), + set: (key, value) => this.call('kvSet', { pluginId, key, value: value ?? null }), + delete: (key) => this.call('kvDelete', { pluginId, key }), + keys: () => this.call('kvKeys', { pluginId }) + }, + db: { + exec: (sql) => this.call('exec', { pluginId, sql }), + run: (sql, params) => this.call('run', { pluginId, sql, params: params ?? [] }), + query: (sql, params) => this.call[]>('query', { pluginId, sql, params: params ?? [] }), + batch: (statements) => this.call('batch', { pluginId, statements }) + }, + dir: () => this.dataDir(pluginId), + declareSchema: (schema) => onSchema(schema) + } + } +} diff --git a/apps/desktop/src/main/plugins/storage-worker.mjs b/apps/desktop/src/main/plugins/storage-worker.mjs new file mode 100644 index 0000000..fb0d186 --- /dev/null +++ b/apps/desktop/src/main/plugins/storage-worker.mjs @@ -0,0 +1,102 @@ +/** + * 插件存储 worker(免打包纯 ESM,只依赖 node 内置模块;构建期拷到 out/main/ 旁)。 + * 持有全部插件的 SQLite 连接(node:sqlite DatabaseSync),按消息逐个执行—— + * 同步 API 全部留在本线程,主进程只做异步 RPC,插件查询永不阻塞主进程事件循环。 + * + * 协议:主进程 → { reqId, op, ...payload };worker → { reqId, ok, result? | error? }。 + * 首条消息必须是 init(下发 dataRoot,避免 worker 依赖 electron app 路径)。 + */ +import { DatabaseSync } from 'node:sqlite' +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { parentPort } from 'node:worker_threads' + +/** dataRoot(userData/plugin-data);由 init 消息下发 */ +let dataRoot = null + +/** pluginId → 已打开连接(跨消息复用;close/clearData 时移除) */ +const dbs = new Map() + +function openDb(pluginId) { + let db = dbs.get(pluginId) + if (db) return db + // pluginId 已由宿主按 manifest 规则校验(小写字母开头,字母/数字/连字符),可安全作路径段 + mkdirSync(join(dataRoot, pluginId), { recursive: true }) + db = new DatabaseSync(join(dataRoot, pluginId, 'data.db')) + db.exec('PRAGMA journal_mode = WAL;') + // 宿主管理的 KV 表:api.storage.kv 的底层(value JSON 编码) + db.exec('CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)') + dbs.set(pluginId, db) + return db +} + +function handle(op, msg) { + switch (op) { + case 'init': + dataRoot = msg.dataRoot + return null + case 'close': { + const db = dbs.get(msg.pluginId) + if (db) { + dbs.delete(msg.pluginId) + db.close() + } + return null + } + case 'kvGet': { + const row = openDb(msg.pluginId).prepare('SELECT value FROM kv WHERE key = ?').get(msg.key) + return row ? JSON.parse(row.value) : null + } + case 'kvSet': + openDb(msg.pluginId) + .prepare('INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') + .run(msg.key, JSON.stringify(msg.value ?? null)) + return null + case 'kvDelete': + openDb(msg.pluginId).prepare('DELETE FROM kv WHERE key = ?').run(msg.key) + return null + case 'kvKeys': + return openDb(msg.pluginId) + .prepare('SELECT key FROM kv ORDER BY key') + .all() + .map((r) => r.key) + case 'exec': + openDb(msg.pluginId).exec(msg.sql) + return null + case 'run': + openDb(msg.pluginId).prepare(msg.sql).run(...(msg.params ?? [])) + return null + case 'query': + return openDb(msg.pluginId).prepare(msg.sql).all(...(msg.params ?? [])) + case 'batch': { + const db = openDb(msg.pluginId) + db.exec('BEGIN') + try { + for (const st of msg.statements ?? []) db.prepare(st.sql).run(...(st.params ?? [])) + db.exec('COMMIT') + } catch (err) { + try { + db.exec('ROLLBACK') + } catch { + // 连接已坏时 ROLLBACK 自身会失败——原始错误优先 + } + throw err + } + return null + } + default: + throw new Error(`未知存储操作: ${op}`) + } +} + +parentPort.on('message', (msg) => { + if (!dataRoot && msg.op !== 'init') { + parentPort.postMessage({ reqId: msg.reqId, ok: false, error: '存储 worker 未初始化' }) + return + } + try { + parentPort.postMessage({ reqId: msg.reqId, ok: true, result: handle(msg.op, msg) ?? null }) + } catch (err) { + parentPort.postMessage({ reqId: msg.reqId, ok: false, error: err instanceof Error ? err.message : String(err) }) + } +}) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 71a8ba3..2c36fcf 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -170,6 +170,8 @@ const api = { }, pluginsSetEnabled: (id: string, enabled: boolean): Promise => ipcRenderer.invoke('plugins:setEnabled', id, enabled), + pluginsOpenDataDir: (id: string): Promise => ipcRenderer.invoke('plugins:openDataDir', id), + pluginsClearData: (id: string): Promise => ipcRenderer.invoke('plugins:clearData', id), pluginsReload: (): Promise => ipcRenderer.invoke('plugins:reload'), pluginsOpenView: (view: string, params?: unknown): Promise => ipcRenderer.invoke('plugins:openView', view, params), diff --git a/apps/desktop/src/renderer/src/components/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/SettingsModal.tsx index aa4bc45..1e54ca3 100644 --- a/apps/desktop/src/renderer/src/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/SettingsModal.tsx @@ -5,8 +5,10 @@ import { LuArrowLeft, LuCheck, LuCpu, + LuDatabase, LuFolderOpen, LuKeyRound, + LuLink2, LuLogOut, LuPlus, LuPuzzle, @@ -16,6 +18,7 @@ import { LuSparkles, LuTerminal, LuTextQuote, + LuTrash2, LuX } from 'react-icons/lu' import type { IconType } from 'react-icons' @@ -23,6 +26,7 @@ import type { CustomProviderConfig, ModelCatalog, ModelMeta, + PluginDepStatus, PluginInfo, ProviderCatalogEntry, ResourceDetails, @@ -631,6 +635,124 @@ function ModelSettings({ ) } +/** 依赖状态展示元数据(状态色对齐状态胶囊语义:绿=可用 / 橙=待处理 / 红=失败) */ +const DEP_STATUS_META: Record = { + ok: { label: '可用', cls: 'text-green' }, + missing: { label: '未安装', cls: 'text-orange' }, + disabled: { label: '已停用', cls: 'text-orange' }, + version: { label: '版本不符', cls: 'text-orange' }, + error: { label: '装载失败', cls: 'text-red' } +} + +/** 依赖关系 Tooltip(管理页行内悬停):展示每个依赖的 id / 版本范围 / 必需性 / 实时状态。 + * 必需依赖缺失或停用时本插件不可用——错误详情见行内红字,去启用在对应插件的行上操作。 */ +function PluginDepsTip({ deps }: { deps: PluginDepStatus[] }): ReactNode { + return ( + e.stopPropagation()}> + + + 依赖 + {deps.map((d) => ( + + {d.id} + {d.range} + {d.required ? '必需' : '可选'} + {DEP_STATUS_META[d.status].label} + + ))} + + + ) +} + +/** 插件存储区(插件二级页):机器可读 schema 展示 + 数据目录/清除数据动作。 + * 数据在 userData/plugin-data//data.db(每插件一库,宿主共享 worker 持有连接)。 */ +function PluginStorageSection({ + info, + onOpenDir, + onClearData +}: { + info: PluginInfo + onOpenDir: () => void + onClearData: () => void +}): ReactNode { + const [confirming, setConfirming] = useState(false) + const schema = info.storageSchema + return ( +
+
+ + 存储区 + + SQLite · 插件私有 + +
+ + {confirming ? ( + + ) : ( + + )} +
+
+ {schema && ( +
+ {schema.kv && ( +
+ kv + {schema.kv.description} +
+ )} + {schema.tables.map((t) => ( +
+
+ {t.name} + {t.description} +
+
+ {t.columns.map((c) => ( +
+ + {c.name} + + {c.type} + {c.description} +
+ ))} +
+
+ ))} +
+ )} +
+ ) +} + /** 插件页:内置 / 全局 / 项目三个作用域统一管理(交互对齐技能页),行内启停开关;单一事实源在主进程,listChanged 驱动刷新 */ function PluginsPanel({ togglingId, @@ -733,7 +855,7 @@ function PluginsPanel({ (a.manifest.order ?? 100) - (b.manifest.order ?? 100) || a.manifest.id.localeCompare(b.manifest.id) ) .map((p) => { - const openSettings = p.hasSettings && p.enabled + const openSettings = p.enabled && (p.hasSettings || Boolean(p.storageSchema)) return (
{scope === 'builtin' ? '内置' : scope === 'user' ? '全局' : '项目'} v{p.manifest.version} + {p.dependencies.length > 0 && }
{p.manifest.description && (
{p.manifest.description}
@@ -1511,7 +1634,10 @@ export function SettingsModal({ (() => { const pid = tab.slice(7) const schema = settingSchemas[pid] - if (!schema || schema.items.length === 0) { + const info = plugins.find((p) => p.manifest.id === pid) + const hasForm = Boolean(schema && schema.items.length > 0) + const hasStorage = Boolean(info?.storageSchema) + if (!hasForm && !hasStorage) { return (
该插件未提供配置项(或插件已停用 / 移除) @@ -1519,13 +1645,22 @@ export function SettingsModal({ ) } return ( -
- handleSetSettingValue(pid, key, value)} - onAction={(key) => void window.api.pluginsSettingsAction(pid, key)} - /> +
+ {hasForm && ( + handleSetSettingValue(pid, key, value)} + onAction={(key) => void window.api.pluginsSettingsAction(pid, key)} + /> + )} + {info && hasStorage && ( + void window.api.pluginsOpenDataDir(pid).catch(() => undefined)} + onClearData={() => run('已清除插件数据', () => window.api.pluginsClearData(pid))} + /> + )}
) })()} diff --git a/apps/desktop/src/renderer/src/lib/api.ts b/apps/desktop/src/renderer/src/lib/api.ts index 4ebadd6..a0b7e44 100644 --- a/apps/desktop/src/renderer/src/lib/api.ts +++ b/apps/desktop/src/renderer/src/lib/api.ts @@ -131,6 +131,8 @@ declare global { onPluginsSettingsSchemas: (callback: (schemas: PluginSettingsSchemas) => void) => () => void onPluginsSettingsValues: (callback: (values: PluginSettingsValuesMap) => void) => () => void pluginsSetEnabled: (id: string, enabled: boolean) => Promise + pluginsOpenDataDir: (id: string) => Promise + pluginsClearData: (id: string) => Promise pluginsReload: () => Promise pluginsOpenView: (view: string, params?: unknown) => Promise pluginsSetStatus: (pluginId: string, itemId: string, item: unknown) => Promise diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index 4e358db..233c653 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -361,7 +361,18 @@ export type AgentUiEvent = // ===== 插件系统(契约类型来自 @aso/plugin-sdk) ===== -import type { PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk' +import type { PluginManifest, PluginStorageSchema, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk' + +/** 依赖状态(管理页 Tooltip 数据源):本插件 manifest 声明的每个依赖的实时可用性 */ +export interface PluginDepStatus { + id: string + /** 声明的 semver 范围(^x.y.z / ~x.y.z / >=x.y.z / 精确 / *) */ + range: string + /** true=必需(缺失/停用/版本不符 → 本插件激活失败);false=可选(只降级) */ + required: boolean + /** ok=可用;missing=未安装;disabled=已停用;version=版本不符;error=自身装载失败 */ + status: 'ok' | 'missing' | 'disabled' | 'version' | 'error' +} /** 插件条目(管理页/侧栏入口数据源) */ export interface PluginInfo { @@ -382,6 +393,10 @@ export interface PluginInfo { hasSettings: boolean /** 设置入口已固定到设置页一级导航(用户开关;仅 hasSettings 时可开) */ settingsNav: boolean + /** 依赖清单及实时状态(manifest.dependencies + optionalDependencies 求值;无声明则空数组) */ + dependencies: PluginDepStatus[] + /** 已声明的存储 schema(api.storage.declareSchema;未声明则缺省) */ + storageSchema?: PluginStorageSchema /** 装载/激活错误(不影响其他插件) */ error?: string } diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 7fa282d..506889d 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -6,4 +6,5 @@ export * from './manifest' export * from './settings' export * from './async' export * from './status' +export * from './storage' export * from './plugin' diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index a7474f8..9c61abf 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -36,6 +36,11 @@ export interface PluginManifest { /** 外置插件遮蔽"组件级"内置插件时必须为 true(能力降级声明, * 防止带原生视图能力的内置被页面级外置静默顶掉) */ replacesNative?: boolean + /** 必需依赖:插件 id → semver 范围(^x.y.z / ~x.y.z / >=x.y.z / 精确 x.y.z / *)。 + * 缺失 / 已停用 / 版本不符 → 本插件激活失败并提示;api.requirePlugin 仅对声明过的依赖放行 */ + dependencies?: Record + /** 可选依赖:同范围语法;缺失或停用只降级不报错(requirePlugin 返回 undefined) */ + optionalDependencies?: Record /** 更新索引地址(JSON;格式见 docs/plugins.md) */ updateURL?: string } diff --git a/packages/plugin-sdk/src/plugin.ts b/packages/plugin-sdk/src/plugin.ts index c426d1c..9f3f4cb 100644 --- a/packages/plugin-sdk/src/plugin.ts +++ b/packages/plugin-sdk/src/plugin.ts @@ -6,13 +6,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent' import type { PluginManifest } from './manifest' import type { PluginSettingsSchema, PluginSettingValues } from './settings' import type { StatusItem, ToolbarButtonDef } from './status' - -/** 插件私有 KV 存储(主进程落盘 userData/aso-plugin-storage.json,按 pluginId 命名空间隔离) */ -export interface PluginStorage { - get(key: string): T | undefined - set(key: string, value: unknown): void - delete(key: string): void -} +import type { PluginStorage } from './storage' /** 插件日志通道:宿主注入,写入统一日志文件(userData/logs/,带 pluginId 字段)。 * Agent 自诊断依赖该通道——插件应当把装载/激活/关键决策与失败都记到这里。 */ @@ -50,10 +44,18 @@ export interface HarnessPluginApi { /** pi 原生扩展 API:工具/事件/命令/消息全走这里(pi.registerTool 等,语义与 pi CLI 扩展一致) */ pi: ExtensionAPI ui: HarnessUiApi + /** 私有存储:每插件一个 SQLite 库(KV + 自建表 + 文件目录),API 全 async */ storage: PluginStorage /** 结构化日志(scope=plugin,自动带 pluginId;error 级别会进日志文件) */ log: PluginLogger manifest: PluginManifest + /** 注册跨插件服务(按名键控;dispose/重载时宿主自动清空)。 + * 只有在 manifest 中声明了本插件为依赖的插件才能取用。 */ + service(name: string, impl: unknown): void + /** 取依赖插件的服务对象({ serviceName: impl })。 + * 未在 dependencies / optionalDependencies 声明 targetId → 抛错(依赖声明即访问授权); + * 必需依赖未就绪 → 抛错;可选依赖未就绪 → 返回 undefined。 */ + requirePlugin(targetId: string): Record | undefined } /** 插件工厂:同步或异步返回 dispose */ diff --git a/packages/plugin-sdk/src/storage.ts b/packages/plugin-sdk/src/storage.ts new file mode 100644 index 0000000..b526587 --- /dev/null +++ b/packages/plugin-sdk/src/storage.ts @@ -0,0 +1,57 @@ +/** + * 插件存储契约(0.3.0):每插件一个 SQLite 库(userData/plugin-data//data.db), + * 由宿主共享 worker 线程持有连接——API 全 async,永不阻塞主进程。 + * 本模块只含类型(同时被宿主、渲染层、插件消费),不得引入 node:sqlite。 + */ + +/** SQLite 存储声明中的一列(description 必填——Agent 自我迭代读的就是它) */ +export interface PluginStorageColumn { + name: string + /** SQLite 类型:TEXT / INTEGER / REAL / BLOB / NUMERIC */ + type: string + description: string +} + +/** 插件自建表声明 */ +export interface PluginStorageTable { + name: string + description: string + columns: PluginStorageColumn[] +} + +/** 机器可读存储 schema:UI Tooltip 与 Agent 内省同源(plugin:check 强制描述非空)。 + * kv 表由宿主管理(api.storage.get/set 的底层),这里只要求描述键空间用途。 */ +export interface PluginStorageSchema { + tables: PluginStorageTable[] + kv?: { description: string } +} + +/** 宿主管理的 KV 表(SQLite kv 表键值对;value JSON 编码) */ +export interface PluginKvStore { + get(key: string): Promise + set(key: string, value: unknown): Promise + delete(key: string): Promise + keys(): Promise +} + +/** 插件自建表的 SQL 入口(worker 线程执行) */ +export interface PluginDb { + /** 多语句 DDL/脚本(无参数) */ + exec(sql: string): Promise + /** 写语句(INSERT/UPDATE/DELETE/DDL,带参数) */ + run(sql: string, params?: unknown[]): Promise + /** 查询(返回全部行) */ + query(sql: string, params?: unknown[]): Promise[]> + /** 原子批量:worker 端 BEGIN/COMMIT 包裹,任一失败整体回滚 */ + batch(statements: Array<{ sql: string; params?: unknown[] }>): Promise +} + +/** 插件私有存储(api.storage):KV + 自建表 SQLite + 自由文件目录 + schema 声明 */ +export interface PluginStorage { + kv: PluginKvStore + db: PluginDb + /** 插件数据目录(userData/plugin-data//,可自由写文件;清除数据时整体删除) */ + dir(): string + /** 声明机器可读存储 schema(每次激活重新声明;使用 db 前必须声明) */ + declareSchema(schema: PluginStorageSchema): void +} diff --git a/packages/plugin-template/manifest.json b/packages/plugin-template/manifest.json index 280b0e1..9e4093c 100644 --- a/packages/plugin-template/manifest.json +++ b/packages/plugin-template/manifest.json @@ -2,7 +2,7 @@ "id": "my-plugin", "name": "我的插件", "version": "0.1.0", - "minContract": "0.2.0", + "minContract": "0.3.0", "description": "TODO:一句话描述插件做什么", "order": 50, "panel": true,