mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-13 08:21:28 +08:00
feat: enhance plugin functionality
This commit is contained in:
279
web/src/plugins/externals.ts
Normal file
279
web/src/plugins/externals.ts
Normal 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 };
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user