feat: 插件性能护栏 + 公共组件库——激活/装载限时兜底、renderer 装载三态呈现、@aso/plugin-sdk/ui、withTimeout/withRetry
This commit is contained in:
@@ -34,6 +34,7 @@ import type {
|
||||
StatusItem,
|
||||
ToolbarButtonDef
|
||||
} from '@aso/plugin-sdk'
|
||||
import { withTimeout } from '@aso/plugin-sdk'
|
||||
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
||||
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
|
||||
import { getLogger } from '../logger'
|
||||
@@ -44,6 +45,9 @@ const pluginLog = getLogger('plugin')
|
||||
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
|
||||
export const HARNESS_CONTRACT_VERSION = '0.2.0'
|
||||
|
||||
/** 插件激活限时(异步工厂的兜底超时):挂死的插件不得阻塞 runtime 重建主流程 */
|
||||
export const PLUGIN_ACTIVATION_TIMEOUT_MS = 10_000
|
||||
|
||||
/** 外置插件装载开关(build 级):开发版开启、用户版关闭;ASO_EXTERNAL_PLUGINS=1 强制开启 */
|
||||
export const EXTERNAL_PLUGINS_ENABLED = !app.isPackaged || process.env.ASO_EXTERNAL_PLUGINS === '1'
|
||||
|
||||
@@ -597,13 +601,25 @@ class PluginHost {
|
||||
this.resetSettingsCallbacks(plugin.manifest.id)
|
||||
try {
|
||||
const result = factory(this.buildApi(plugin, pi))
|
||||
log.info('plugin activated', { pluginId: plugin.manifest.id, source: plugin.source })
|
||||
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<unknown>).then === 'function') {
|
||||
void (result as Promise<void | (() => void)>).then((d) => {
|
||||
if (typeof d === 'function') disposers.push(d)
|
||||
})
|
||||
// 异步工厂:限时兜底(挂死不拖垮 runtime 重建),reject 必须接住(不得成为 unhandled rejection)
|
||||
void withTimeout(result as Promise<void | (() => 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)}`
|
||||
|
||||
@@ -13,7 +13,13 @@ import { Search } from './components/Search'
|
||||
import { BrowserPanel } from './components/browser/BrowserPanel'
|
||||
import { SidePanel, type PanelTabIcon } from './components/SidePanel'
|
||||
import { PluginPageFrame, PluginIcon } from './plugins/PluginPageFrame'
|
||||
import { useExternalPluginComponents, usePluginRendererLoader } from './plugins/renderer-host'
|
||||
import {
|
||||
useExternalPluginComponents,
|
||||
usePluginRendererLoader,
|
||||
usePluginRenderStatus,
|
||||
retryPluginRenderer
|
||||
} from './plugins/renderer-host'
|
||||
import { LoadingState, ErrorState } from '@aso/plugin-sdk/ui'
|
||||
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
|
||||
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
|
||||
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
|
||||
@@ -60,6 +66,20 @@ function loadSideState(): SideState {
|
||||
return { open: false, width: SIDE_DEFAULT_WIDTH, tabs: [], active: null }
|
||||
}
|
||||
|
||||
/** 外置 renderer 槽位的统一等待/失败态:装载中显示 LoadingState,失败显示 ErrorState + 重试 */
|
||||
function PluginRendererSlot({ pluginId }: { pluginId: string }): JSX.Element {
|
||||
const status = usePluginRenderStatus(pluginId)
|
||||
if (!status || status.state !== 'error') return <LoadingState className="h-full" />
|
||||
return (
|
||||
<ErrorState
|
||||
className="h-full"
|
||||
title="插件界面装载失败"
|
||||
description={status.message}
|
||||
onRetry={() => retryPluginRenderer(pluginId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const {
|
||||
state,
|
||||
@@ -347,6 +367,8 @@ export default function App(): JSX.Element {
|
||||
icon: pluginTabIcons.get(id) ?? (LuGlobe as PanelTabIcon),
|
||||
content: PanelComponent ? (
|
||||
<PanelComponent />
|
||||
) : plugin.hasRenderer ? (
|
||||
<PluginRendererSlot pluginId={id} />
|
||||
) : plugin.hasPage ? (
|
||||
<PluginPageFrame plugin={plugin} onOpenView={handleOpenView} reloadToken={pluginReloadToken} />
|
||||
) : (
|
||||
@@ -477,6 +499,8 @@ export default function App(): JSX.Element {
|
||||
>
|
||||
{ActivePluginComponent ? (
|
||||
<ActivePluginComponent />
|
||||
) : activePlugin.hasRenderer ? (
|
||||
<PluginRendererSlot pluginId={activeView.view} />
|
||||
) : activePlugin.hasPage ? (
|
||||
<PluginPageFrame
|
||||
plugin={activePlugin}
|
||||
|
||||
@@ -6,16 +6,21 @@
|
||||
* (dev 页面源是 http://localhost,直接 import(plugin://) 会被 CORS 拦),
|
||||
* 默认导出 mount(ctx) 注册 view/panel 组件;列表/热重载变化即整模块重装
|
||||
* - 仅开发版装载(与主进程 EXTERNAL_PLUGINS_ENABLED 同开关语义;用户版主进程不下发外置条目)
|
||||
* - 错误隔离到单插件:装载/注册失败仅 console.error 并清空该插件注册,不影响宿主与其他插件;
|
||||
* - 性能护栏:读源与 import 均限时 8s(挂死的装载不得无限占位),按插件记录
|
||||
* loading/ready/error 状态供槽位呈现统一等待/失败态;错误隔离到单插件,
|
||||
* 组件渲染期崩溃由 App 层 PluginErrorBoundary 兜底
|
||||
*/
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
import * as React from 'react'
|
||||
import * as jsxRuntime from 'react/jsx-runtime'
|
||||
import { withTimeout } from '@aso/plugin-sdk'
|
||||
import type { PluginHostGlobal, PluginRendererModule } from '@aso/plugin-sdk/renderer'
|
||||
import type { PluginInfo } from '@shared/types'
|
||||
|
||||
/** 单个 renderer 模块读源/装载限时 */
|
||||
const RENDERER_LOAD_TIMEOUT_MS = 8_000
|
||||
|
||||
/** 插件已注册组件(按槽位;缺省 = 未注册该槽位) */
|
||||
interface PluginComponents {
|
||||
view?: ComponentType
|
||||
@@ -49,6 +54,52 @@ export function useExternalPluginComponents(): Map<string, PluginComponents> {
|
||||
return registry
|
||||
}
|
||||
|
||||
// ---- 装载状态(loading/ready/error,按插件;槽位据此呈现统一等待/失败态) ----
|
||||
|
||||
export type PluginRenderStatus = { state: 'loading' } | { state: 'ready' } | { state: 'error'; message: string }
|
||||
|
||||
const renderStatus = new Map<string, PluginRenderStatus>()
|
||||
const statusListeners = new Set<() => void>()
|
||||
let statusVersion = 0
|
||||
|
||||
function setStatus(pluginId: string, status: PluginRenderStatus): void {
|
||||
renderStatus.set(pluginId, status)
|
||||
statusVersion++
|
||||
for (const fn of statusListeners) fn()
|
||||
}
|
||||
|
||||
function deleteStatus(pluginId: string): void {
|
||||
if (!renderStatus.delete(pluginId)) return
|
||||
statusVersion++
|
||||
for (const fn of statusListeners) fn()
|
||||
}
|
||||
|
||||
function subscribeStatus(fn: () => void): () => void {
|
||||
statusListeners.add(fn)
|
||||
return () => statusListeners.delete(fn)
|
||||
}
|
||||
|
||||
/** 某插件 renderer 的当前装载状态(未跟踪 = undefined,视为 loading) */
|
||||
export function usePluginRenderStatus(pluginId: string): PluginRenderStatus | undefined {
|
||||
useSyncExternalStore(subscribeStatus, () => statusVersion)
|
||||
return renderStatus.get(pluginId)
|
||||
}
|
||||
|
||||
// ---- 重试:槽位失败态的重试按钮触发整轮重装 ----
|
||||
|
||||
let retryTick = 0
|
||||
|
||||
/** 重试装载某插件的 renderer(清掉 error 态并重跑装载循环) */
|
||||
export function retryPluginRenderer(pluginId: string): void {
|
||||
setStatus(pluginId, { state: 'loading' })
|
||||
retryTick++
|
||||
for (const fn of statusListeners) fn()
|
||||
}
|
||||
|
||||
function usePluginRendererRetryTick(): number {
|
||||
return useSyncExternalStore(subscribeStatus, () => retryTick)
|
||||
}
|
||||
|
||||
// 宿主全局:插件构建期把 react / react/jsx-runtime 别名到这里的实例(见 docs/plugins.md)
|
||||
;(window as unknown as Record<'__ASO_PLUGIN_HOST__', PluginHostGlobal | undefined>).__ASO_PLUGIN_HOST__ = {
|
||||
react: React,
|
||||
@@ -56,12 +107,13 @@ export function useExternalPluginComponents(): Map<string, PluginComponents> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 外置插件 renderer 模块装载循环:插件列表 / 热重载令牌变化即重装。
|
||||
* 外置插件 renderer 模块装载循环:插件列表 / 热重载令牌 / 重试信号变化即重装。
|
||||
* 源码经 IPC 取回后以 Blob URL import——dev 下页面源是 http://localhost,
|
||||
* 直接 import(plugin://) 会被 CORS 拦截;Blob 模块须自包含(相对导入无基路径)。
|
||||
* 不在列表(禁用/卸载/关闭开关)或主进程侧报错的插件被移除,其组件随之卸载。
|
||||
*/
|
||||
export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: number): void {
|
||||
const retryToken = usePluginRendererRetryTick()
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return
|
||||
let cancelled = false
|
||||
@@ -72,12 +124,17 @@ export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: numb
|
||||
if (!plugin.enabled || plugin.source !== 'external' || plugin.error || !file) continue
|
||||
const id = plugin.manifest.id
|
||||
keep.add(id)
|
||||
setStatus(id, { state: 'loading' })
|
||||
try {
|
||||
const code = await window.api.pluginsReadRenderer(id)
|
||||
const code = await withTimeout(window.api.pluginsReadRenderer(id), RENDERER_LOAD_TIMEOUT_MS, `读取 ${id} renderer 源码`)
|
||||
if (cancelled) return
|
||||
const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }))
|
||||
try {
|
||||
const mod = (await import(/* @vite-ignore */ url)) as { default?: unknown }
|
||||
const mod = (await withTimeout(
|
||||
import(/* @vite-ignore */ url),
|
||||
RENDERER_LOAD_TIMEOUT_MS,
|
||||
`装载 ${id} renderer 模块`
|
||||
)) as { default?: unknown }
|
||||
if (cancelled) return
|
||||
const mount = mod.default
|
||||
if (typeof mount !== 'function') throw new Error('renderer 模块默认导出必须是 mount(ctx) 函数')
|
||||
@@ -87,21 +144,27 @@ export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: numb
|
||||
registerView: (component) => upsert(id, { view: component }),
|
||||
registerPanel: (component) => upsert(id, { panel: component })
|
||||
})
|
||||
setStatus(id, { state: 'ready' })
|
||||
} finally {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[plugins] ${id} renderer 装载失败:`, err)
|
||||
setStatus(id, { state: 'error', message })
|
||||
remove(id)
|
||||
}
|
||||
}
|
||||
for (const id of [...registry.keys()]) {
|
||||
if (!keep.has(id)) remove(id)
|
||||
if (!keep.has(id)) {
|
||||
remove(id)
|
||||
deleteStatus(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [plugins, reloadToken])
|
||||
}, [plugins, reloadToken, retryToken])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user