From 0fcb3b8ce08bf7469be929e2cf1961fbb291c360 Mon Sep 17 00:00:00 2001 From: shiyu Date: Mon, 15 Dec 2025 14:49:01 +0800 Subject: [PATCH] feat: add support for opening plugins --- domain/config/service.py | 2 +- domain/plugins/api.py | 8 +++ domain/plugins/service.py | 90 ++++++++++++++++++++++++++ domain/plugins/types.py | 5 ++ models/database.py | 2 + web/src/api/plugins.ts | 3 +- web/src/apps/AppWindowsLayer.tsx | 66 ++++++++++++------- web/src/apps/PluginHost/index.tsx | 55 +++++++++++++++- web/src/apps/VideoPlayer/index.ts | 2 + web/src/apps/registry.ts | 6 +- web/src/apps/types.ts | 9 +++ web/src/contexts/AppWindowsContext.tsx | 66 +++++++++++++++---- web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/zh.json | 2 + web/src/layout/SideNav.tsx | 17 ++--- web/src/pages/PluginsPage.tsx | 53 ++++++++------- web/src/plugins/runtime.ts | 26 +++++++- 17 files changed, 342 insertions(+), 72 deletions(-) diff --git a/domain/config/service.py b/domain/config/service.py index e6493e7..684705c 100644 --- a/domain/config/service.py +++ b/domain/config/service.py @@ -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: diff --git a/domain/plugins/api.py b/domain/plugins/api.py index bd13302..2993a64 100644 --- a/domain/plugins/api.py +++ b/domain/plugins/api.py @@ -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"}) diff --git a/domain/plugins/service.py b/domain/plugins/service.py index 8e1c3a1..c56bf10 100644 --- a/domain/plugins/service.py +++ b/domain/plugins/service.py @@ -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) diff --git a/domain/plugins/types.py b/domain/plugins/types.py index f5d8f52..5982e22 100644 --- a/domain/plugins/types.py +++ b/domain/plugins/types.py @@ -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 diff --git a/models/database.py b/models/database.py index 745bc28..b750ab3 100644 --- a/models/database.py +++ b/models/database.py @@ -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) diff --git a/web/src/api/plugins.ts b/web/src/api/plugins.ts index 2a6df9e..7fc8cf2 100644 --- a/web/src/api/plugins.ts +++ b/web/src/api/plugins.ts @@ -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; default_maximized?: boolean; @@ -43,4 +45,3 @@ export const pluginsApi = { update: (id: number, payload: PluginCreate) => request(`/plugins/${id}`, { method: 'PUT', json: payload }), updateManifest: (id: number, payload: PluginManifestUpdate) => request(`/plugins/${id}/metadata`, { method: 'POST', json: payload }), }; - diff --git a/web/src/apps/AppWindowsLayer.tsx b/web/src/apps/AppWindowsLayer.tsx index d5238de..143abd3 100644 --- a/web/src/apps/AppWindowsLayer.tsx +++ b/web/src/apps/AppWindowsLayer.tsx @@ -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>; + interface AppWindowsLayerProps { windows: AppWindowItem[]; onClose: (id: string) => void; onToggleMax: (id: string) => void; onBringToFront: (id: string) => void; - onUpdateWindow: (id: string, patch: Partial) => void; + onUpdateWindow: (id: string, patch: AppWindowPatch) => void; } export const AppWindowsLayer: React.FC = ({ windows, onClose, onToggleMax, onBringToFront, onUpdateWindow }) => { @@ -193,8 +196,17 @@ export const AppWindowsLayer: React.FC = ({ windows, onClo return ( <> {visibleWindows.map((w, idx) => { - const AppComp = w.app.component as React.FC; + const isFileWindow = w.kind !== 'app'; + const FileComp = w.app.component as React.FC; + const OpenComp = w.app.openAppComponent as React.FC | undefined; + const ContentComp = (isFileWindow ? FileComp : OpenComp) as React.FC | 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 (
= ({ windows, onClo overflow: 'hidden', background: 'transparent' }} - > - onClose(w.id)} - /> -
- - ); - } + > + {isFileWindow ? ( + onClose(w.id)} + /> + ) : ( + onClose(w.id)} /> + )} + + + ); + } // 否则继续使用系统窗口渲染(不改动原有逻辑) const interacting = isInteracting(w.id); return ( @@ -290,9 +306,9 @@ export const AppWindowsLayer: React.FC = ({ windows, onClo paddingRight: 8, flex: 1 }} - > - {w.app.name} - {w.entry.name} - + > + {titleText} + , + , + , { await pluginsApi.remove(p.id); await reload(); await reloadPluginApps(); }}> @@ -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 = (
{name} { (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={[ , - , + , ]} > @@ -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(); diff --git a/web/src/plugins/runtime.ts b/web/src/plugins/runtime.ts index 9f49f61..4cf9e48 100644 --- a/web/src/plugins/runtime.ts +++ b/web/src/plugins/runtime.ts @@ -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; unmount?: (container: HTMLElement) => void | Promise; + mountApp?: (container: HTMLElement, ctx: { host: HostApi }) => void | Promise; + unmountApp?: (container: HTMLElement) => void | Promise; + key?: string; name?: string; version?: string; @@ -95,11 +98,32 @@ export async function loadPluginFromUrl(url: string): Promise }); } +export function getPluginBundleUrl(pluginId: number) { + return `/api/plugins/${pluginId}/bundle.js`; +} + +export async function loadPlugin(plugin: Pick): Promise { + 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,