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

@@ -10,7 +10,7 @@ from models.database import Configuration, UserAccount
load_dotenv(dotenv_path=".env")
VERSION = "v1.4.0"
VERSION = "v1.5.0"
class ConfigService:

View File

@@ -1,6 +1,7 @@
from typing import List
from fastapi import APIRouter, Body, Request
from fastapi.responses import FileResponse
from domain.audit import AuditAction, audit
from domain.plugins.service import PluginService
@@ -50,6 +51,7 @@ async def update_plugin(request: Request, plugin_id: int, payload: PluginCreate)
"key",
"name",
"version",
"open_app",
"supported_exts",
"default_bounds",
"default_maximized",
@@ -64,3 +66,9 @@ async def update_manifest(
request: Request, plugin_id: int, manifest: PluginManifestUpdate = Body(...)
):
return await PluginService.update_manifest(plugin_id, manifest)
@router.get("/{plugin_id}/bundle.js")
async def get_bundle(request: Request, plugin_id: int):
path = await PluginService.get_bundle_path(plugin_id)
return FileResponse(path, media_type="application/javascript", headers={"Cache-Control": "no-store"})

View File

@@ -1,3 +1,10 @@
import contextlib
import re
import shutil
from pathlib import Path
import aiofiles
import httpx
from fastapi import HTTPException
from domain.plugins.types import PluginCreate, PluginManifestUpdate, PluginOut
@@ -5,9 +12,71 @@ from models.database import Plugin
class PluginService:
_plugins_root = Path("data/plugins")
@classmethod
def _folder_name(cls, rec: Plugin) -> str:
if rec.key:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", rec.key)
return safe or str(rec.id)
return str(rec.id)
@classmethod
def _bundle_dir_from_rec(cls, rec: Plugin) -> Path:
return cls._plugins_root / cls._folder_name(rec) / "current"
@classmethod
def _bundle_path_from_rec(cls, rec: Plugin) -> Path:
return cls._bundle_dir_from_rec(rec) / "index.js"
@classmethod
async def _download_bundle(cls, rec: Plugin, url: str) -> None:
dest_dir = cls._bundle_dir_from_rec(rec)
dest_dir.mkdir(parents=True, exist_ok=True)
dest_path = cls._bundle_path_from_rec(rec)
tmp_path = dest_path.with_suffix(".tmp")
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
async with client.stream("GET", url) as resp:
resp.raise_for_status()
async with aiofiles.open(tmp_path, "wb") as f:
async for chunk in resp.aiter_bytes(chunk_size=65536):
if not chunk:
continue
await f.write(chunk)
tmp_path.replace(dest_path)
except Exception:
with contextlib.suppress(Exception):
if tmp_path.exists():
tmp_path.unlink()
raise
@classmethod
async def _ensure_bundle(cls, plugin_id: int) -> Path:
rec = await cls._get_or_404(plugin_id)
bundle_path = cls._bundle_path_from_rec(rec)
if bundle_path.exists():
return bundle_path
legacy = cls._plugins_root / str(rec.id) / "current" / "index.js"
if legacy.exists():
return legacy
raise HTTPException(status_code=404, detail="Plugin bundle not found")
@classmethod
async def get_bundle_path(cls, plugin_id: int) -> Path:
return await cls._ensure_bundle(plugin_id)
@classmethod
async def create(cls, payload: PluginCreate) -> PluginOut:
rec = await Plugin.create(**payload.model_dump())
try:
await cls._download_bundle(rec, rec.url)
except Exception as exc:
with contextlib.suppress(Exception):
await rec.delete()
raise HTTPException(status_code=400, detail=f"Failed to fetch plugin: {exc}")
return PluginOut.model_validate(rec)
@classmethod
@@ -26,10 +95,21 @@ class PluginService:
async def delete(cls, plugin_id: int) -> None:
rec = await cls._get_or_404(plugin_id)
await rec.delete()
with contextlib.suppress(Exception):
dirs = {cls._bundle_dir_from_rec(rec).parent, cls._plugins_root / str(rec.id)}
for plugin_dir in dirs:
if plugin_dir.exists():
shutil.rmtree(plugin_dir)
@classmethod
async def update(cls, plugin_id: int, payload: PluginCreate) -> PluginOut:
rec = await cls._get_or_404(plugin_id)
url_changed = rec.url != payload.url
if url_changed:
try:
await cls._download_bundle(rec, payload.url)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Failed to fetch plugin: {exc}")
rec.url = payload.url
rec.enabled = payload.enabled
await rec.save()
@@ -40,9 +120,19 @@ class PluginService:
cls, plugin_id: int, manifest: PluginManifestUpdate
) -> PluginOut:
rec = await cls._get_or_404(plugin_id)
old_dir = cls._bundle_dir_from_rec(rec).parent
updates = manifest.model_dump(exclude_none=True)
if updates:
for key, value in updates.items():
setattr(rec, key, value)
await rec.save()
new_dir = cls._bundle_dir_from_rec(rec).parent
if rec.key and new_dir != old_dir:
candidate_dir = old_dir if old_dir.exists() else (cls._plugins_root / str(rec.id))
if candidate_dir.exists():
new_dir.parent.mkdir(parents=True, exist_ok=True)
with contextlib.suppress(Exception):
if new_dir.exists():
shutil.rmtree(new_dir)
shutil.move(str(candidate_dir), str(new_dir))
return PluginOut.model_validate(rec)

