feat: Add App Center plugin functionality

This commit is contained in:
shiyu
2025-09-08 12:28:37 +08:00
parent 280bedcf1a
commit f40ff4d751
14 changed files with 605 additions and 6 deletions

3
.gitignore vendored
View File

@@ -6,4 +6,5 @@ __pycache__/
.vscode/
data/
migrate/
.env
.env
AGENTS.md

View File

@@ -1,6 +1,7 @@
from fastapi import FastAPI
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db
from .routes import plugins
def include_routers(app: FastAPI):
@@ -15,4 +16,5 @@ def include_routers(app: FastAPI):
app.include_router(share.router)
app.include_router(share.public_router)
app.include_router(backup.router)
app.include_router(vector_db.router)
app.include_router(vector_db.router)
app.include_router(plugins.router)

73
api/routes/plugins.py Normal file
View File

@@ -0,0 +1,73 @@
from typing import List, Any, Dict
from fastapi import APIRouter, HTTPException, Body
from models import database
from schemas import PluginCreate, PluginOut
router = APIRouter(prefix="/api/plugins", tags=["plugins"])
@router.post("", response_model=PluginOut)
async def create_plugin(payload: PluginCreate):
rec = await database.Plugin.create(
url=payload.url,
enabled=payload.enabled,
)
return PluginOut.model_validate(rec)
@router.get("", response_model=List[PluginOut])
async def list_plugins():
rows = await database.Plugin.all().order_by("-id")
return [PluginOut.model_validate(r) for r in rows]
@router.delete("/{plugin_id}")
async def delete_plugin(plugin_id: int):
rec = await database.Plugin.get_or_none(id=plugin_id)
if not rec:
raise HTTPException(status_code=404, detail="Plugin not found")
await rec.delete()
return {"code": 0, "msg": "ok"}
@router.put("/{plugin_id}", response_model=PluginOut)
async def update_plugin(plugin_id: int, payload: PluginCreate):
rec = await database.Plugin.get_or_none(id=plugin_id)
if not rec:
raise HTTPException(status_code=404, detail="Plugin not found")
rec.url = payload.url
rec.enabled = payload.enabled
await rec.save()
return PluginOut.model_validate(rec)
@router.post("/{plugin_id}/metadata", response_model=PluginOut)
async def update_manifest(plugin_id: int, manifest: Dict[str, Any] = Body(...)):
rec = await database.Plugin.get_or_none(id=plugin_id)
if not rec:
raise HTTPException(status_code=404, detail="Plugin not found")
key_map = {
'key': 'key',
'name': 'name',
'version': 'version',
'supported_exts': 'supported_exts',
'supportedExts': 'supported_exts',
'default_bounds': 'default_bounds',
'defaultBounds': 'default_bounds',
'default_maximized': 'default_maximized',
'defaultMaximized': 'default_maximized',
'icon': 'icon',
'description': 'description',
'author': 'author',
'website': 'website',
'github': 'github',
}
for k, v in list(manifest.items()):
if v is None:
continue
attr = key_map.get(k)
if not attr:
continue
setattr(rec, attr, v)
await rec.save()
return PluginOut.model_validate(rec)

View File

