feat: enhance plugin functionality

This commit is contained in:
时雨
2026-01-06 16:54:49 +08:00
committed by GitHub
parent 31d97b2968
commit 24255744df
48 changed files with 3089 additions and 3811 deletions

View File

@@ -0,0 +1,279 @@
/**
* 插件外部依赖注入模块
*
* 宿主应用通过 window.__FOXEL_EXTERNALS__ 暴露共享依赖给插件使用
* 插件开发时可以直接从 window.__FOXEL_EXTERNALS__ 获取 React、Antd 等依赖
*/
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import * as antd from 'antd';
import * as AntdIcons from '@ant-design/icons';
// 宿主共享依赖(供插件复用)
import MonacoEditor from '@monaco-editor/react';
import Artplayer from 'artplayer';
// API 模块
import request, { vfsApi, API_BASE_URL } from '../api/client';
import { pluginsApi } from '../api/plugins';
// 类型定义
import type { VfsEntry, DirListing } from '../api/client';
import type { PluginItem } from '../api/plugins';
type Lang = 'zh' | 'en';
type Dict = Record<string, string>;
type Dicts = Partial<Record<Lang, Dict>>;
function interpolate(template: string, params?: Record<string, string | number>): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`));
}
/**
* 宿主 API 接口
*/
export interface FoxelHostApi {
/**
* 关闭当前插件窗口
*/
close: () => void;
/**
* 在文件浏览器中打开指定路径
*/
openPath?: (path: string) => void;
/**
* 使用指定应用打开文件
*/
openFile?: (filePath: string, appKey?: string) => void;
/**
* 显示消息提示
*/
showMessage: (type: 'success' | 'error' | 'info' | 'warning', content: string) => void;
/**
* 调用 API
*/
callApi: <T = unknown>(path: string, options?: RequestInit & { json?: unknown }) => Promise<T>;
/**
* 获取文件的临时公开链接
*/
getTempLink?: (filePath: string) => Promise<string>;
/**
* 获取文件流式播放/下载 URL
*/
getStreamUrl?: (filePath: string) => string;
}
/**
* 插件上下文 - 文件打开模式
*/
export interface PluginContext {
/** 文件路径 */
filePath: string;
/** 文件信息 */
entry: VfsEntry;
/** 文件 URL */
urls: {
/** 临时下载链接 */
downloadUrl: string;
/** 流式播放链接 */
streamUrl?: string;
};
/** 宿主 API */
host: FoxelHostApi;
}
/**
* 插件上下文 - 独立应用模式
*/
export interface PluginAppContext {
/** 宿主 API */
host: FoxelHostApi;
}
/**
* 已注册的插件接口
*/
export interface RegisteredPlugin {
/**
* 挂载插件(文件打开模式)
* @returns 可选的清理函数
*/
mount: (container: HTMLElement, ctx: PluginContext) => void | (() => void) | Promise<void | (() => void)>;
/**
* 卸载插件(文件打开模式)
*/
unmount?: (container: HTMLElement) => void | Promise<void>;
/**
* 挂载独立应用
* @returns 可选的清理函数
*/
mountApp?: (container: HTMLElement, ctx: PluginAppContext) => void | (() => void) | Promise<void | (() => void)>;
/**
* 卸载独立应用
*/
unmountApp?: (container: HTMLElement) => void | Promise<void>;
}
/**
* VFS API 包装
*/
const vfsApiWrapper = {
list: vfsApi.list,
stat: vfsApi.stat,
mkdir: vfsApi.mkdir,
rename: vfsApi.rename,
deletePath: vfsApi.deletePath,
copy: vfsApi.copy,
move: vfsApi.move,
streamUrl: vfsApi.streamUrl,
getTempLinkToken: vfsApi.getTempLinkToken,
getTempPublicUrl: vfsApi.getTempPublicUrl,
readFile: vfsApi.readFile,
uploadFile: vfsApi.uploadFile,
};
/**
* 暴露给插件的外部依赖
*/
export interface FoxelExternals {
// React 核心
React: typeof React;
ReactDOM: typeof ReactDOM;
// UI 库
antd: typeof antd;
AntdIcons: typeof AntdIcons;
// 编辑器/播放器
MonacoEditor: typeof MonacoEditor;
Artplayer: typeof Artplayer;
// i18n
i18n?: {
getLang: () => Lang;
subscribe: (cb: (lang: Lang) => void) => () => void;
create: (dicts: Dicts) => {
t: (key: string, params?: Record<string, string | number>) => string;
useI18n: () => { lang: Lang; t: (key: string, params?: Record<string, string | number>) => string };
};
};
// Foxel API
foxelApi: {
/** HTTP 请求函数 */
request: typeof request;
/** VFS 文件系统 API */
vfs: typeof vfsApiWrapper;
/** 插件管理 API */
plugins: typeof pluginsApi;
/** API 基础 URL */
baseUrl: string;
};
// 版本信息
version: string;
}
// 声明全局类型
declare global {
interface Window {
__FOXEL_EXTERNALS__?: FoxelExternals;
FoxelRegister?: (plugin: RegisteredPlugin) => void;
}
}
/**
* 初始化并暴露外部依赖
*/
export function initExternals(): void {
const normalizeLang = (raw: unknown): Lang => (raw === 'en' ? 'en' : 'zh');
const i18nApi = {
getLang: () => normalizeLang(localStorage.getItem('lang')),
subscribe: (cb: (lang: Lang) => void) => {
const handler = (e: Event) => {
const lang = (e as CustomEvent)?.detail?.lang as Lang;
cb(normalizeLang(lang));
};
window.addEventListener('foxel:langchange', handler as any);
return () => window.removeEventListener('foxel:langchange', handler as any);
},
create: (dicts: Dicts) => {
const t = (key: string, params?: Record<string, string | number>) => {
const lang = i18nApi.getLang();
const dict = dicts[lang] || {};
return interpolate(dict[key] ?? key, params);
};
const useI18n = () => {
const [lang, setLang] = React.useState<Lang>(() => i18nApi.getLang());
React.useEffect(() => i18nApi.subscribe(setLang), []);
const tt = React.useCallback((key: string, params?: Record<string, string | number>) => {
const dict = dicts[lang] || {};
return interpolate(dict[key] ?? key, params);
}, [lang]);
return { lang, t: tt };
};
return { t, useI18n };
},
};
if (window.__FOXEL_EXTERNALS__) {
window.__FOXEL_EXTERNALS__.i18n = i18nApi;
window.__FOXEL_EXTERNALS__.MonacoEditor = MonacoEditor;
window.__FOXEL_EXTERNALS__.Artplayer = Artplayer;
return; // 已初始化
}
window.__FOXEL_EXTERNALS__ = {
// React 核心
React,
ReactDOM,
// UI 库
antd,
AntdIcons,
// 编辑器/播放器
MonacoEditor,
Artplayer,
// i18n
i18n: i18nApi,
// Foxel API
foxelApi: {
request,
vfs: vfsApiWrapper,
plugins: pluginsApi,
baseUrl: API_BASE_URL,
},
// 版本信息
version: '1.0.0',
};
console.debug('[Foxel] Plugin externals initialized');
}
/**
* 获取外部依赖
*/
export function getExternals(): FoxelExternals | undefined {
return window.__FOXEL_EXTERNALS__;
}
// 导出类型供插件 SDK 使用
export type { VfsEntry, DirListing, PluginItem };

View File

@@ -1,64 +1,55 @@
import { pluginsApi, type PluginManifestUpdate, type PluginItem } from '../api/plugins';
/**
* 插件运行时模块
*
* 负责:
* 1. 加载和管理插件
* 2. 处理插件注册
*/
export interface RegisteredPlugin {
mount: (container: HTMLElement, ctx: {
filePath: string;
entry: any;
urls: { downloadUrl: string };
host: HostApi;
}) => void | Promise<void>;
unmount?: (container: HTMLElement) => void | Promise<void>;
import type { PluginItem } from '../api/plugins';
import type { RegisteredPlugin, PluginContext, PluginAppContext, FoxelHostApi } from './externals';
mountApp?: (container: HTMLElement, ctx: { host: HostApi }) => void | Promise<void>;
unmountApp?: (container: HTMLElement) => void | Promise<void>;
key?: string;
name?: string;
version?: string;
supportedExts?: string[];
defaultBounds?: { x?: number; y?: number; width?: number; height?: number };
defaultMaximized?: boolean;
icon?: string;
description?: string;
author?: string;
website?: string;
github?: string;
}
export interface HostApi {
close: () => void;
}
// 重新导出类型
export type { RegisteredPlugin, PluginContext, PluginAppContext, FoxelHostApi };
// 已加载的插件缓存
const loadedPlugins = new Map<string, RegisteredPlugin>();
// 等待加载的插件回调
const waiters = new Map<string, ((p: RegisteredPlugin) => void)[]>();
// 已注入的脚本 URL
const injected = new Set<string>();
declare global {
interface Window { FoxelRegister?: (plugin: RegisteredPlugin) => void; }
}
/**
* 全局插件注册函数
* 插件加载后调用此函数注册自己
*/
window.FoxelRegister = (plugin: RegisteredPlugin) => {
const pendingUrl = sessionStorage.getItem('foxel:pendingPluginUrl') || '';
if (pendingUrl) {
loadedPlugins.set(pendingUrl, plugin);
const resolvers = waiters.get(pendingUrl) || [];
resolvers.forEach(fn => fn(plugin));
resolvers.forEach((fn) => fn(plugin));
waiters.delete(pendingUrl);
sessionStorage.removeItem('foxel:pendingPluginUrl');
} else {
// 回退:使用第一个等待的 URL
const anyUrl = Array.from(waiters.keys())[0];
if (anyUrl) {
loadedPlugins.set(anyUrl, plugin);
const resolvers = waiters.get(anyUrl) || [];
resolvers.forEach(fn => fn(plugin));
resolvers.forEach((fn) => fn(plugin));
waiters.delete(anyUrl);
}
}
};
/**
* 从 URL 加载插件
*/
export async function loadPluginFromUrl(url: string): Promise<RegisteredPlugin> {
const existing = loadedPlugins.get(url);
if (existing) return existing;
return new Promise<RegisteredPlugin>((resolve, reject) => {
const arr = waiters.get(url) || [];
arr.push(resolve);
@@ -67,7 +58,7 @@ export async function loadPluginFromUrl(url: string): Promise<RegisteredPlugin>
const ready = loadedPlugins.get(url);
if (ready) {
const resolvers = waiters.get(url) || [];
resolvers.forEach(fn => fn(ready));
resolvers.forEach((fn) => fn(ready));
waiters.delete(url);
return;
}
@@ -94,52 +85,61 @@ export async function loadPluginFromUrl(url: string): Promise<RegisteredPlugin>
}, 15000);
const last = arr[arr.length - 1];
arr[arr.length - 1] = (p: RegisteredPlugin) => { clearTimeout(t); last(p); };
arr[arr.length - 1] = (p: RegisteredPlugin) => {
clearTimeout(t);
last(p);
};
});
}
export function getPluginBundleUrl(pluginId: number) {
return `/api/plugins/${pluginId}/bundle.js`;
/**
* 获取插件 bundle URL
*/
export function getPluginBundleUrl(pluginKey: string): string {
return `/api/plugins/${pluginKey}/bundle.js`;
}
export async function loadPlugin(plugin: Pick<PluginItem, 'id' | 'url'>): Promise<RegisteredPlugin> {
const bundleUrl = getPluginBundleUrl(plugin.id);
try {
return await loadPluginFromUrl(bundleUrl);
} catch (e) {
if (plugin.url && plugin.url !== bundleUrl) {
try {
return await loadPluginFromUrl(plugin.url);
} catch {
throw e;
}
}
throw e;
}
/**
* 获取插件资源 URL
*/
export function getPluginAssetUrl(pluginKey: string, assetPath: string): string {
return `/api/plugins/${pluginKey}/assets/${assetPath}`;
}
export async function ensureManifest(pluginId: number, plugin: RegisteredPlugin) {
const manifest: PluginManifestUpdate = {
key: plugin.key,
name: plugin.name,
version: plugin.version,
open_app: typeof plugin.mountApp === 'function',
supported_exts: plugin.supportedExts,
default_bounds: plugin.defaultBounds,
default_maximized: plugin.defaultMaximized,
icon: plugin.icon,
description: plugin.description,
author: plugin.author,
website: plugin.website,
github: plugin.github,
};
try { console.debug('[foxel] report manifest', pluginId, manifest); } catch { void 0; }
const key = `foxel:manifestReported:${pluginId}`;
if (sessionStorage.getItem(key) === '1') return;
try {
await pluginsApi.updateManifest(pluginId, manifest);
sessionStorage.setItem(key, '1');
} catch {
void 0;
/**
* 加载插件
*/
export async function loadPlugin(plugin: Pick<PluginItem, 'key'>): Promise<RegisteredPlugin> {
const bundleUrl = getPluginBundleUrl(plugin.key);
return await loadPluginFromUrl(bundleUrl);
}
/**
* 检查插件是否已加载
*/
export function isPluginLoaded(key: string): boolean {
const bundleUrl = getPluginBundleUrl(key);
return loadedPlugins.has(bundleUrl);
}
/**
* 获取已加载的插件
*/
export function getLoadedPlugin(key: string): RegisteredPlugin | undefined {
const bundleUrl = getPluginBundleUrl(key);
return loadedPlugins.get(bundleUrl);
}
/**
* 清除插件缓存(用于开发/调试)
*/
export function clearPluginCache(key?: string): void {
if (key) {
const bundleUrl = getPluginBundleUrl(key);
loadedPlugins.delete(bundleUrl);
injected.delete(bundleUrl);
} else {
loadedPlugins.clear();
injected.clear();
}
}