mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 07:36:53 +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")
|
load_dotenv(dotenv_path=".env")
|
||||||
|
|
||||||
VERSION = "v1.4.0"
|
VERSION = "v1.5.0"
|
||||||
|
|
||||||
|
|
||||||
class ConfigService:
|
class ConfigService:
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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' }} />;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 { }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "官网",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user