@@ -81,3 +81,29 @@ class ShareLink(Model):
class Meta:
table = "share_links"
class Plugin(Model):
id = fields.IntField(pk=True)
url = fields.CharField(max_length=2048)
enabled = fields.BooleanField(default=True)
key = fields.CharField(max_length=100, null=True)
name = fields.CharField(max_length=255, null=True)
version = fields.CharField(max_length=50, null=True)
supported_exts = fields.JSONField(null=True)
default_bounds = fields.JSONField(null=True)
default_maximized = fields.BooleanField(null=True)
icon = fields.CharField(max_length=2048, null=True)
description = fields.TextField(null=True)
author = fields.CharField(max_length=255, null=True)
website = fields.CharField(max_length=2048, null=True)
github = fields.CharField(max_length=2048, null=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "plugins"

View File

@@ -1,7 +1,10 @@
from schemas.plugins import PluginCreate,PluginOut
from .adapters import AdapterCreate, AdapterOut
from .fs import MkdirRequest, MoveRequest
__all__ = [
"PluginOut"
"PluginCreate"
"AdapterCreate",
"AdapterOut",
"MkdirRequest",

27
schemas/plugins.py Normal file
View File

@@ -0,0 +1,27 @@
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
class PluginCreate(BaseModel):
url: str = Field(min_length=1)
enabled: bool = True
class PluginOut(BaseModel):
id: int
url: str
enabled: bool
key: Optional[str]
name: Optional[str]
version: Optional[str]
supported_exts: Optional[List[str]]
default_bounds: Optional[Dict[str, Any]]
default_maximized: Optional[bool]
icon: Optional[str]
description: Optional[str]
author: Optional[str]
website: Optional[str]
github: Optional[str]
class Config:
from_attributes = True

46
web/src/api/plugins.ts Normal file
View File

@@ -0,0 +1,46 @@
import request from './client';
export interface PluginItem {
id: number;
url: string;
enabled: boolean;
key?: string | null;
name?: string | null;
version?: string | null;
supported_exts?: string[] | null;
default_bounds?: Record<string, any> | null;
default_maximized?: boolean | null;
icon?: string | null;
description?: string | null;
author?: string | null;
website?: string | null;
github?: string | null;
}
export interface PluginCreate {
url: string;
enabled?: boolean;
}
export interface PluginManifestUpdate {
key?: string;
name?: string;
version?: string;
supported_exts?: string[];
default_bounds?: Record<string, any>;
default_maximized?: boolean;
icon?: string;
description?: string;
author?: string;
website?: string;
github?: string;
}
export const pluginsApi = {
list: () => request<PluginItem[]>(`/plugins`),
create: (payload: PluginCreate) => request<PluginItem>(`/plugins`, { method: 'POST', json: payload }),
remove: (id: number) => request(`/plugins/${id}`, { method: 'DELETE' }),
update: (id: number, payload: PluginCreate) => request<PluginItem>(`/plugins/${id}`, { method: 'PUT', json: payload }),
updateManifest: (id: number, payload: PluginManifestUpdate) => request<PluginItem>(`/plugins/${id}/metadata`, { method: 'POST', json: payload }),
};

View File

@@ -0,0 +1,57 @@
import React, { useRef, useState } from 'react';
import type { AppComponentProps } from '../types';
import { vfsApi } from '../../api/vfs';
import { loadPluginFromUrl, ensureManifest, type RegisteredPlugin } from '../../plugins/runtime';
import type { PluginItem } from '../../api/plugins';
import { useAsyncSafeEffect } from '../../hooks/useAsyncSafeEffect';
export interface PluginAppHostProps extends AppComponentProps {
plugin: PluginItem;
}
export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath, entry, onRequestClose }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const onCloseRef = useRef(onRequestClose);
onCloseRef.current = onRequestClose;
const pluginRef = useRef<RegisteredPlugin | null>(null);
useAsyncSafeEffect(
async ({ isDisposed }) => {
try {
const p = await loadPluginFromUrl(plugin.url);
if (isDisposed()) return;
pluginRef.current = p;
await ensureManifest(plugin.id, p);
if (isDisposed()) return;
const token = await vfsApi.getTempLinkToken(filePath);
if (isDisposed()) return;
const downloadUrl = vfsApi.getTempPublicUrl(token.token);
if (isDisposed() || !containerRef.current) return;
await p.mount(containerRef.current, {
filePath,
entry,
urls: { downloadUrl },
host: { close: () => onCloseRef.current() },
});
} catch (e: any) {
if (!isDisposed()) setError(e?.message || '插件运行失败');
}
},
[plugin.id, plugin.url, filePath],
() => {
try {
if (pluginRef.current?.unmount && containerRef.current) {
pluginRef.current.unmount(containerRef.current);
}
} catch {}
},
);
if (error) {
return <div style={{ padding: 12, color: 'red' }}>: {error}</div>;
}
return <div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
};

