feat: 外置插件一等公民——entry.renderer 宿主内 React 组件(.pi/extensions 一等插件源 + 开发版门控)
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* 插件宿主(主进程):
|
||||
* - 内置插件:代码注册(manifest + 第一方工厂 + 组件 key),编译进 bundle
|
||||
* - 外置插件:userData/plugins/<id>/manifest.json + 可选 main.mjs(动态 import)
|
||||
* - 同 id 高 semver 胜、平级外置胜;外置遮蔽组件级内置需 manifest.replacesNative
|
||||
* - 外置插件: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 页(含路径穿越防护)
|
||||
* - plugin:// 协议把插件目录映射给 iframe 页与渲染层 renderer 模块(含路径穿越防护)
|
||||
* - entry.renderer(渲染层 React 组件模块)在渲染层经动态 import 装载,React 用宿主实例
|
||||
*
|
||||
* 外置装载是 build 级开关:EXTERNAL_PLUGINS_ENABLED——开发版(未打包)开启,
|
||||
* 用户版(打包)关闭(M5 ed25519 验签 + 开发者模式落地后再放开;QA 可用
|
||||
* ASO_EXTERNAL_PLUGINS=1 豁免)。关闭时仅内置插件生效。
|
||||
*
|
||||
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
|
||||
* ed25519 验签 + 开发者模式豁免)。
|
||||
@@ -15,6 +22,7 @@ import { app, BrowserWindow, net, protocol } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, 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, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
|
||||
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
||||
@@ -23,6 +31,9 @@ import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
|
||||
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
|
||||
export const HARNESS_CONTRACT_VERSION = '0.1.0'
|
||||
|
||||
/** 外置插件装载开关(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'
|
||||
@@ -57,6 +68,8 @@ function compareSemver(a: string, b: string): number {
|
||||
class PluginHost {
|
||||
private resolved = new Map<string, ResolvedPlugin>()
|
||||
private disabled = new Set<string>()
|
||||
/** 项目级扩展目录(.pi/extensions,随工作区切换;由 agent-service 同步) */
|
||||
private projectExtensionsDir: string | null = null
|
||||
/** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */
|
||||
private rebuildRuntime: (() => Promise<void>) | null = null
|
||||
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
|
||||
@@ -109,7 +122,16 @@ class PluginHost {
|
||||
this.rebuildRuntime = fn
|
||||
}
|
||||
|
||||
/** 扫描内置注册表 + 外置目录并合并(不动已激活状态;reload 才重建) */
|
||||
/** 同步项目级扩展目录(工作区切换时变更即重扫描;仅通知 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>()
|
||||
|
||||
@@ -122,39 +144,45 @@ class PluginHost {
|
||||
})
|
||||
}
|
||||
|
||||
if (existsSync(this.externalRoot)) {
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = readdirSync(this.externalRoot, { withFileTypes: true })
|
||||
} catch {
|
||||
entries = []
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const dir = join(this.externalRoot, 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)
|
||||
// 外置仅在严格更高版本时遮蔽内置;平级外置胜仅适用于同为外置的场景(此处 existing 必为内置)
|
||||
if (cmp <= 0) continue
|
||||
// 组件级内置只能被显式声明降级替代的外置遮蔽
|
||||
if (existing.componentKey && !manifest.replacesNative) {
|
||||
existing.error = `外置 ${manifest.version} 未声明 replacesNative,继续使用内置 ${existing.manifest.version}`
|
||||
if (EXTERNAL_PLUGINS_ENABLED) {
|
||||
const roots = [this.externalRoot]
|
||||
if (this.projectExtensionsDir) roots.push(this.projectExtensionsDir)
|
||||
roots.push(join(getAgentDir(), 'extensions'))
|
||||
for (const root 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,
|
||||
error: `需要契约版本 >= ${manifest.minContract}(当前 ${HARNESS_CONTRACT_VERSION})`
|
||||
})
|
||||
continue
|
||||
}
|
||||
byId.set(manifest.id, { manifest, source: 'external', dir })
|
||||
}
|
||||
if (compareSemver(manifest.minContract, HARNESS_CONTRACT_VERSION) > 0) {
|
||||
byId.set(manifest.id, {
|
||||
manifest,
|
||||
source: 'external',
|
||||
dir,
|
||||
error: `需要契约版本 >= ${manifest.minContract}(当前 ${HARNESS_CONTRACT_VERSION})`
|
||||
})
|
||||
continue
|
||||
}
|
||||
byId.set(manifest.id, { manifest, source: 'external', dir })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +211,8 @@ class PluginHost {
|
||||
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
|
||||
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,
|
||||
@@ -250,6 +279,7 @@ class PluginHost {
|
||||
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),
|
||||
error: p.error
|
||||
}))
|
||||
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
|
||||
@@ -462,11 +492,17 @@ class PluginHost {
|
||||
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': res.headers.get('content-type') ?? 'application/octet-stream',
|
||||
'content-type': contentType,
|
||||
'cache-control': 'no-store'
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user