feat: add support for opening plugins

This commit is contained in:
shiyu
2025-12-15 14:49:01 +08:00
parent 686202a0dd
commit 0fcb3b8ce0
17 changed files with 342 additions and 72 deletions

View File

@@ -1,14 +1,15 @@
import React, { useRef, useEffect, useCallback } from 'react';
import { Space, Button } from 'antd';
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, MinusOutlined } from '@ant-design/icons';
import type { AppDescriptor, AppComponentProps } from './types';
import type { AppDescriptor, AppComponentProps, AppOpenComponentProps } from './types';
import type { VfsEntry } from '../api/client';
export interface AppWindowItem {
id: string;
app: AppDescriptor;
entry: VfsEntry;
filePath: string;
kind: 'file' | 'app';
entry?: VfsEntry;
filePath?: string;
maximized: boolean;
minimized: boolean;
x: number;
@@ -17,12 +18,14 @@ export interface AppWindowItem {
height: number;
}
type AppWindowPatch = Partial<Pick<AppWindowItem, 'maximized' | 'minimized' | 'x' | 'y' | 'width' | 'height'>>;
interface AppWindowsLayerProps {
windows: AppWindowItem[];
onClose: (id: string) => void;
onToggleMax: (id: string) => void;
onBringToFront: (id: string) => void;
onUpdateWindow: (id: string, patch: Partial<AppWindowItem>) => void;
onUpdateWindow: (id: string, patch: AppWindowPatch) => void;
}
export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClose, onToggleMax, onBringToFront, onUpdateWindow }) => {
@@ -193,8 +196,17 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
return (
<>
{visibleWindows.map((w, idx) => {
const AppComp = w.app.component as React.FC<AppComponentProps>;
const isFileWindow = w.kind !== 'app';
const FileComp = w.app.component as React.FC<AppComponentProps>;
const OpenComp = w.app.openAppComponent as React.FC<AppOpenComponentProps> | undefined;
const ContentComp = (isFileWindow ? FileComp : OpenComp) as React.FC<any> | undefined;
const useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true
const titleText = isFileWindow ? `${w.app.name} - ${w.entry?.name || ''}` : w.app.name;
if (!ContentComp) {
return null;
}
if (!useSystemWindow) {
return (
<div
@@ -223,16 +235,20 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
overflow: 'hidden',
background: 'transparent'
}}
>
<AppComp
filePath={w.filePath}
entry={w.entry}
onRequestClose={() => onClose(w.id)}
/>
</div>
</div>
);
}
>
{isFileWindow ? (
<ContentComp
filePath={w.filePath || ''}
entry={w.entry as VfsEntry}
onRequestClose={() => onClose(w.id)}
/>
) : (
<ContentComp onRequestClose={() => onClose(w.id)} />
)}
</div>
</div>
);
}
// 否则继续使用系统窗口渲染(不改动原有逻辑)
const interacting = isInteracting(w.id);
return (
@@ -290,9 +306,9 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
paddingRight: 8,
flex: 1
}}
>
{w.app.name} - {w.entry.name}
</span>
>
{titleText}
</span>
<Space size={4}>
<Button
type="text"
@@ -351,11 +367,15 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
}}
>
{!w.maximized && resizeHandles(w)}
<AppComp
filePath={w.filePath}
entry={w.entry}
onRequestClose={() => onClose(w.id)}
/>
{isFileWindow ? (
<ContentComp
filePath={w.filePath || ''}
entry={w.entry as VfsEntry}
onRequestClose={() => onClose(w.id)}
/>
) : (
<ContentComp onRequestClose={() => onClose(w.id)} />
)}
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
import React, { useRef, useState } from 'react';
import type { AppComponentProps } from '../types';
import type { AppComponentProps, AppOpenComponentProps } from '../types';
import { vfsApi } from '../../api/vfs';
import { loadPluginFromUrl, ensureManifest, type RegisteredPlugin } from '../../plugins/runtime';
import { loadPlugin, ensureManifest, type RegisteredPlugin } from '../../plugins/runtime';
import type { PluginItem } from '../../api/plugins';
import { useAsyncSafeEffect } from '../../hooks/useAsyncSafeEffect';
import { useI18n } from '../../i18n';
@@ -22,7 +22,7 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath,
useAsyncSafeEffect(
async ({ isDisposed }) => {
try {
const p = await loadPluginFromUrl(plugin.url);
const p = await loadPlugin(plugin);
if (isDisposed()) return;
pluginRef.current = p;
await ensureManifest(plugin.id, p);
@@ -57,3 +57,52 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath,
return <div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
};
export interface PluginAppOpenHostProps extends AppOpenComponentProps {
plugin: PluginItem;
}
export const PluginAppOpenHost: React.FC<PluginAppOpenHostProps> = ({ plugin, onRequestClose }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const onCloseRef = useRef(onRequestClose);
onCloseRef.current = onRequestClose;
const { t } = useI18n();
const pluginRef = useRef<RegisteredPlugin | null>(null);
useAsyncSafeEffect(
async ({ isDisposed }) => {
try {
const p = await loadPlugin(plugin);
if (isDisposed()) return;
pluginRef.current = p;
await ensureManifest(plugin.id, p);
if (isDisposed() || !containerRef.current) return;
if (typeof p.mountApp !== 'function') {
throw new Error('该插件不支持独立打开');
}
await p.mountApp(containerRef.current, {
host: { close: () => onCloseRef.current() },
});
} catch (e: any) {
if (!isDisposed()) setError(e?.message || t('Plugin run failed'));
}
},
[plugin.id, plugin.url],
() => {
try {
if (!containerRef.current) return;
const p = pluginRef.current;
if (p?.unmountApp) return p.unmountApp(containerRef.current);
if (p?.unmount) return p.unmount(containerRef.current);
} catch { }
},
);
if (error) {
return <div style={{ padding: 12, color: 'red' }}>{t('Plugin Error')}: {error}</div>;
}
return <div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
};

View File

@@ -1,5 +1,6 @@
import type { AppDescriptor } from '../types';
import { VideoPlayerApp } from './VideoPlayer.tsx';
import { VideoLibraryApp } from './VideoLibrary.tsx';
const supportedExts = ['mp4','webm','ogg','m4v','mov','mkv','avi','wmv','flv','3gp'];
@@ -9,6 +10,7 @@ export const descriptor: AppDescriptor = {
iconUrl: 'https://api.iconify.design/mdi:video.svg',
description: '内置视频播放器,支持常见视频格式播放。',
author: 'Foxel',
openAppComponent: VideoLibraryApp,
supportedExts,
supported: (entry) => {
if (entry.is_dir) return false;

View File

@@ -2,7 +2,7 @@ import type { VfsEntry } from '../api/client';
import type { AppDescriptor } from './types';
import React from 'react';
import { pluginsApi, type PluginItem } from '../api/plugins';
import { PluginAppHost } from './PluginHost';
import { PluginAppHost, PluginAppOpenHost } from './PluginHost';
const apps: AppDescriptor[] = [];
// 使用 import.meta.glob 动态导入所有应用
@@ -39,6 +39,7 @@ function registerPluginAsApp(p: PluginItem) {
name: p.name || `插件 ${p.id}`,
supported,
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }),
openAppComponent: p.open_app ? ((props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props })) : undefined,
iconUrl: p.icon || undefined,
default: false,
defaultBounds: p.default_bounds || undefined,
@@ -98,6 +99,9 @@ export async function reloadPluginApps() {
existing.defaultBounds = p.default_bounds || undefined;
existing.defaultMaximized = p.default_maximized || undefined;
existing.iconUrl = p.icon || existing.iconUrl;
existing.openAppComponent = p.open_app
? ((props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props }))
: undefined;
}
});
} catch { }

View File

@@ -6,11 +6,20 @@ export interface AppComponentProps {
onRequestClose: () => void;
}
export interface AppOpenComponentProps {
onRequestClose: () => void;
}
export interface AppDescriptor {
key: string;
name: string;
supported: (entry: VfsEntry) => boolean;
component: React.ComponentType<AppComponentProps>;
/**
* 独立打开应用(不依赖文件)
* 缺省表示该应用仅支持“通过文件打开”。
*/
openAppComponent?: React.ComponentType<AppOpenComponentProps>;
iconUrl?: string;
default?: boolean;
defaultMaximized?: boolean;