View File

@@ -14,6 +14,10 @@ class PluginManifestUpdate(BaseModel):
key: Optional[str] = None
name: Optional[str] = None
version: Optional[str] = None
open_app: Optional[bool] = Field(
default=None,
validation_alias=AliasChoices("open_app", "openApp"),
)
supported_exts: Optional[List[str]] = Field(
default=None,
validation_alias=AliasChoices("supported_exts", "supportedExts"),
@@ -37,6 +41,7 @@ class PluginOut(BaseModel):
id: int
url: str
enabled: bool
open_app: bool = False
key: Optional[str] = None
name: Optional[str] = None
version: Optional[str] = None

View File

@@ -171,6 +171,8 @@ class Plugin(Model):
url = fields.CharField(max_length=2048)
enabled = fields.BooleanField(default=True)
open_app = fields.BooleanField(default=False)
key = fields.CharField(max_length=100, null=True)
name = fields.CharField(max_length=255, null=True)
version = fields.CharField(max_length=50, null=True)

View File

@@ -4,6 +4,7 @@ export interface PluginItem {
id: number;
url: string;
enabled: boolean;
open_app?: boolean | null;
key?: string | null;
name?: string | null;
version?: string | null;
@@ -26,6 +27,7 @@ export interface PluginManifestUpdate {
key?: string;
name?: string;
version?: string;
open_app?: boolean;
supported_exts?: string[];
default_bounds?: Record<string, any>;
default_maximized?: boolean;
@@ -43,4 +45,3 @@ export const pluginsApi = {
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

@@ -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;

View File

@@ -5,28 +5,39 @@ import type { AppDescriptor } from '../apps/registry';
import { getAppsForEntry, getDefaultAppForEntry, getAppByKey } from '../apps/registry';
import { useI18n } from '../i18n';
export interface AppWindowItem {
type WindowBase = {
id: string;
app: AppDescriptor;
entry: VfsEntry;
filePath: string;
maximized: boolean;
minimized: boolean;
x: number;
y: number;
width: number;
height: number;
}
};
export type AppWindowItem =
| (WindowBase & {
kind: 'file';
entry: VfsEntry;
filePath: string;
})
| (WindowBase & {
kind: 'app';
});
type AppWindowPatch = Partial<Pick<AppWindowItem, 'maximized' | 'minimized' | 'x' | 'y' | 'width' | 'height'>>;
interface AppWindowsContextValue {
windows: AppWindowItem[];
openWithApp: (entry: VfsEntry, app: AppDescriptor, currentPath: string) => void;
openFileWithDefaultApp: (entry: VfsEntry, currentPath: string) => void;
confirmOpenWithApp: (entry: VfsEntry, appKey: string, currentPath: string) => void;
openApp: (app: AppDescriptor) => void;
closeWindow: (id: string) => void;
toggleMax: (id: string) => void;
bringToFront: (id: string) => void;
updateWindow: (id: string, patch: Partial<Omit<AppWindowItem, 'id' | 'app' | 'entry' | 'filePath'>>) => void;
updateWindow: (id: string, patch: AppWindowPatch) => void;
minimizeWindow: (id: string) => void;
restoreWindow: (id: string) => void;
toggleMinimize: (id: string) => void;
@@ -57,6 +68,7 @@ export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ ch
...ws,
{
id: Date.now().toString(36) + Math.random().toString(36).slice(2),
kind: 'file',
app,
entry,
filePath: fullPath,
@@ -71,6 +83,40 @@ export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ ch
});
}, []);
const openApp = useCallback((app: AppDescriptor) => {
if (!app.openAppComponent) {
return;
}
setWindows(ws => {
const idx = ws.length;
const bounds = app.defaultBounds || {};
const baseX = bounds.x ?? (160 + idx * 32);
const baseY = bounds.y ?? (100 + idx * 28);
const baseW = bounds.width ?? 640;
const baseH = bounds.height ?? 480;
const vw = window.innerWidth;
const vh = window.innerHeight;
const finalW = Math.min(baseW, vw - 40);
const finalH = Math.min(baseH, vh - 60);
const finalX = Math.min(Math.max(0, baseX), vw - finalW - 8);
const finalY = Math.min(Math.max(48, baseY), vh - finalH - 8);
return [
...ws,
{
id: Date.now().toString(36) + Math.random().toString(36).slice(2),
kind: 'app',
app,
maximized: !!app.defaultMaximized,
minimized: false,
x: finalX,
y: finalY,
width: finalW,
height: finalH,
},
];
});
}, []);
const openFileWithDefaultApp = useCallback((entry: VfsEntry, currentPath: string) => {
const apps = getAppsForEntry(entry);
if (!apps.length) {
@@ -115,10 +161,8 @@ export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ ch
if (!target) return ws;
return [...ws.filter(w => w.id !== id), target];
});
const updateWindow = (
id: string,
patch: Partial<Omit<AppWindowItem, 'id' | 'app' | 'entry' | 'filePath'>>,
) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, ...patch } : w)));
const updateWindow = (id: string, patch: AppWindowPatch) =>
setWindows(ws => ws.map(w => (w.id === id ? { ...w, ...patch } : w)));
const minimizeWindow = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, minimized: true } : w)));
const restoreWindow = (id: string) => setWindows(ws => {
@@ -134,6 +178,7 @@ export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ ch
openWithApp,
openFileWithDefaultApp,
confirmOpenWithApp,
openApp,
closeWindow,
toggleMax,
bringToFront,
@@ -141,7 +186,7 @@ export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ ch
minimizeWindow,
restoreWindow,
toggleMinimize,
}), [windows, openWithApp, openFileWithDefaultApp, confirmOpenWithApp]);
}), [windows, openWithApp, openFileWithDefaultApp, confirmOpenWithApp, openApp]);
return <AppWindowsContext.Provider value={value}>{children}</AppWindowsContext.Provider>;
};
@@ -151,4 +196,3 @@ export function useAppWindows() {
if (!ctx) throw new Error('useAppWindows must be used within AppWindowsProvider');
return ctx;
}