View File

@@ -1,9 +1,11 @@
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';
const apps: AppDescriptor[] = [];
// 使用 import.meta.glob 动态导入所有应用
// vite-glob-ignore
const appModules = import.meta.glob('./*/index.ts');
async function loadApps() {
@@ -16,11 +18,34 @@ async function loadApps() {
}
}
}
try {
const items = await pluginsApi.list();
items.filter(p => p.enabled !== false).forEach((p) => registerPluginAsApp(p));
} catch (e) {
}
}
// 立即加载并注册所有应用
loadApps();
function registerPluginAsApp(p: PluginItem) {
const key = 'plugin:' + p.id;
if (apps.find(a => a.key === key)) return;
const supported = (entry: VfsEntry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
if (!p.supported_exts || p.supported_exts.length === 0) return true;
return p.supported_exts.includes(ext);
};
apps.push({
key,
name: p.name || `插件 ${p.id}`,
supported,
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }),
default: false,
defaultBounds: p.default_bounds || undefined,
defaultMaximized: p.default_maximized || undefined,
});
}
loadApps();
export function getAppsForEntry(entry: VfsEntry): AppDescriptor[] {
return apps.filter(a => a.supported(entry));
@@ -43,3 +68,27 @@ export function getDefaultAppForEntry(entry: VfsEntry): AppDescriptor | undefine
export type { AppDescriptor };
export type { AppComponentProps } from './types';
export async function reloadPluginApps() {
try {
const items = await pluginsApi.list();
const keepKeys = new Set(items.filter(p => p.enabled !== false).map(p => 'plugin:' + p.id));
for (let i = apps.length - 1; i >= 0; i--) {
const a = apps[i];
if (a.key.startsWith('plugin:') && !keepKeys.has(a.key)) {
apps.splice(i, 1);
}
}
items.filter(p => p.enabled !== false).forEach(p => {
const key = 'plugin:' + p.id;
const existing = apps.find(a => a.key === key);
if (!existing) {
registerPluginAsApp(p);
} else {
existing.name = p.name || `插件 ${p.id}`;
existing.defaultBounds = p.default_bounds || undefined;
existing.defaultMaximized = p.default_maximized || undefined;
}
});
} catch { }
}

View File

@@ -0,0 +1,35 @@
import React, { useEffect } from 'react';
export interface AsyncEffectCtx {
isDisposed: () => boolean;
signal: AbortSignal;
}
export function useAsyncSafeEffect(
effect: (ctx: AsyncEffectCtx) => void | Promise<void>,
deps: React.DependencyList,
cleanup?: (ctx: AsyncEffectCtx) => void,
) {
useEffect(() => {
let disposed = false;
const ac = new AbortController();
const ctx: AsyncEffectCtx = {
isDisposed: () => disposed,
signal: ac.signal,
};
Promise.resolve(effect(ctx)).catch(() => {
// 故意忽略 effect 内部抛出的异常,交由调用方处理
});
return () => {
disposed = true;
try {
cleanup?.(ctx);
} finally {
ac.abort();
}
};
}, deps);
}

View File

@@ -8,6 +8,7 @@ import {
RobotOutlined,
BugOutlined,
DatabaseOutlined,
AppstoreOutlined,
} from '@ant-design/icons';
import type { ReactNode } from 'react';
@@ -30,6 +31,7 @@ export const navGroups: NavGroup[] = [
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: '我的分享' },
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: '离线下载' },
{ key: 'adapters', icon: React.createElement(ApiOutlined), label: '存储挂载' },
{ key: 'plugins', icon: React.createElement(AppstoreOutlined), label: '应用中心' },
]
},
{

View File

@@ -0,0 +1,157 @@
import { memo, useEffect, useMemo, useState } from 'react';
import { Button, Modal, Form, Input, Tag, message, Card, Typography, Popconfirm, Empty, Skeleton, theme, Divider } from 'antd';
import { GithubOutlined, LinkOutlined } from '@ant-design/icons';
import { pluginsApi, type PluginItem } from '../api/plugins';
import { loadPluginFromUrl, ensureManifest } from '../plugins/runtime';
import { reloadPluginApps } from '../apps/registry';
const PluginsPage = memo(function PluginsPage() {
const [data, setData] = useState<PluginItem[]>([]);
const [adding, setAdding] = useState(false);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState('');
const [form] = Form.useForm<{ url: string }>();
const { token } = theme.useToken();
const reload = async () => {
try { setLoading(true); setData(await pluginsApi.list()); } finally { setLoading(false); }
};
useEffect(() => { reload(); }, []);
const handleAdd = async () => {
try {
const { url } = await form.validateFields();
const created = await pluginsApi.create({ url });
try {
const p = await loadPluginFromUrl(created.url);
await ensureManifest(created.id, p);
} catch {}
setAdding(false);
form.resetFields();
await reload();
await reloadPluginApps();
message.success('安装成功');
} catch {}
};
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
if (!s) return data;
return data.filter(p => (
(p.name || '').toLowerCase().includes(s)
|| (p.author || '').toLowerCase().includes(s)
|| (p.url || '').toLowerCase().includes(s)
|| (p.description || '').toLowerCase().includes(s)
|| (p.supported_exts || []).some(e => e.toLowerCase().includes(s))
));
}, [data, q]);
const renderCard = (p: PluginItem) => {
const icon = p.icon || '/plugins/demo-text-viewer.svg';
const name = p.name || `插件 ${p.id}`;
const exts = (p.supported_exts || []).slice(0, 6);
const more = (p.supported_exts || []).length - exts.length;
const title = (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<img src={icon} alt={name} style={{ width: 24, height: 24, objectFit: 'contain' }} onError={(e) => { (e.currentTarget as HTMLImageElement).src = '/plugins/demo-text-viewer.svg'; }} />
<span>{name}</span>
{p.version && <Tag color="blue" style={{ marginLeft: 'auto' }}>{p.version}</Tag>}
</div>
);
return (
<Card
key={p.id}
title={title}
hoverable
size="small"
styles={{ body: { padding: 12 } } as any}
style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }}
actions={[
<a key="open" href={p.url} target="_blank" rel="noreferrer"></a>,
<Button key="copy" type="link" size="small" onClick={async () => { try { await navigator.clipboard.writeText(p.url); message.success('已复制链接'); } catch {} }}></Button>,
<Popconfirm key="del" title="确认删除该插件?" onConfirm={async () => { await pluginsApi.remove(p.id); await reload(); await reloadPluginApps(); }}>
<Button type="link" danger size="small"></Button>
</Popconfirm>
]}
>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<Typography.Paragraph style={{ marginBottom: 8 }} ellipsis={{ rows: 2 }}>
{p.description || '(暂无描述)'}
</Typography.Paragraph>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{(exts.length > 0 ? exts : ['任意']).map(e => <Tag key={e}>{e}</Tag>)}
{more > 0 && <Tag>+{more}</Tag>}
</div>
<Divider style={{ margin: '8px 0' }} />
{(p.author || p.github || p.website) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: token.colorTextTertiary, fontSize: 12 }}>
{p.author && <span>: {p.author}</span>}
<span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{p.github && (
<a href={p.github || undefined} target="_blank" rel="noreferrer" title="GitHub">
<GithubOutlined style={{ fontSize: 16, color: token.colorTextTertiary }} />
</a>
)}
{p.website && (
<a href={p.website || undefined} target="_blank" rel="noreferrer" title="官网">
<LinkOutlined style={{ fontSize: 16, color: token.colorTextTertiary }} />
</a>
)}
</span>
</div>
)}
</div>
</div>
</Card>
);
};
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<Button type="primary" onClick={() => setAdding(true)}></Button>
<Button onClick={reload} loading={loading}></Button>
<Input
placeholder="搜索 名称/作者/链接/扩展名"
value={q}
onChange={e => setQ(e.target.value)}
allowClear
style={{ maxWidth: 320, marginLeft: 'auto' }}
onPressEnter={() => reload()}
/>
</div>
{loading ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} style={{ borderRadius: 10 }}>
<Skeleton active avatar paragraph={{ rows: 3 }} />
</Card>
))}
</div>
) : filtered.length === 0 ? (
<Empty description="暂无插件" />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
{filtered.map(renderCard)}
</div>
)}
<Modal
title="安装应用"
open={adding}
onCancel={() => setAdding(false)}
onOk={handleAdd}
okText="安装"
>
<Form form={form} layout="vertical">
<Form.Item name="url" label="应用链接" rules={[{ required: true }, { type: 'url', message: '请输入合法的 URL' }]}>
<Input placeholder="https://example.com/plugin.js" />
</Form.Item>
</Form>
</Modal>
</>
);
});
export default PluginsPage;

