fix: renderer 模块改经 IPC+Blob URL 装载(绕开 dev http 源 CORS);插件页改为全局/项目作用域切换
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user