mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-25 17:23:59 +08:00
feat: add support for opening plugins
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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' }} />;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -598,6 +598,8 @@
|
||||
"Open Link": "打开链接",
|
||||
"Link copied": "已复制链接",
|
||||
"Copy Link": "复制链接",
|
||||
"Open App": "打开应用",
|
||||
"Update App": "更新应用",
|
||||
"Confirm delete this plugin?": "确认删除该插件?",
|
||||
"Author": "作者",
|
||||
"Website": "官网",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user