120
web/src/plugins/runtime.ts Normal file
View File

@@ -0,0 +1,120 @@
import { pluginsApi, type PluginManifestUpdate } from '../api/plugins';
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>;
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;
}
const loadedPlugins = new Map<string, RegisteredPlugin>();
const waiters = new Map<string, ((p: RegisteredPlugin) => void)[]>();
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));
waiters.delete(pendingUrl);
sessionStorage.removeItem('foxel:pendingPluginUrl');
} else {
const anyUrl = Array.from(waiters.keys())[0];
if (anyUrl) {
loadedPlugins.set(anyUrl, plugin);
const resolvers = waiters.get(anyUrl) || [];
resolvers.forEach(fn => fn(plugin));
waiters.delete(anyUrl);
}
}
};
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);
waiters.set(url, arr);
const ready = loadedPlugins.get(url);
if (ready) {
const resolvers = waiters.get(url) || [];
resolvers.forEach(fn => fn(ready));
waiters.delete(url);
return;
}
sessionStorage.setItem('foxel:pendingPluginUrl', url);
if (!injected.has(url)) {
injected.add(url);
const script = document.createElement('script');
script.src = url;
script.async = true;
script.onerror = () => {
waiters.delete(url);
reject(new Error('Failed to load plugin script: ' + url));
};
document.head.appendChild(script);
}
const t = setTimeout(() => {
if (!loadedPlugins.get(url)) {
waiters.delete(url);
reject(new Error('Plugin did not call FoxelRegister: ' + url));
}
}, 15000);
const last = arr[arr.length - 1];
arr[arr.length - 1] = (p: RegisteredPlugin) => { clearTimeout(t); last(p); };
});
}
export async function ensureManifest(pluginId: number, plugin: RegisteredPlugin) {
const manifest: PluginManifestUpdate = {
key: plugin.key,
name: plugin.name,
version: plugin.version,
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 { }
const key = `foxel:manifestReported:${pluginId}`;
if (sessionStorage.getItem(key) === '1') return;
try {
await pluginsApi.updateManifest(pluginId, manifest);
sessionStorage.setItem(key, '1');
} catch {
}
}

View File

@@ -11,6 +11,7 @@ import OfflineDownloadPage from '../pages/OfflineDownloadPage.tsx';
import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.tsx';
import LogsPage from '../pages/LogsPage.tsx';
import BackupPage from '../pages/SystemSettingsPage/BackupPage.tsx';
import PluginsPage from '../pages/PluginsPage.tsx';
const LayoutShell = memo(function LayoutShell() {
const { navKey = 'files' } = useParams();
@@ -34,10 +35,10 @@ const LayoutShell = memo(function LayoutShell() {
{navKey === 'share' && <SharePage />}
{navKey === 'tasks' && <TasksPage />}
{navKey === 'offline' && <OfflineDownloadPage />}
{navKey === 'plugins' && <PluginsPage />}
{navKey === 'settings' && <SystemSettingsPage />}
{navKey === 'logs' && <LogsPage />}
{navKey === 'backup' && <BackupPage />}
{!['adapters','files','image','video','doc','fav','recent','recycle','share','tasks','offline','settings', 'logs', 'backup'].includes(navKey!) && <FileExplorerPage />}
</Flex>
</div>
</Layout.Content>