From f77942ee2b2243d6127b5d228a37afcb03f91d24 Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Fri, 11 Sep 2026 09:56:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=92=E4=BB=B6=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E6=8A=A4=E6=A0=8F=20+=20=E5=85=AC=E5=85=B1=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E5=BA=93=E2=80=94=E2=80=94=E6=BF=80=E6=B4=BB/=E8=A3=85?= =?UTF-8?q?=E8=BD=BD=E9=99=90=E6=97=B6=E5=85=9C=E5=BA=95=E3=80=81renderer?= =?UTF-8?q?=20=E8=A3=85=E8=BD=BD=E4=B8=89=E6=80=81=E5=91=88=E7=8E=B0?= =?UTF-8?q?=E3=80=81@aso/plugin-sdk/ui=E3=80=81withTimeout/withRetry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/main/plugins/plugin-host.ts | 24 +- apps/desktop/src/renderer/src/App.tsx | 26 +- .../renderer/src/plugins/renderer-host.tsx | 75 ++++- packages/plugin-sdk/package.json | 3 +- packages/plugin-sdk/src/async.ts | 68 +++++ packages/plugin-sdk/src/index.ts | 1 + packages/plugin-sdk/src/ui.tsx | 288 ++++++++++++++++++ 7 files changed, 473 insertions(+), 12 deletions(-) create mode 100644 packages/plugin-sdk/src/async.ts create mode 100644 packages/plugin-sdk/src/ui.tsx diff --git a/apps/desktop/src/main/plugins/plugin-host.ts b/apps/desktop/src/main/plugins/plugin-host.ts index 496d0e3..da24c65 100644 --- a/apps/desktop/src/main/plugins/plugin-host.ts +++ b/apps/desktop/src/main/plugins/plugin-host.ts @@ -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).then === 'function') { - void (result as Promise void)>).then((d) => { - if (typeof d === 'function') disposers.push(d) - }) + // 异步工厂:限时兜底(挂死不拖垮 runtime 重建),reject 必须接住(不得成为 unhandled rejection) + void withTimeout(result as Promise 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)}` diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index c5adef7..2fd4c6d 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -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 + return ( + 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 ? ( + ) : plugin.hasRenderer ? ( + ) : plugin.hasPage ? ( ) : ( @@ -477,6 +499,8 @@ export default function App(): JSX.Element { > {ActivePluginComponent ? ( + ) : activePlugin.hasRenderer ? ( + ) : activePlugin.hasPage ? ( { return registry } +// ---- 装载状态(loading/ready/error,按插件;槽位据此呈现统一等待/失败态) ---- + +export type PluginRenderStatus = { state: 'loading' } | { state: 'ready' } | { state: 'error'; message: string } + +const renderStatus = new Map() +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 { } /** - * 外置插件 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]) } diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index c595119..46a98e3 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -7,7 +7,8 @@ "exports": { ".": "./src/index.ts", "./client": "./src/page.ts", - "./renderer": "./src/renderer.ts" + "./renderer": "./src/renderer.ts", + "./ui": "./src/ui.tsx" }, "scripts": { "typecheck": "tsc --noEmit" diff --git a/packages/plugin-sdk/src/async.ts b/packages/plugin-sdk/src/async.ts new file mode 100644 index 0000000..91de13d --- /dev/null +++ b/packages/plugin-sdk/src/async.ts @@ -0,0 +1,68 @@ +/** + * 插件异步工具:超时与重试(零依赖,主进程 main.mjs 与 renderer 通用)。 + * + * 性能铁律(docs/plugins/08-performance.md):插件内一切可能挂起/失败的异步操作 + * (网络请求、子进程、文件轮询…)都应经 withTimeout / withRetry 包裹。宿主对插件 + * 激活(10s)与 renderer 装载(8s)有兜底超时——超时即判失败,不会无限等; + * 插件自己的异步调用应先于宿主超时并自行恢复,而不是被宿主判死。 + */ + +/** 超时错误(err.name === 'TimeoutError',可据此与真实业务失败区分) */ +export class TimeoutError extends Error { + constructor(message: string) { + super(message) + this.name = 'TimeoutError' + } +} + +/** + * 给 Promise 加超时;超时后以 TimeoutError 拒绝。 + * 注意:底层操作不会被取消(Promise 无 cancel 语义),有 AbortController 的调用方 + * 应自行把 signal 接到底层,再用本函数兜底计时。 + */ +export function withTimeout(promise: Promise, ms: number, label?: string): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new TimeoutError(`${label ?? '操作'} 超时(${ms}ms)`)) + }, ms) + }) + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export interface RetryOptions { + /** 失败后的额外重试次数(总尝试 = retries + 1),默认 2 */ + retries?: number + /** 首次重试前的等待,默认 300ms;之后指数退避(×2) */ + delayMs?: number + /** 单次尝试的超时上限(ms),经 withTimeout 计时 */ + timeoutMs?: number + /** 每次重试前的回调(记日志/上报用) */ + onRetry?: (err: unknown, attempt: number, nextDelayMs: number) => void +} + +/** + * 带超时与指数退避的重试:fn 返回 reject 或超时都算失败,重试耗尽后抛最后一次的错误。 + */ +export async function withRetry(fn: (attempt: number) => Promise, options: RetryOptions = {}): Promise { + const retries = options.retries ?? 2 + const delayMs = options.delayMs ?? 300 + let lastErr: unknown + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const run = fn(attempt) + return options.timeoutMs !== undefined ? await withTimeout(run, options.timeoutMs, `第 ${attempt + 1} 次尝试`) : await run + } catch (err) { + lastErr = err + if (attempt === retries) break + const nextDelay = delayMs * 2 ** attempt + options.onRetry?.(err, attempt + 1, nextDelay) + await sleep(nextDelay) + } + } + throw lastErr +} diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 4104182..7fa282d 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -4,5 +4,6 @@ */ export * from './manifest' export * from './settings' +export * from './async' export * from './status' export * from './plugin' diff --git a/packages/plugin-sdk/src/ui.tsx b/packages/plugin-sdk/src/ui.tsx new file mode 100644 index 0000000..e08f759 --- /dev/null +++ b/packages/plugin-sdk/src/ui.tsx @@ -0,0 +1,288 @@ +/** + * 插件公共 UI 组件库(@aso/plugin-sdk/ui)——宿主视觉规范的代码化。 + * + * 仅限 renderer 组件槽位使用(组件渲染在宿主 DOM 里,直接复用宿主样式表); + * iframe 插件页是物理隔离文档,请参考 DESIGN.md §4 的 HTML 模式自行实现。 + * + * 样式来源只有两类: + * - 宿主已生成的 Tailwind 类(DESIGN.md §2 类名词汇,全部经过存在性核验) + * - 语义 token 的内联变量(var(--accent) 等,用于宿主类词汇未覆盖的场合) + * 因此深浅主题自动跟随宿主,禁止出现硬编码色值。 + * + * 等待/失败/空态铁律(docs/plugins/08-performance.md §4):一切异步区块必须呈现 + * LoadingState / ErrorState / EmptyState 三态之一,禁止裸文本或无反馈。 + */ +import { useState } from 'react' +import type { ButtonHTMLAttributes, ComponentProps, InputHTMLAttributes, ReactElement, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react' + +/** 拼接类名(过滤 falsy;轻量版 cn,不引依赖) */ +export function cn(...classes: Array): string { + return classes.filter(Boolean).join(' ') +} + +// ---- 等待态 ---- + +export interface SpinnerProps { + /** 直径 px,默认 14 */ + size?: number + className?: string +} + +/** 旋转指示器:强调色弧 + 弱色圆环 */ +export function Spinner({ size = 14, className }: SpinnerProps): ReactElement { + return ( + + ) +} + +export interface LoadingStateProps { + /** 默认「加载中…」 */ + label?: ReactNode + className?: string +} + +/** 区块级等待态:任何异步内容就绪前的统一占位 */ +export function LoadingState({ label = '加载中…', className }: LoadingStateProps): ReactElement { + return ( +
+ +
{label}
+
+ ) +} + +// ---- 失败态 ---- + +export interface ErrorStateProps { + /** 默认「出错了」 */ + title?: ReactNode + /** 错误详情(错误消息、排查建议),保留换行 */ + description?: ReactNode + /** 提供则显示重试按钮 */ + onRetry?: () => void + retryLabel?: string + className?: string +} + +/** 区块级失败态:错误必须可见、可重试,禁止静默吞掉 */ +export function ErrorState({ title = '出错了', description, onRetry, retryLabel = '重试', className }: ErrorStateProps): ReactElement { + return ( +
+
{title}
+ {description !== undefined && description !== null && description !== '' && ( +
{description}
+ )} + {onRetry && ( + + )} +
+ ) +} + +// ---- 空态 ---- + +export interface EmptyStateProps { + title: ReactNode + /** 怎么产生的提示(如何让它不再为空) */ + description?: ReactNode + /** 可选动作(如「新建」「去设置」) */ + action?: ReactNode + className?: string +} + +/** 区块级空态(DESIGN.md §4 标准模式的组件化) */ +export function EmptyState({ title, description, action, className }: EmptyStateProps): ReactElement { + return ( +
+
{title}
+ {description !== undefined && description !== null && description !== '' && ( +
{description}
+ )} + {action &&
{action}
} +
+ ) +} + +// ---- 按钮 ---- + +export interface ButtonProps extends ButtonHTMLAttributes { + /** primary=强调实心 / ghost=描边(默认)/ danger=危险描边 */ + variant?: 'primary' | 'ghost' | 'danger' + /** sm=辅助按钮 11px / md=标准 11.5px(默认) */ + size?: 'sm' | 'md' + /** true 时禁用并显示旋转指示器(异步提交防重复点击) */ + loading?: boolean +} + +export function Button({ variant = 'ghost', size = 'md', loading = false, disabled, className, children, ...rest }: ButtonProps): ReactElement { + const variantCls = + variant === 'primary' + ? 'bg-accent text-white hover:opacity-90' + : variant === 'danger' + ? 'border border-line-strong text-red hover:bg-red/10' + : 'border border-line-strong text-ink hover:bg-hover' + const sizeCls = size === 'sm' ? 'px-2 py-1 text-[11px]' : 'px-3 py-1.5 text-[11.5px]' + return ( + + ) +} + +// ---- 输入控件 ---- + +/** 单行输入(设置行/表单通用;与宿主设置页同款) */ +export function Input({ className, ...rest }: InputHTMLAttributes): ReactElement { + return ( + + ) +} + +export function Textarea({ className, ...rest }: TextareaHTMLAttributes): ReactElement { + return ( +