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
+1 -1
View File
@@ -10,7 +10,7 @@ from models.database import Configuration, UserAccount
load_dotenv(dotenv_path=".env") load_dotenv(dotenv_path=".env")
VERSION = "v1.4.0" VERSION = "v1.5.0"
class ConfigService: class ConfigService:
+8
View File
@@ -1,6 +1,7 @@
from typing import List from typing import List
from fastapi import APIRouter, Body, Request from fastapi import APIRouter, Body, Request
from fastapi.responses import FileResponse
from domain.audit import AuditAction, audit from domain.audit import AuditAction, audit
from domain.plugins.service import PluginService from domain.plugins.service import PluginService
@@ -50,6 +51,7 @@ async def update_plugin(request: Request, plugin_id: int, payload: PluginCreate)
"key", "key",
"name", "name",
"version", "version",
"open_app",
"supported_exts", "supported_exts",
"default_bounds", "default_bounds",
"default_maximized", "default_maximized",
@@ -64,3 +66,9 @@ async def update_manifest(
request: Request, plugin_id: int, manifest: PluginManifestUpdate = Body(...) request: Request, plugin_id: int, manifest: PluginManifestUpdate = Body(...)
): ):
return await PluginService.update_manifest(plugin_id, manifest) 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"})
+90
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 fastapi import HTTPException
from domain.plugins.types import PluginCreate, PluginManifestUpdate, PluginOut from domain.plugins.types import PluginCreate, PluginManifestUpdate, PluginOut
@@ -5,9 +12,71 @@ from models.database import Plugin
class PluginService: 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 @classmethod
async def create(cls, payload: PluginCreate) -> PluginOut: async def create(cls, payload: PluginCreate) -> PluginOut:
rec = await Plugin.create(**payload.model_dump()) 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) return PluginOut.model_validate(rec)
@classmethod @classmethod
@@ -26,10 +95,21 @@ class PluginService:
async def delete(cls, plugin_id: int) -> None: async def delete(cls, plugin_id: int) -> None:
rec = await cls._get_or_404(plugin_id) rec = await cls._get_or_404(plugin_id)
await rec.delete() 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 @classmethod
async def update(cls, plugin_id: int, payload: PluginCreate) -> PluginOut: async def update(cls, plugin_id: int, payload: PluginCreate) -> PluginOut:
rec = await cls._get_or_404(plugin_id) 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.url = payload.url
rec.enabled = payload.enabled rec.enabled = payload.enabled
await rec.save() await rec.save()
@@ -40,9 +120,19 @@ class PluginService:
cls, plugin_id: int, manifest: PluginManifestUpdate cls, plugin_id: int, manifest: PluginManifestUpdate
) -> PluginOut: ) -> PluginOut:
rec = await cls._get_or_404(plugin_id) rec = await cls._get_or_404(plugin_id)
old_dir = cls._bundle_dir_from_rec(rec).parent
updates = manifest.model_dump(exclude_none=True) updates = manifest.model_dump(exclude_none=True)
if updates: if updates:
for key, value in updates.items(): for key, value in updates.items():
setattr(rec, key, value) setattr(rec, key, value)
await rec.save() 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) return PluginOut.model_validate(rec)
+5
View File
@@ -14,6 +14,10 @@ class PluginManifestUpdate(BaseModel):
key: Optional[str] = None key: Optional[str] = None
name: Optional[str] = None name: Optional[str] = None
version: 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( supported_exts: Optional[List[str]] = Field(
default=None, default=None,
validation_alias=AliasChoices("supported_exts", "supportedExts"), validation_alias=AliasChoices("supported_exts", "supportedExts"),
@@ -37,6 +41,7 @@ class PluginOut(BaseModel):
id: int id: int
url: str url: str
enabled: bool enabled: bool
open_app: bool = False
key: Optional[str] = None key: Optional[str] = None
name: Optional[str] = None name: Optional[str] = None
version: Optional[str] = None version: Optional[str] = None
+2
View File
@@ -171,6 +171,8 @@ class Plugin(Model):
url = fields.CharField(max_length=2048) url = fields.CharField(max_length=2048)
enabled = fields.BooleanField(default=True) enabled = fields.BooleanField(default=True)
open_app = fields.BooleanField(default=False)
key = fields.CharField(max_length=100, null=True) key = fields.CharField(max_length=100, null=True)
name = fields.CharField(max_length=255, null=True) name = fields.CharField(max_length=255, null=True)
version = fields.CharField(max_length=50, null=True) version = fields.CharField(max_length=50, null=True)
+2 -1
View File
@@ -4,6 +4,7 @@ export interface PluginItem {
id: number; id: number;
url: string; url: string;
enabled: boolean; enabled: boolean;
open_app?: boolean | null;
key?: string | null; key?: string | null;
name?: string | null; name?: string | null;
version?: string | null; version?: string | null;
@@ -26,6 +27,7 @@ export interface PluginManifestUpdate {
key?: string; key?: string;
name?: string; name?: string;
version?: string; version?: string;
open_app?: boolean;
supported_exts?: string[]; supported_exts?: string[];
default_bounds?: Record<string, any>; default_bounds?: Record<string, any>;
default_maximized?: boolean; default_maximized?: boolean;
@@ -43,4 +45,3 @@ export const pluginsApi = {
update: (id: number, payload: PluginCreate) => request<PluginItem>(`/plugins/${id}`, { method: 'PUT', json: payload }), 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 }), updateManifest: (id: number, payload: PluginManifestUpdate) => request<PluginItem>(`/plugins/${id}/metadata`, { method: 'POST', json: payload }),
}; };
+43 -23
View File
@@ -1,14 +1,15 @@
import React, { useRef, useEffect, useCallback } from 'react'; import React, { useRef, useEffect, useCallback } from 'react';
import { Space, Button } from 'antd'; import { Space, Button } from 'antd';
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, MinusOutlined } from '@ant-design/icons'; 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'; import type { VfsEntry } from '../api/client';
export interface AppWindowItem { export interface AppWindowItem {
id: string; id: string;
app: AppDescriptor; app: AppDescriptor;
entry: VfsEntry; kind: 'file' | 'app';
filePath: string; entry?: VfsEntry;
filePath?: string;
maximized: boolean; maximized: boolean;
minimized: boolean; minimized: boolean;
x: number; x: number;
@@ -17,12 +18,14 @@ export interface AppWindowItem {
height: number; height: number;
} }
type AppWindowPatch = Partial<Pick<AppWindowItem, 'maximized' | 'minimized' | 'x' | 'y' | 'width' | 'height'>>;
interface AppWindowsLayerProps { interface AppWindowsLayerProps {
windows: AppWindowItem[]; windows: AppWindowItem[];
onClose: (id: string) => void; onClose: (id: string) => void;
onToggleMax: (id: string) => void; onToggleMax: (id: string) => void;
onBringToFront: (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 }) => { export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClose, onToggleMax, onBringToFront, onUpdateWindow }) => {
@@ -193,8 +196,17 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
return ( return (
<> <>
{visibleWindows.map((w, idx) => { {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 useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true
const titleText = isFileWindow ? `${w.app.name} - ${w.entry?.name || ''}` : w.app.name;
if (!ContentComp) {
return null;
}
if (!useSystemWindow) { if (!useSystemWindow) {
return ( return (
<div <div
@@ -223,16 +235,20 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
overflow: 'hidden', overflow: 'hidden',
background: 'transparent' background: 'transparent'
}} }}
> >
<AppComp {isFileWindow ? (
filePath={w.filePath} <ContentComp
entry={w.entry} filePath={w.filePath || ''}
onRequestClose={() => onClose(w.id)} entry={w.entry as VfsEntry}
/> onRequestClose={() => onClose(w.id)}
</div> />
</div> ) : (
); <ContentComp onRequestClose={() => onClose(w.id)} />
} )}
</div>
</div>
);
}
// 否则继续使用系统窗口渲染(不改动原有逻辑) // 否则继续使用系统窗口渲染(不改动原有逻辑)
const interacting = isInteracting(w.id); const interacting = isInteracting(w.id);
return ( return (
@@ -290,9 +306,9 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
paddingRight: 8, paddingRight: 8,
flex: 1 flex: 1
}} }}
> >
{w.app.name} - {w.entry.name} {titleText}
</span> </span>
<Space size={4}> <Space size={4}>
<Button <Button
type="text" type="text"
@@ -351,11 +367,15 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
}} }}
> >
{!w.maximized && resizeHandles(w)} {!w.maximized && resizeHandles(w)}
<AppComp {isFileWindow ? (
filePath={w.filePath} <ContentComp
entry={w.entry} filePath={w.filePath || ''}
onRequestClose={() => onClose(w.id)} entry={w.entry as VfsEntry}
/> onRequestClose={() => onClose(w.id)}
/>
) : (
<ContentComp onRequestClose={() => onClose(w.id)} />
)}
</div> </div>
</div> </div>
); );
+52 -3
View File
@@ -1,7 +1,7 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import type { AppComponentProps } from '../types'; import type { AppComponentProps, AppOpenComponentProps } from '../types';
import { vfsApi } from '../../api/vfs'; 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 type { PluginItem } from '../../api/plugins';
import { useAsyncSafeEffect } from '../../hooks/useAsyncSafeEffect'; import { useAsyncSafeEffect } from '../../hooks/useAsyncSafeEffect';
import { useI18n } from '../../i18n'; import { useI18n } from '../../i18n';
@@ -22,7 +22,7 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath,
useAsyncSafeEffect( useAsyncSafeEffect(
async ({ isDisposed }) => { async ({ isDisposed }) => {
try { try {
const p = await loadPluginFromUrl(plugin.url); const p = await loadPlugin(plugin);
if (isDisposed()) return; if (isDisposed()) return;
pluginRef.current = p; pluginRef.current = p;
await ensureManifest(plugin.id, 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' }} />; 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' }} />;
};
+2
View File
@@ -1,5 +1,6 @@
import type { AppDescriptor } from '../types'; import type { AppDescriptor } from '../types';
import { VideoPlayerApp } from './VideoPlayer.tsx'; import { VideoPlayerApp } from './VideoPlayer.tsx';
import { VideoLibraryApp } from './VideoLibrary.tsx';
const supportedExts = ['mp4','webm','ogg','m4v','mov','mkv','avi','wmv','flv','3gp']; 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', iconUrl: 'https://api.iconify.design/mdi:video.svg',
description: '内置视频播放器,支持常见视频格式播放。', description: '内置视频播放器,支持常见视频格式播放。',
author: 'Foxel', author: 'Foxel',
openAppComponent: VideoLibraryApp,
supportedExts, supportedExts,
supported: (entry) => { supported: (entry) => {
if (entry.is_dir) return false; if (entry.is_dir) return false;
+5 -1
View File
@@ -2,7 +2,7 @@ import type { VfsEntry } from '../api/client';
import type { AppDescriptor } from './types'; import type { AppDescriptor } from './types';
import React from 'react'; import React from 'react';
import { pluginsApi, type PluginItem } from '../api/plugins'; import { pluginsApi, type PluginItem } from '../api/plugins';
import { PluginAppHost } from './PluginHost'; import { PluginAppHost, PluginAppOpenHost } from './PluginHost';
const apps: AppDescriptor[] = []; const apps: AppDescriptor[] = [];
// 使用 import.meta.glob 动态导入所有应用 // 使用 import.meta.glob 动态导入所有应用
@@ -39,6 +39,7 @@ function registerPluginAsApp(p: PluginItem) {
name: p.name || `插件 ${p.id}`, name: p.name || `插件 ${p.id}`,
supported, supported,
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }), 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, iconUrl: p.icon || undefined,
default: false, default: false,
defaultBounds: p.default_bounds || undefined, defaultBounds: p.default_bounds || undefined,
@@ -98,6 +99,9 @@ export async function reloadPluginApps() {
existing.defaultBounds = p.default_bounds || undefined; existing.defaultBounds = p.default_bounds || undefined;
existing.defaultMaximized = p.default_maximized || undefined; existing.defaultMaximized = p.default_maximized || undefined;
existing.iconUrl = p.icon || existing.iconUrl; existing.iconUrl = p.icon || existing.iconUrl;
existing.openAppComponent = p.open_app
? ((props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props }))
: undefined;
} }
}); });
} catch { } } catch { }
+9
View File
@@ -6,11 +6,20 @@ export interface AppComponentProps {
onRequestClose: () => void; onRequestClose: () => void;
} }
export interface AppOpenComponentProps {
onRequestClose: () => void;
}
export interface AppDescriptor { export interface AppDescriptor {
key: string; key: string;
name: string; name: string;
supported: (entry: VfsEntry) => boolean; supported: (entry: VfsEntry) => boolean;
component: React.ComponentType<AppComponentProps>; component: React.ComponentType<AppComponentProps>;
/**
* 独立打开应用(不依赖文件)
* 缺省表示该应用仅支持“通过文件打开”。
*/
openAppComponent?: React.ComponentType<AppOpenComponentProps>;
iconUrl?: string; iconUrl?: string;
default?: boolean; default?: boolean;
defaultMaximized?: boolean; defaultMaximized?: boolean;
+55 -11
View File
@@ -5,28 +5,39 @@ import type { AppDescriptor } from '../apps/registry';
import { getAppsForEntry, getDefaultAppForEntry, getAppByKey } from '../apps/registry'; import { getAppsForEntry, getDefaultAppForEntry, getAppByKey } from '../apps/registry';
import { useI18n } from '../i18n'; import { useI18n } from '../i18n';
export interface AppWindowItem { type WindowBase = {
id: string; id: string;
app: AppDescriptor; app: AppDescriptor;
entry: VfsEntry;
filePath: string;
maximized: boolean; maximized: boolean;
minimized: boolean; minimized: boolean;
x: number; x: number;
y: number; y: number;
width: number; width: number;
height: 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 { interface AppWindowsContextValue {
windows: AppWindowItem[]; windows: AppWindowItem[];
openWithApp: (entry: VfsEntry, app: AppDescriptor, currentPath: string) => void; openWithApp: (entry: VfsEntry, app: AppDescriptor, currentPath: string) => void;
openFileWithDefaultApp: (entry: VfsEntry, currentPath: string) => void; openFileWithDefaultApp: (entry: VfsEntry, currentPath: string) => void;
confirmOpenWithApp: (entry: VfsEntry, appKey: string, currentPath: string) => void; confirmOpenWithApp: (entry: VfsEntry, appKey: string, currentPath: string) => void;
openApp: (app: AppDescriptor) => void;
closeWindow: (id: string) => void; closeWindow: (id: string) => void;
toggleMax: (id: string) => void; toggleMax: (id: string) => void;
bringToFront: (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; minimizeWindow: (id: string) => void;
restoreWindow: (id: string) => void; restoreWindow: (id: string) => void;
toggleMinimize: (id: string) => void; toggleMinimize: (id: string) => void;
@@ -57,6 +68,7 @@ export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ ch
...ws, ...ws,
{ {
id: Date.now().toString(36) + Math.random().toString(36).slice(2), id: Date.now().toString(36) + Math.random().toString(36).slice(2),
kind: 'file',
app, app,
entry, entry,
filePath: fullPath, 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 openFileWithDefaultApp = useCallback((entry: VfsEntry, currentPath: string) => {
const apps = getAppsForEntry(entry); const apps = getAppsForEntry(entry);
if (!apps.length) { if (!apps.length) {
@@ -115,10 +161,8 @@ export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ ch
if (!target) return ws; if (!target) return ws;
return [...ws.filter(w => w.id !== id), target]; return [...ws.filter(w => w.id !== id), target];
}); });
const updateWindow = ( const updateWindow = (id: string, patch: AppWindowPatch) =>
id: string, setWindows(ws => ws.map(w => (w.id === id ? { ...w, ...patch } : w)));
patch: Partial<Omit<AppWindowItem, 'id' | 'app' | 'entry' | 'filePath'>>,
) => 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 minimizeWindow = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, minimized: true } : w)));
const restoreWindow = (id: string) => setWindows(ws => { const restoreWindow = (id: string) => setWindows(ws => {
@@ -134,6 +178,7 @@ export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ ch
openWithApp, openWithApp,
openFileWithDefaultApp, openFileWithDefaultApp,
confirmOpenWithApp, confirmOpenWithApp,
openApp,
closeWindow, closeWindow,
toggleMax, toggleMax,
bringToFront, bringToFront,
@@ -141,7 +186,7 @@ export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ ch
minimizeWindow, minimizeWindow,
restoreWindow, restoreWindow,
toggleMinimize, toggleMinimize,
}), [windows, openWithApp, openFileWithDefaultApp, confirmOpenWithApp]); }), [windows, openWithApp, openFileWithDefaultApp, confirmOpenWithApp, openApp]);
return <AppWindowsContext.Provider value={value}>{children}</AppWindowsContext.Provider>; 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'); if (!ctx) throw new Error('useAppWindows must be used within AppWindowsProvider');
return ctx; return ctx;
} }
+2
View File
@@ -605,6 +605,8 @@
"Open Link": "Open Link", "Open Link": "Open Link",
"Link copied": "Link copied", "Link copied": "Link copied",
"Copy Link": "Copy Link", "Copy Link": "Copy Link",
"Open App": "Open App",
"Update App": "Update App",
"Confirm delete this plugin?": "Confirm delete this plugin?", "Confirm delete this plugin?": "Confirm delete this plugin?",
"Author": "Author", "Author": "Author",
"Website": "Website", "Website": "Website",
+2
View File
@@ -598,6 +598,8 @@
"Open Link": "打开链接", "Open Link": "打开链接",
"Link copied": "已复制链接", "Link copied": "已复制链接",
"Copy Link": "复制链接", "Copy Link": "复制链接",
"Open App": "打开应用",
"Update App": "更新应用",
"Confirm delete this plugin?": "确认删除该插件?", "Confirm delete this plugin?": "确认删除该插件?",
"Author": "作者", "Author": "作者",
"Website": "官网", "Website": "官网",
+9 -8
View File
@@ -180,14 +180,15 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
overflowY: collapsed ? 'auto' : 'visible', overflowY: collapsed ? 'auto' : 'visible',
}} }}
> >
{minimized.map(w => { {minimized.map(w => {
const src = w.app.iconUrl || DEFAULT_APP_ICON; const src = w.app.iconUrl || DEFAULT_APP_ICON;
return ( const title = w.kind === 'file' ? `${w.app.name} - ${w.entry.name}` : w.app.name;
<Tooltip key={w.id} title={`${w.app.name} - ${w.entry.name}`} placement={collapsed ? 'right' : 'top'}> return (
<Button <Tooltip key={w.id} title={title} placement={collapsed ? 'right' : 'top'}>
shape="circle" <Button
onClick={() => restoreWindow(w.id)} shape="circle"
icon={<img src={src} alt={w.app.name} style={{ width: 16, height: 16 }} />} onClick={() => restoreWindow(w.id)}
icon={<img src={src} alt={w.app.name} style={{ width: 16, height: 16 }} />}
/> />
</Tooltip> </Tooltip>
); );
+30 -23
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 { 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 { GithubOutlined, LinkOutlined } from '@ant-design/icons';
import { pluginsApi, type PluginItem } from '../api/plugins'; import { pluginsApi, type PluginItem } from '../api/plugins';
import { loadPluginFromUrl, ensureManifest } from '../plugins/runtime'; import { loadPlugin, ensureManifest } from '../plugins/runtime';
import { reloadPluginApps, ensureAppsLoaded, listSystemApps, type AppDescriptor } from '../apps/registry'; import { getAppByKey, reloadPluginApps, ensureAppsLoaded, listSystemApps, type AppDescriptor } from '../apps/registry';
import { useI18n } from '../i18n'; import { useI18n } from '../i18n';
import { fetchRepoList, type RepoItem, buildCenterUrl } from '../api/pluginCenter'; import { fetchRepoList, type RepoItem, buildCenterUrl } from '../api/pluginCenter';
import { useAppWindows } from '../contexts/AppWindowsContext';
const PluginsPage = memo(function PluginsPage() { const PluginsPage = memo(function PluginsPage() {
const [data, setData] = useState<PluginItem[]>([]); const [data, setData] = useState<PluginItem[]>([]);
@@ -25,6 +26,7 @@ const PluginsPage = memo(function PluginsPage() {
const [form] = Form.useForm<{ url: string }>(); const [form] = Form.useForm<{ url: string }>();
const { token } = theme.useToken(); const { token } = theme.useToken();
const { t } = useI18n(); const { t } = useI18n();
const { openApp } = useAppWindows();
const reload = async () => { const reload = async () => {
try { setLoading(true); setData(await pluginsApi.list()); } finally { setLoading(false); } 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 { url } = await form.validateFields();
const created = await pluginsApi.create({ url }); const created = await pluginsApi.create({ url });
try { try {
const p = await loadPluginFromUrl(created.url); const p = await loadPlugin(created);
await ensureManifest(created.id, p); await ensureManifest(created.id, p);
} catch {} } catch {}
setAdding(false); setAdding(false);
@@ -111,6 +113,8 @@ const PluginsPage = memo(function PluginsPage() {
const name = p.name || `${t('Plugin')} ${p.id}`; const name = p.name || `${t('Plugin')} ${p.id}`;
const exts = (p.supported_exts || []).slice(0, 6); const exts = (p.supported_exts || []).slice(0, 6);
const more = (p.supported_exts || []).length - exts.length; const more = (p.supported_exts || []).length - exts.length;
const app = getAppByKey('plugin:' + p.id);
const canOpenApp = !!p.open_app;
const title = ( const title = (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <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'; }} /> <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} styles={{ body: { padding: 12 } } as any}
style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }} style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }}
actions={[ actions={[
<a key="open" href={p.url} target="_blank" rel="noreferrer">{t('Open Link')}</a>, <Button
<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>, 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(); }}> <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> <Button type="link" danger size="small">{t('Delete')}</Button>
</Popconfirm> </Popconfirm>
@@ -177,7 +196,6 @@ const PluginsPage = memo(function PluginsPage() {
const name = a.name || a.key; const name = a.name || a.key;
const exts = (a.supportedExts || []).slice(0, 6); const exts = (a.supportedExts || []).slice(0, 6);
const more = (a.supportedExts || []).length - exts.length; const more = (a.supportedExts || []).length - exts.length;
const link = a.website || a.github || '';
const title = ( const title = (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <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'; }} /> <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 }} style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }}
actions={[ actions={[
<Button <Button
key="open" key="open-app"
type="link" type="link"
size="small" size="small"
disabled={!link} disabled={!a.openAppComponent}
onClick={() => { if (link) window.open(link, '_blank', 'noreferrer'); }} onClick={() => openApp(a)}
> >
{t('Open Link')} {t('Open App')}
</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')}
</Button>, </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> <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 url = buildCenterUrl(item.directUrl);
const created = await pluginsApi.create({ url }); const created = await pluginsApi.create({ url });
try { try {
const p = await loadPluginFromUrl(created.url); const p = await loadPlugin(created);
await ensureManifest(created.id, p); await ensureManifest(created.id, p);
} catch {} } catch {}
await reload(); await reload();
+25 -1
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 { export interface RegisteredPlugin {
mount: (container: HTMLElement, ctx: { mount: (container: HTMLElement, ctx: {
@@ -9,6 +9,9 @@ export interface RegisteredPlugin {
}) => void | Promise<void>; }) => void | Promise<void>;
unmount?: (container: HTMLElement) => 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; key?: string;
name?: string; name?: string;
version?: 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) { export async function ensureManifest(pluginId: number, plugin: RegisteredPlugin) {
const manifest: PluginManifestUpdate = { const manifest: PluginManifestUpdate = {
key: plugin.key, key: plugin.key,
name: plugin.name, name: plugin.name,
version: plugin.version, version: plugin.version,
open_app: typeof plugin.mountApp === 'function',
supported_exts: plugin.supportedExts, supported_exts: plugin.supportedExts,
default_bounds: plugin.defaultBounds, default_bounds: plugin.defaultBounds,
default_maximized: plugin.defaultMaximized, default_maximized: plugin.defaultMaximized,