fix: renderer 模块改经 IPC+Blob URL 装载(绕开 dev http 源 CORS);插件页改为全局/项目作用域切换

This commit is contained in:
Rick Ye
2026-09-11 01:11:18 +08:00
parent 71c91563e8
commit 538c25ab37
7 changed files with 184 additions and 87 deletions
+7
View File
@@ -397,6 +397,13 @@ export function registerIpc(): AgentService {
ipcMain.handle('plugins:list', () => getPluginHost().list())
ipcMain.handle('plugins:uiState', () => getPluginHost().uiState())
// 外置插件来源目录(设置页「打开目录」)
ipcMain.handle('plugins:dirs', () => getPluginHost().pluginDirs())
// 外置插件渲染层模块源码(渲染层以 Blob URL import,绕开 dev http 源对 plugin:// 的 CORS 限制)
ipcMain.handle('plugins:readRenderer', (_e, id: unknown) => {
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id')
return getPluginHost().readRendererSource(id)
})
ipcMain.handle('plugins:setEnabled', (_e, id: unknown, enabled: unknown) => {
if (typeof id !== 'string' || typeof enabled !== 'boolean') throw new Error('invalid plugin toggle')
return getPluginHost().setEnabled(id, enabled)
+24 -1
View File
@@ -19,7 +19,7 @@
* ed25519 验签 + 开发者模式豁免)。
*/
import { app, BrowserWindow, net, protocol } from 'electron'
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { getAgentDir } from '@earendil-works/pi-coding-agent'
@@ -296,6 +296,29 @@ class PluginHost {
return p?.componentKey
}
/** 外置插件来源目录(管理页「打开目录」用) */
pluginDirs(): { user: string[]; project: string[]; global: string[] } {
return {
user: [this.externalRoot],
project: this.projectExtensionsDir ? [this.projectExtensionsDir] : [],
global: [join(getAgentDir(), 'extensions')]
}
}
/** 读取外置插件渲染层模块源码。渲染层取回后以 Blob URL import——
* dev 下页面源是 http://localhost,直接 import(plugin://) 会被 CORS 拦截。 */
readRendererSource(id: string): string {
if (!EXTERNAL_PLUGINS_ENABLED) throw new Error('第三方插件装载已关闭(用户版构建)')
const plugin = this.resolved.get(id)
const file =
plugin?.source === 'external' && plugin.manifest.entry?.renderer
? resolve(plugin.dir!, plugin.manifest.entry.renderer)
: null
if (!file || !existsSync(file)) throw new Error(`插件 ${id} 未提供渲染层模块`)
if (statSync(file).size > 8 * 1024 * 1024) throw new Error('渲染层模块超过 8MB 上限')
return readFileSync(file, 'utf8')
}
uiState(): PluginsUiState {
const status: PluginStatusEntry[] = []
for (const [pluginId, items] of this.statusItems) {
+3
View File
@@ -139,6 +139,9 @@ const api = {
// ---- 插件系统 ----
pluginsList: (): Promise<PluginInfo[]> => ipcRenderer.invoke('plugins:list'),
pluginsUiState: (): Promise<PluginsUiState> => ipcRenderer.invoke('plugins:uiState'),
pluginsDirs: (): Promise<{ user: string[]; project: string[]; global: string[] }> =>
ipcRenderer.invoke('plugins:dirs'),
pluginsReadRenderer: (id: string): Promise<string> => ipcRenderer.invoke('plugins:readRenderer', id),
pluginsSetEnabled: (id: string, enabled: boolean): Promise<void> =>
ipcRenderer.invoke('plugins:setEnabled', id, enabled),
pluginsReload: (): Promise<void> => ipcRenderer.invoke('plugins:reload'),
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' plugin:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'self' plugin:"
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' plugin: blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'self' plugin:"
/>
<title>Inkreach Agent</title>
</head>
@@ -681,53 +681,100 @@ function CorePluginPanel({
)
}
/** 第三方插件页:外置插件按来源分组(商店 / 项目 .pi/extensions / 全局扩展目录),启停机制与核心插件相同 */
/** 第三方插件页:全局/项目两个作用域(交互对齐技能页),行内启停开关与核心插件相同 */
function ExternalPluginPanel({
togglingId,
onToggle,
devBuild
devBuild,
onOpenDir
}: {
togglingId: string | null
onToggle: (plugin: PluginInfo, next: boolean) => void
/** 开发版构建(第三方装载开关开启);用户版展示关闭提示 */
devBuild: boolean
onOpenDir: (path: string) => void
}): ReactNode {
const external = usePluginList().filter((p) => p.source === 'external')
const groups = [
{ label: '商店插件', hint: 'userData/plugins', items: external.filter((p) => p.origin === 'user') },
{ label: '项目插件', hint: '.pi/extensions(当前工作区)', items: external.filter((p) => p.origin === 'project') },
{ label: '全局插件', hint: '~/.pi/agent/extensions', items: external.filter((p) => p.origin === 'global') }
].filter((g) => g.items.length > 0)
const [scope, setScope] = useState<'user' | 'project'>('user')
const [dirs, setDirs] = useState<{ user: string[]; project: string[]; global: string[] }>({
user: [],
project: [],
global: []
})
useEffect(() => {
let alive = true
window.api
.pluginsDirs()
.then((d) => {
if (alive) setDirs(d)
})
.catch(() => undefined)
return () => {
alive = false
}
}, [])
// 全局作用域 = 商店目录 + 全局扩展目录;项目作用域 = 当前工作区 .pi/extensions
const scoped = external.filter((p) => (scope === 'project' ? p.origin === 'project' : p.origin !== 'project'))
const scopedDirs = (scope === 'user' ? [...dirs.user, ...dirs.global] : dirs.project).filter(Boolean)
const scopedHints = scope === 'user' ? ['商店', '全局'] : ['项目']
if (groups.length === 0) {
return (
<div className="flex min-h-0 flex-col">
<div className="flex items-center gap-2 pb-3">
<div className="flex items-center rounded-control bg-zinc-100 p-0.5">
{(['user', 'project'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={`h-6 rounded-[6px] px-2.5 text-[11.5px] transition-colors ${
scope === s ? 'bg-white font-medium text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'
}`}
>
{s === 'user' ? '全局' : '项目'}
</button>
))}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{scopedDirs.map((d, i) => (
<button
key={d}
type="button"
title={d}
onClick={() => onOpenDir(d)}
className="flex shrink-0 items-center gap-1 rounded-control px-1.5 py-1 font-mono text-[10.5px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
>
<LuFolderOpen size={12} className="shrink-0" />
{scopedHints[i] ?? '打开目录'}
</button>
))}
</div>
</div>
{scoped.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center">
<div className="text-[13px] font-medium text-ink-2">
{devBuild ? '暂无第三方插件' : '第三方插件装载已关闭(用户版构建)'}
</div>
{devBuild && (
<div className="text-[11.5px] text-ink-3">把含 manifest.json 的插件目录放入上方任一来源即可自动识别</div>
<div className="text-[11.5px] text-ink-3">
把含 manifest.json 的插件目录放入
{scope === 'user' ? '商店(userData/plugins)或全局(~/.pi/agent/extensions)' : '项目 .pi/extensions'}
即可自动识别
</div>
)}
</div>
)
}
return (
<div className="space-y-4">
{groups.map((group) => (
<div key={group.label}>
<div className="flex items-baseline gap-1.5 pb-1.5">
<span className="text-[11.5px] font-medium text-ink-2">{group.label}</span>
<span className="font-mono text-[10.5px] text-ink-3">{group.hint}</span>
</div>
) : (
<div className="space-y-1.5">
{group.items
{scoped
.slice()
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
.map((p) => (
<div key={p.manifest.id} className="rounded-card border border-line px-3 py-2.5">
<div className="flex items-center gap-2">
<span className="text-[12.5px] font-medium text-ink">{p.manifest.name}</span>
<span className="rounded-control bg-hover px-1.5 py-0.5 text-[10px] text-ink-3">v{p.manifest.version}</span>
<span className="rounded-control bg-hover px-1.5 py-0.5 text-[10px] text-ink-3">
{scope === 'user' ? (p.origin === 'user' ? '商店' : '全局') : '项目'} v{p.manifest.version}
</span>
<span className="ml-auto text-[11px] text-ink-3">{p.enabled ? '已启用' : '已停用'}</span>
<button
type="button"
@@ -750,8 +797,7 @@ function ExternalPluginPanel({
</div>
))}
</div>
</div>
))}
)}
</div>
)
}
@@ -1057,7 +1103,12 @@ export function SettingsModal({
{tab === 'corePlugins' && <CorePluginPanel togglingId={togglingId} onToggle={togglePlugin} />}
{tab === 'plugins' && (
<ExternalPluginPanel togglingId={togglingId} onToggle={togglePlugin} devBuild={import.meta.env.DEV} />
<ExternalPluginPanel
togglingId={togglingId}
onToggle={togglePlugin}
devBuild={import.meta.env.DEV}
onOpenDir={(path) => void window.api.openPath(path)}
/>
)}
{tab === 'prompt' && (
+2
View File
@@ -118,6 +118,8 @@ declare global {
// ---- 插件系统 ----
pluginsList: () => Promise<PluginInfo[]>
pluginsUiState: () => Promise<PluginsUiState>
pluginsDirs: () => Promise<{ user: string[]; project: string[]; global: string[] }>
pluginsReadRenderer: (id: string) => Promise<string>
pluginsSetEnabled: (id: string, enabled: boolean) => Promise<void>
pluginsReload: () => Promise<void>
pluginsOpenView: (view: string, params?: unknown) => Promise<void>
@@ -2,8 +2,9 @@
* 外置插件渲染层宿主(entry.renderer 动态装载):
* - 启动即注入 window.__ASO_PLUGIN_HOST__(宿主 React 单实例——插件组件必须用它创建,
* 自带 React 副本会导致 hooks 失效)
* - 插件 renderer 模块经 plugin://<id>/<file>?t=<token> 动态 import(no-store + cache-bust,
* 列表变化/热重载即整模块重装),默认导出 mount(ctx) 注册 view/panel 组件
* - 插件 renderer 模块源码经 IPC(plugins:readRenderer)取回,以 Blob URL 动态 import
* (dev 页面源是 http://localhost,直接 import(plugin://) 会被 CORS 拦),
* 默认导出 mount(ctx) 注册 view/panel 组件;列表/热重载变化即整模块重装
* - 仅开发版装载(与主进程 EXTERNAL_PLUGINS_ENABLED 同开关语义;用户版主进程不下发外置条目)
* - 错误隔离到单插件:装载/注册失败仅 console.error 并清空该插件注册,不影响宿主与其他插件;
* 组件渲染期崩溃由 App 层 PluginErrorBoundary 兜底
@@ -12,7 +13,7 @@ import { useEffect, useSyncExternalStore } from 'react'
import type { ComponentType } from 'react'
import * as React from 'react'
import * as jsxRuntime from 'react/jsx-runtime'
import type { PluginHostGlobal, PluginRendererContext, PluginRendererModule } from '@aso/plugin-sdk/renderer'
import type { PluginHostGlobal, PluginRendererModule } from '@aso/plugin-sdk/renderer'
import type { PluginInfo } from '@shared/types'
/** 插件已注册组件(按槽位;缺省 = 未注册该槽位) */
@@ -56,39 +57,49 @@ export function useExternalPluginComponents(): Map<string, PluginComponents> {
/**
* 外置插件 renderer 模块装载循环:插件列表 / 热重载令牌变化即重装。
* 不在列表(禁用/卸载/关闭开关)的插件注册被移除,其组件随之卸载。
* 源码经 IPC 取回后以 Blob URL import——dev 下页面源是 http://localhost,
* 直接 import(plugin://) 会被 CORS 拦截;Blob 模块须自包含(相对导入无基路径)。
* 不在列表(禁用/卸载/关闭开关)或主进程侧报错的插件被移除,其组件随之卸载。
*/
export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: number): void {
useEffect(() => {
if (!import.meta.env.DEV) return
let cancelled = false
const load = async (): Promise<void> => {
const keep = new Set<string>()
for (const plugin of plugins) {
const file = plugin.manifest.entry?.renderer
if (!plugin.enabled || plugin.source !== 'external' || !file) continue
if (!plugin.enabled || plugin.source !== 'external' || plugin.error || !file) continue
const id = plugin.manifest.id
keep.add(id)
import(/* @vite-ignore */ `plugin://${id}/${file}?t=${reloadToken}`)
.then((mod) => {
try {
const code = await window.api.pluginsReadRenderer(id)
if (cancelled) return
const mount = (mod as { default?: unknown }).default
const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }))
try {
const mod = (await import(/* @vite-ignore */ url)) as { default?: unknown }
if (cancelled) return
const mount = mod.default
if (typeof mount !== 'function') throw new Error('renderer 模块默认导出必须是 mount(ctx) 函数')
const ctx: PluginRendererContext = {
;(mount as PluginRendererModule)({
manifest: plugin.manifest,
react: React,
registerView: (component) => upsert(id, { view: component }),
registerPanel: (component) => upsert(id, { panel: component })
}
;(mount as PluginRendererModule)(ctx)
})
.catch((err: unknown) => {
} finally {
URL.revokeObjectURL(url)
}
} catch (err) {
console.error(`[plugins] ${id} renderer 装载失败:`, err)
remove(id)
})
}
}
for (const id of [...registry.keys()]) {
if (!keep.has(id)) remove(id)
}
}
void load()
return () => {
cancelled = true
}