View File

@@ -605,6 +605,8 @@
"Open Link": "Open Link",
"Link copied": "Link copied",
"Copy Link": "Copy Link",
"Open App": "Open App",
"Update App": "Update App",
"Confirm delete this plugin?": "Confirm delete this plugin?",
"Author": "Author",
"Website": "Website",

View File

@@ -598,6 +598,8 @@
"Open Link": "打开链接",
"Link copied": "已复制链接",
"Copy Link": "复制链接",
"Open App": "打开应用",
"Update App": "更新应用",
"Confirm delete this plugin?": "确认删除该插件?",
"Author": "作者",
"Website": "官网",

View File

@@ -180,14 +180,15 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
overflowY: collapsed ? 'auto' : 'visible',
}}
>
{minimized.map(w => {
const src = w.app.iconUrl || DEFAULT_APP_ICON;
return (
<Tooltip key={w.id} title={`${w.app.name} - ${w.entry.name}`} placement={collapsed ? 'right' : 'top'}>
<Button
shape="circle"
onClick={() => restoreWindow(w.id)}
icon={<img src={src} alt={w.app.name} style={{ width: 16, height: 16 }} />}
{minimized.map(w => {
const src = w.app.iconUrl || DEFAULT_APP_ICON;
const title = w.kind === 'file' ? `${w.app.name} - ${w.entry.name}` : w.app.name;
return (
<Tooltip key={w.id} title={title} placement={collapsed ? 'right' : 'top'}>
<Button
shape="circle"
onClick={() => restoreWindow(w.id)}
icon={<img src={src} alt={w.app.name} style={{ width: 16, height: 16 }} />}
/>
</Tooltip>
);

View File

@@ -2,10 +2,11 @@ import { memo, useEffect, useMemo, useState } from 'react';
import { Button, Modal, Form, Input, Tag, message, Card, Typography, Popconfirm, Empty, Skeleton, theme, Divider, Tabs, Select, Pagination } from 'antd';
import { GithubOutlined, LinkOutlined } from '@ant-design/icons';
import { pluginsApi, type PluginItem } from '../api/plugins';
import { loadPluginFromUrl, ensureManifest } from '../plugins/runtime';
import { reloadPluginApps, ensureAppsLoaded, listSystemApps, type AppDescriptor } from '../apps/registry';
import { loadPlugin, ensureManifest } from '../plugins/runtime';
import { getAppByKey, reloadPluginApps, ensureAppsLoaded, listSystemApps, type AppDescriptor } from '../apps/registry';
import { useI18n } from '../i18n';
import { fetchRepoList, type RepoItem, buildCenterUrl } from '../api/pluginCenter';
import { useAppWindows } from '../contexts/AppWindowsContext';
const PluginsPage = memo(function PluginsPage() {
const [data, setData] = useState<PluginItem[]>([]);
@@ -25,6 +26,7 @@ const PluginsPage = memo(function PluginsPage() {
const [form] = Form.useForm<{ url: string }>();
const { token } = theme.useToken();
const { t } = useI18n();
const { openApp } = useAppWindows();
const reload = async () => {
try { setLoading(true); setData(await pluginsApi.list()); } finally { setLoading(false); }
@@ -69,7 +71,7 @@ const PluginsPage = memo(function PluginsPage() {
const { url } = await form.validateFields();
const created = await pluginsApi.create({ url });
try {
const p = await loadPluginFromUrl(created.url);
const p = await loadPlugin(created);
await ensureManifest(created.id, p);
} catch {}
setAdding(false);
@@ -111,6 +113,8 @@ const PluginsPage = memo(function PluginsPage() {
const name = p.name || `${t('Plugin')} ${p.id}`;
const exts = (p.supported_exts || []).slice(0, 6);
const more = (p.supported_exts || []).length - exts.length;
const app = getAppByKey('plugin:' + p.id);
const canOpenApp = !!p.open_app;
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'; }} />
@@ -127,8 +131,23 @@ const PluginsPage = memo(function PluginsPage() {
styles={{ body: { padding: 12 } } as any}
style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }}
actions={[
<a key="open" href={p.url} target="_blank" rel="noreferrer">{t('Open Link')}</a>,
<Button key="copy" type="link" size="small" onClick={async () => { try { await navigator.clipboard.writeText(p.url); message.success(t('Link copied')); } catch {} }}>{t('Copy Link')}</Button>,
<Button
key="open-app"
type="link"
size="small"
disabled={!canOpenApp}
onClick={async () => {
let target = app || getAppByKey('plugin:' + p.id);
if (!target) {
await reloadPluginApps();
target = getAppByKey('plugin:' + p.id);
}
if (target?.openAppComponent) openApp(target);
}}
>
{t('Open App')}
</Button>,
<Button key="update-app" type="link" size="small" onClick={() => message.info(t('Coming soon'))}>{t('Update App')}</Button>,
<Popconfirm key="del" title={t('Confirm delete this plugin?')} onConfirm={async () => { await pluginsApi.remove(p.id); await reload(); await reloadPluginApps(); }}>
<Button type="link" danger size="small">{t('Delete')}</Button>
</Popconfirm>
@@ -177,7 +196,6 @@ const PluginsPage = memo(function PluginsPage() {
const name = a.name || a.key;
const exts = (a.supportedExts || []).slice(0, 6);
const more = (a.supportedExts || []).length - exts.length;
const link = a.website || a.github || '';
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'; }} />
@@ -195,26 +213,15 @@ const PluginsPage = memo(function PluginsPage() {
style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }}
actions={[
<Button
key="open"
key="open-app"
type="link"
size="small"
disabled={!link}
onClick={() => { if (link) window.open(link, '_blank', 'noreferrer'); }}
disabled={!a.openAppComponent}
onClick={() => openApp(a)}
>
{t('Open Link')}
</Button>,
<Button
key="copy"
type="link"
size="small"
disabled={!link}
onClick={async () => {
if (!link) return;
try { await navigator.clipboard.writeText(link); message.success(t('Link copied')); } catch {}
}}
>
{t('Copy Link')}
{t('Open App')}
</Button>,
<Button key="update-app" type="link" size="small" disabled onClick={() => message.info(t('Coming soon'))}>{t('Update App')}</Button>,
<Button key="del" type="link" danger size="small" disabled>{t('Delete')}</Button>
]}
>
@@ -281,7 +288,7 @@ const PluginsPage = memo(function PluginsPage() {
const url = buildCenterUrl(item.directUrl);
const created = await pluginsApi.create({ url });
try {
const p = await loadPluginFromUrl(created.url);
const p = await loadPlugin(created);
await ensureManifest(created.id, p);
} catch {}
await reload();

View File

@@ -1,4 +1,4 @@
import { pluginsApi, type PluginManifestUpdate } from '../api/plugins';
import { pluginsApi, type PluginManifestUpdate, type PluginItem } from '../api/plugins';
export interface RegisteredPlugin {
mount: (container: HTMLElement, ctx: {
@@ -9,6 +9,9 @@ export interface RegisteredPlugin {
}) => void | Promise<void>;
unmount?: (container: HTMLElement) => void | Promise<void>;
mountApp?: (container: HTMLElement, ctx: { host: HostApi }) => void | Promise<void>;
unmountApp?: (container: HTMLElement) => void | Promise<void>;
key?: string;
name?: string;
version?: string;
@@ -95,11 +98,32 @@ export async function loadPluginFromUrl(url: string): Promise<RegisteredPlugin>
});
}
export function getPluginBundleUrl(pluginId: number) {
return `/api/plugins/${pluginId}/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;
}
}
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,