From 538c25ab378e3cc7302025e717fff4ce5350f225 Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Fri, 11 Sep 2026 01:11:18 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20renderer=20=E6=A8=A1=E5=9D=97=E6=94=B9?= =?UTF-8?q?=E7=BB=8F=20IPC+Blob=20URL=20=E8=A3=85=E8=BD=BD=EF=BC=88?= =?UTF-8?q?=E7=BB=95=E5=BC=80=20dev=20http=20=E6=BA=90=20CORS=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E6=8F=92=E4=BB=B6=E9=A1=B5=E6=94=B9=E4=B8=BA=E5=85=A8?= =?UTF-8?q?=E5=B1=80/=E9=A1=B9=E7=9B=AE=E4=BD=9C=E7=94=A8=E5=9F=9F?= =?UTF-8?q?=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/main/ipc.ts | 7 + apps/desktop/src/main/plugins/plugin-host.ts | 25 ++- apps/desktop/src/preload/index.ts | 3 + apps/desktop/src/renderer/index.html | 2 +- .../renderer/src/components/SettingsModal.tsx | 169 ++++++++++++------ apps/desktop/src/renderer/src/lib/api.ts | 2 + .../renderer/src/plugins/renderer-host.tsx | 63 ++++--- 7 files changed, 184 insertions(+), 87 deletions(-) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index e7cc87d..1dbd677 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -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) diff --git a/apps/desktop/src/main/plugins/plugin-host.ts b/apps/desktop/src/main/plugins/plugin-host.ts index 354df93..98bbb68 100644 --- a/apps/desktop/src/main/plugins/plugin-host.ts +++ b/apps/desktop/src/main/plugins/plugin-host.ts @@ -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) { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index cf98f4f..0a81850 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -139,6 +139,9 @@ const api = { // ---- 插件系统 ---- pluginsList: (): Promise => ipcRenderer.invoke('plugins:list'), pluginsUiState: (): Promise => ipcRenderer.invoke('plugins:uiState'), + pluginsDirs: (): Promise<{ user: string[]; project: string[]; global: string[] }> => + ipcRenderer.invoke('plugins:dirs'), + pluginsReadRenderer: (id: string): Promise => ipcRenderer.invoke('plugins:readRenderer', id), pluginsSetEnabled: (id: string, enabled: boolean): Promise => ipcRenderer.invoke('plugins:setEnabled', id, enabled), pluginsReload: (): Promise => ipcRenderer.invoke('plugins:reload'), diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 40e2378..f15764a 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -5,7 +5,7 @@ Inkreach Agent diff --git a/apps/desktop/src/renderer/src/components/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/SettingsModal.tsx index 9dab9b7..b1e51f5 100644 --- a/apps/desktop/src/renderer/src/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/SettingsModal.tsx @@ -681,77 +681,123 @@ 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 ( -
-
- {devBuild ? '暂无第三方插件' : '第三方插件装载已关闭(用户版构建)'} -
- {devBuild && ( -
把含 manifest.json 的插件目录放入上方任一来源即可自动识别
- )} -
- ) - } return ( -
- {groups.map((group) => ( -
-
- {group.label} - {group.hint} -
-
- {group.items - .slice() - .sort((a, b) => a.manifest.id.localeCompare(b.manifest.id)) - .map((p) => ( -
-
- {p.manifest.name} - v{p.manifest.version} - {p.enabled ? '已启用' : '已停用'} - -
- {p.manifest.description &&
{p.manifest.description}
} - {p.error &&
装载失败:{p.error}
} -
- ))} -
+
+
+
+ {(['user', 'project'] as const).map((s) => ( + + ))}
- ))} +
+ {scopedDirs.map((d, i) => ( + + ))} +
+
+ {scoped.length === 0 ? ( +
+
+ {devBuild ? '暂无第三方插件' : '第三方插件装载已关闭(用户版构建)'} +
+ {devBuild && ( +
+ 把含 manifest.json 的插件目录放入 + {scope === 'user' ? '商店(userData/plugins)或全局(~/.pi/agent/extensions)' : '项目 .pi/extensions'} + 即可自动识别 +
+ )} +
+ ) : ( +
+ {scoped + .slice() + .sort((a, b) => a.manifest.id.localeCompare(b.manifest.id)) + .map((p) => ( +
+
+ {p.manifest.name} + + {scope === 'user' ? (p.origin === 'user' ? '商店' : '全局') : '项目'} v{p.manifest.version} + + {p.enabled ? '已启用' : '已停用'} + +
+ {p.manifest.description &&
{p.manifest.description}
} + {p.error &&
装载失败:{p.error}
} +
+ ))} +
+ )}
) } @@ -1057,7 +1103,12 @@ export function SettingsModal({ {tab === 'corePlugins' && } {tab === 'plugins' && ( - + void window.api.openPath(path)} + /> )} {tab === 'prompt' && ( diff --git a/apps/desktop/src/renderer/src/lib/api.ts b/apps/desktop/src/renderer/src/lib/api.ts index 53d4a74..d14799c 100644 --- a/apps/desktop/src/renderer/src/lib/api.ts +++ b/apps/desktop/src/renderer/src/lib/api.ts @@ -118,6 +118,8 @@ declare global { // ---- 插件系统 ---- pluginsList: () => Promise pluginsUiState: () => Promise + pluginsDirs: () => Promise<{ user: string[]; project: string[]; global: string[] }> + pluginsReadRenderer: (id: string) => Promise pluginsSetEnabled: (id: string, enabled: boolean) => Promise pluginsReload: () => Promise pluginsOpenView: (view: string, params?: unknown) => Promise diff --git a/apps/desktop/src/renderer/src/plugins/renderer-host.tsx b/apps/desktop/src/renderer/src/plugins/renderer-host.tsx index 6f82402..f58a598 100644 --- a/apps/desktop/src/renderer/src/plugins/renderer-host.tsx +++ b/apps/desktop/src/renderer/src/plugins/renderer-host.tsx @@ -2,8 +2,9 @@ * 外置插件渲染层宿主(entry.renderer 动态装载): * - 启动即注入 window.__ASO_PLUGIN_HOST__(宿主 React 单实例——插件组件必须用它创建, * 自带 React 副本会导致 hooks 失效) - * - 插件 renderer 模块经 plugin:///?t= 动态 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 { /** * 外置插件 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 keep = new Set() - for (const plugin of plugins) { - const file = plugin.manifest.entry?.renderer - if (!plugin.enabled || plugin.source !== 'external' || !file) continue - const id = plugin.manifest.id - keep.add(id) - import(/* @vite-ignore */ `plugin://${id}/${file}?t=${reloadToken}`) - .then((mod) => { + const load = async (): Promise => { + const keep = new Set() + for (const plugin of plugins) { + const file = plugin.manifest.entry?.renderer + if (!plugin.enabled || plugin.source !== 'external' || plugin.error || !file) continue + const id = plugin.manifest.id + keep.add(id) + try { + const code = await window.api.pluginsReadRenderer(id) if (cancelled) return - const mount = (mod as { default?: unknown }).default - if (typeof mount !== 'function') throw new Error('renderer 模块默认导出必须是 mount(ctx) 函数') - const ctx: PluginRendererContext = { - manifest: plugin.manifest, - react: React, - registerView: (component) => upsert(id, { view: component }), - registerPanel: (component) => upsert(id, { panel: component }) + 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) 函数') + ;(mount as PluginRendererModule)({ + manifest: plugin.manifest, + react: React, + registerView: (component) => upsert(id, { view: component }), + registerPanel: (component) => upsert(id, { panel: component }) + }) + } finally { + URL.revokeObjectURL(url) } - ;(mount as PluginRendererModule)(ctx) - }) - .catch((err: unknown) => { + } catch (err) { console.error(`[plugins] ${id} renderer 装载失败:`, err) remove(id) - }) - } - for (const id of [...registry.keys()]) { - if (!keep.has(id)) remove(id) + } + } + for (const id of [...registry.keys()]) { + if (!keep.has(id)) remove(id) + } } + void load() return () => { cancelled = true }