From 6b8cc6756db977a19938e4570de454ba394313b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Tue, 27 Jan 2026 22:51:45 +0800 Subject: [PATCH] Add plugin install SSE API and mirror selection UI Introduces a new SSE-based plugin installation API for real-time progress updates and adds frontend support for selecting download mirrors, especially for GitHub-based plugins. Refactors backend plugin directory handling, improves logging, and updates the frontend to use the new API with user-selectable mirrors and progress feedback. --- packages/napcat-common/src/mirror.ts | 30 ++-- .../src/api/PluginStore.ts | 142 +++++++++++++++--- .../napcat-webui-backend/src/router/Plugin.ts | 3 +- .../src/controllers/plugin_manager.ts | 7 +- .../src/pages/dashboard/plugin_store.tsx | 129 +++++++++++++++- 5 files changed, 263 insertions(+), 48 deletions(-) diff --git a/packages/napcat-common/src/mirror.ts b/packages/napcat-common/src/mirror.ts index 4112de79..f6d4b59c 100644 --- a/packages/napcat-common/src/mirror.ts +++ b/packages/napcat-common/src/mirror.ts @@ -383,17 +383,14 @@ export async function testUrlHead (url: string, timeout: number = 5000): Promise }, (res) => { const statusCode = res.statusCode || 0; const contentType = (res.headers['content-type'] as string) || ''; - const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10); - // 验证条件: + // 简化验证条件: // 1. 状态码 2xx 或 3xx // 2. Content-Type 不应该是 text/html(表示错误页面) - // 3. 对于 .zip 文件,Content-Length 应该 > 1MB(避免获取到错误页面) const isValidStatus = statusCode >= 200 && statusCode < 400; const isNotHtmlError = !contentType.includes('text/html'); - const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true; - resolve(isValidStatus && isNotHtmlError && isValidSize); + resolve(isValidStatus && isNotHtmlError); }); req.on('error', () => resolve(false)); @@ -437,10 +434,9 @@ export async function validateUrl (url: string, timeout: number = 5000): Promise const contentType = (res.headers['content-type'] as string) || ''; const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10); - // 验证条件 + // 简化验证条件 const isValidStatus = statusCode >= 200 && statusCode < 400; const isNotHtmlError = !contentType.includes('text/html'); - const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true; if (!isValidStatus) { resolve({ @@ -458,14 +454,6 @@ export async function validateUrl (url: string, timeout: number = 5000): Promise contentLength, error: '返回了 HTML 页面而非文件', }); - } else if (!isValidSize) { - resolve({ - valid: false, - statusCode, - contentType, - contentLength, - error: `文件过小 (${contentLength} bytes),可能是错误页面`, - }); } else { resolve({ valid: true, @@ -542,21 +530,21 @@ export async function findAvailableDownloadUrl ( const testWithValidation = async (url: string): Promise => { if (validateContent) { const result = await validateUrl(url, timeout); - // 额外检查文件大小 + // 额外检查文件大小(仅当指定了 minFileSize 时) if (result.valid && minFileSize && result.contentLength && result.contentLength < minFileSize) { return false; } return result.valid; } - return testMethod === 'head' ? testUrlHead(url, timeout) : testUrl(url, timeout); + // 不验证内容,只检查状态码 + const isValid = testMethod === 'head' ? await testUrlHead(url, timeout) : await testUrl(url, timeout); + return isValid; }; - // 1. 如果设置了自定义镜像,优先使用 + // 1. 如果设置了自定义镜像,直接使用(不测试,信任用户选择) if (customMirror) { const customUrl = buildMirrorUrl(originalUrl, customMirror); - if (await testWithValidation(customUrl)) { - return customUrl; - } + return customUrl; } // 2. 先测试原始 URL diff --git a/packages/napcat-webui-backend/src/api/PluginStore.ts b/packages/napcat-webui-backend/src/api/PluginStore.ts index 77d5d051..0a347308 100644 --- a/packages/napcat-webui-backend/src/api/PluginStore.ts +++ b/packages/napcat-webui-backend/src/api/PluginStore.ts @@ -7,19 +7,15 @@ import { pipeline } from 'stream/promises'; import { createWriteStream } from 'fs'; import compressing from 'compressing'; import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'napcat-common/src/mirror'; +import { webUiPathWrapper } from '@/napcat-webui-backend/index'; // 插件商店源配置 const PLUGIN_STORE_SOURCES = [ 'https://raw.githubusercontent.com/NapNeko/napcat-plugin-index/main/plugins.v4.json', ]; -// 插件目录 -const PLUGINS_DIR = path.join(process.cwd(), 'plugins'); - -// 确保插件目录存在 -if (!fs.existsSync(PLUGINS_DIR)) { - fs.mkdirSync(PLUGINS_DIR, { recursive: true }); -} +// 插件目录 - 使用 pathWrapper +const getPluginsDir = () => webUiPathWrapper.pluginPath; // 插件列表缓存 let pluginListCache: PluginStoreList | null = null; @@ -80,7 +76,7 @@ async function fetchPluginList (): Promise { * 下载文件,使用镜像系统 * 自动识别 GitHub Release URL 并使用镜像加速 */ -async function downloadFile (url: string, destPath: string): Promise { +async function downloadFile (url: string, destPath: string, customMirror?: string): Promise { try { let downloadUrl: string; @@ -91,25 +87,36 @@ async function downloadFile (url: string, destPath: string): Promise { if (githubReleasePattern.test(url)) { // 使用镜像系统查找可用的下载 URL(支持 GitHub Release 镜像) console.log(`Detected GitHub Release URL: ${url}`); + console.log(`Custom mirror: ${customMirror || 'auto'}`); + downloadUrl = await findAvailableDownloadUrl(url, { - validateContent: true, - minFileSize: 1024, // 最小 1KB - timeout: 60000, // 60秒超时 - useFastMirrors: true, // 使用快速镜像列表 + validateContent: false, // 不验证内容,只检查状态码和 Content-Type + timeout: 5000, // 每个镜像测试5秒超时 + useFastMirrors: false, // 不使用快速镜像列表(避免测速阻塞) + customMirror: customMirror || undefined, // 使用用户选择的镜像 }); + + console.log(`Selected download URL: ${downloadUrl}`); } else { // 其他URL直接下载 console.log(`Direct download URL: ${url}`); downloadUrl = url; } - console.log(`Downloading from: ${downloadUrl}`); + console.log(`Starting download from: ${downloadUrl}`); + + // 确保目标目录存在 + const destDir = path.dirname(destPath); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + console.log(`Created directory: ${destDir}`); + } const response = await fetch(downloadUrl, { headers: { 'User-Agent': 'NapCat-WebUI', }, - signal: AbortSignal.timeout(60000), + signal: AbortSignal.timeout(120000), // 实际下载120秒超时 }); if (!response.ok) { @@ -138,20 +145,38 @@ async function downloadFile (url: string, destPath: string): Promise { * 解压插件到指定目录 */ async function extractPlugin (zipPath: string, pluginId: string): Promise { + const PLUGINS_DIR = getPluginsDir(); const pluginDir = path.join(PLUGINS_DIR, pluginId); + console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`); + console.log(`[extractPlugin] pluginId: ${pluginId}`); + console.log(`[extractPlugin] Target directory: ${pluginDir}`); + console.log(`[extractPlugin] Zip file: ${zipPath}`); + + // 确保插件根目录存在 + if (!fs.existsSync(PLUGINS_DIR)) { + fs.mkdirSync(PLUGINS_DIR, { recursive: true }); + console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`); + } + // 如果目录已存在,先删除 if (fs.existsSync(pluginDir)) { + console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`); fs.rmSync(pluginDir, { recursive: true, force: true }); } // 创建插件目录 fs.mkdirSync(pluginDir, { recursive: true }); + console.log(`[extractPlugin] Created directory: ${pluginDir}`); // 解压 await compressing.zip.uncompress(zipPath, pluginDir); - //console.log(`Plugin extracted to: ${pluginDir}`); + console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`); + + // 列出解压后的文件 + const files = fs.readdirSync(pluginDir); + console.log(`[extractPlugin] Extracted files:`, files); } /** @@ -186,11 +211,11 @@ export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => { }; /** - * 安装插件(从商店) + * 安装插件(从商店)- 普通 POST 接口 */ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => { try { - const { id } = req.body; + const { id, mirror } = req.body; if (!id) { return sendError(res, 'Plugin ID is required'); @@ -205,10 +230,11 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => } // 下载插件 + const PLUGINS_DIR = getPluginsDir(); const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`); try { - await downloadFile(plugin.downloadUrl, tempZipPath); + await downloadFile(plugin.downloadUrl, tempZipPath, mirror); // 解压插件 await extractPlugin(tempZipPath, id); @@ -232,3 +258,83 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => return sendError(res, 'Failed to install plugin: ' + e.message); } }; + +/** + * 安装插件(从商店)- SSE 版本,实时推送进度 + */ +export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) => { + const { id, mirror } = req.query; + + if (!id || typeof id !== 'string') { + res.status(400).json({ error: 'Plugin ID is required' }); + return; + } + + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const sendProgress = (message: string, progress?: number) => { + res.write(`data: ${JSON.stringify({ message, progress })}\n\n`); + }; + + try { + sendProgress('正在获取插件信息...', 10); + + // 获取插件信息 + const data = await fetchPluginList(); + const plugin = data.plugins.find(p => p.id === id); + + if (!plugin) { + sendProgress('错误: 插件不存在', 0); + res.write(`data: ${JSON.stringify({ error: 'Plugin not found in store' })}\n\n`); + res.end(); + return; + } + + sendProgress(`找到插件: ${plugin.name} v${plugin.version}`, 20); + sendProgress(`下载地址: ${plugin.downloadUrl}`, 25); + + if (mirror && typeof mirror === 'string') { + sendProgress(`使用镜像: ${mirror}`, 28); + } + + // 下载插件 + const PLUGINS_DIR = getPluginsDir(); + const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`); + + try { + sendProgress('正在下载插件...', 30); + await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined); + + sendProgress('下载完成,正在解压...', 70); + await extractPlugin(tempZipPath, id); + + sendProgress('解压完成,正在清理...', 90); + fs.unlinkSync(tempZipPath); + + sendProgress('安装成功!', 100); + res.write(`data: ${JSON.stringify({ + success: true, + message: 'Plugin installed successfully', + plugin: plugin, + installPath: path.join(PLUGINS_DIR, id), + })}\n\n`); + res.end(); + } catch (downloadError: any) { + // 清理临时文件 + if (fs.existsSync(tempZipPath)) { + fs.unlinkSync(tempZipPath); + } + sendProgress(`错误: ${downloadError.message}`, 0); + res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`); + res.end(); + } + } catch (e: any) { + sendProgress(`错误: ${e.message}`, 0); + res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`); + res.end(); + } +}; diff --git a/packages/napcat-webui-backend/src/router/Plugin.ts b/packages/napcat-webui-backend/src/router/Plugin.ts index f5c19f5e..41a3b230 100644 --- a/packages/napcat-webui-backend/src/router/Plugin.ts +++ b/packages/napcat-webui-backend/src/router/Plugin.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { GetPluginListHandler, ReloadPluginHandler, SetPluginStatusHandler, UninstallPluginHandler } from '@/napcat-webui-backend/src/api/Plugin'; -import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler } from '@/napcat-webui-backend/src/api/PluginStore'; +import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler, InstallPluginFromStoreSSEHandler } from '@/napcat-webui-backend/src/api/PluginStore'; const router: Router = Router(); @@ -13,5 +13,6 @@ router.post('/Uninstall', UninstallPluginHandler); router.get('/Store/List', GetPluginStoreListHandler); router.get('/Store/Detail/:id', GetPluginStoreDetailHandler); router.post('/Store/Install', InstallPluginFromStoreHandler); +router.get('/Store/Install/SSE', InstallPluginFromStoreSSEHandler); export { router as PluginRouter }; diff --git a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts index 61dec6ef..220cc444 100644 --- a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts +++ b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts @@ -50,7 +50,10 @@ export default class PluginManager { return data.data; } - public static async installPluginFromStore (id: string) { - await serverRequest.post>('/Plugin/Store/Install', { id }); + public static async installPluginFromStore (id: string, mirror?: string) { + // 插件安装可能需要较长时间(下载+解压),设置5分钟超时 + await serverRequest.post>('/Plugin/Store/Install', { id, mirror }, { + timeout: 300000, // 5分钟 + }); } } diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx index eaeb1d97..96ee58d3 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx @@ -1,15 +1,21 @@ import { Button } from '@heroui/button'; import { Input } from '@heroui/input'; +import { Select, SelectItem } from '@heroui/select'; import { Tab, Tabs } from '@heroui/tabs'; import { useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; import { IoMdRefresh, IoMdSearch } from 'react-icons/io'; import clsx from 'clsx'; +import { useRequest } from 'ahooks'; +import { EventSourcePolyfill } from 'event-source-polyfill'; import PageLoading from '@/components/page_loading'; import PluginStoreCard from '@/components/display_card/plugin_store_card'; import PluginManager from '@/controllers/plugin_manager'; +import WebUIManager from '@/controllers/webui_manager'; import { PluginStoreItem } from '@/types/plugin-store'; +import useDialog from '@/hooks/use-dialog'; +import key from '@/const/key'; interface EmptySectionProps { isEmpty: boolean; @@ -32,6 +38,14 @@ export default function PluginStorePage () { const [loading, setLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [activeTab, setActiveTab] = useState('all'); + const dialog = useDialog(); + + // 获取镜像列表 + const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, { + cacheKey: 'napcat-mirrors', + staleTime: 60 * 60 * 1000, + }); + const mirrors = mirrorsData?.mirrors || []; const loadPlugins = async () => { setLoading(true); @@ -86,13 +100,116 @@ export default function PluginStorePage () { ]; }, [categorizedPlugins]); - const handleInstall = async (pluginId: string) => { + const handleInstall = async (plugin: PluginStoreItem) => { + // 检测是否是 GitHub 下载链接 + const githubPattern = /^https:\/\/github\.com\//; + const isGitHubUrl = githubPattern.test(plugin.downloadUrl); + + // 如果是 GitHub 链接,弹出镜像选择对话框 + if (isGitHubUrl) { + let selectedMirror: string | undefined = undefined; + + dialog.confirm({ + title: '安装插件', + content: ( +
+
+

+ 插件名称: {plugin.name} +

+

+ 版本: v{plugin.version} +

+

+ {plugin.description} +

+
+
+ + +
+
+ ), + confirmText: '开始安装', + cancelText: '取消', + onConfirm: async () => { + await installPluginWithSSE(plugin.id, selectedMirror); + }, + }); + } else { + // 非 GitHub 链接,直接安装 + await installPluginWithSSE(plugin.id); + } + }; + + const installPluginWithSSE = async (pluginId: string, mirror?: string) => { + const loadingToast = toast.loading('正在准备安装...'); + try { - await PluginManager.installPluginFromStore(pluginId); - toast.success('插件安装成功!'); - // 可以选择刷新插件列表或导航到插件管理页面 + // 获取认证 token + const token = localStorage.getItem(key.token); + if (!token) { + toast.error('未登录,请先登录', { id: loadingToast }); + return; + } + const _token = JSON.parse(token); + + const params = new URLSearchParams({ id: pluginId }); + if (mirror) { + params.append('mirror', mirror); + } + + const eventSource = new EventSourcePolyfill( + `/api/Plugin/Store/Install/SSE?${params.toString()}`, + { + headers: { + Authorization: `Bearer ${_token}`, + Accept: 'text/event-stream', + }, + withCredentials: true, + } + ); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.error) { + toast.error(`安装失败: ${data.error}`, { id: loadingToast }); + eventSource.close(); + } else if (data.success) { + toast.success('插件安装成功!', { id: loadingToast }); + eventSource.close(); + } else if (data.message) { + toast.loading(data.message, { id: loadingToast }); + } + } catch (e) { + console.error('Failed to parse SSE message:', e); + } + }; + + eventSource.onerror = (error) => { + console.error('SSE连接出错:', error); + toast.error('连接中断,安装失败', { id: loadingToast }); + eventSource.close(); + }; } catch (error: any) { - toast.error(`安装失败: ${error.message || '未知错误'}`); + toast.error(`安装失败: ${error.message || '未知错误'}`, { id: loadingToast }); } }; @@ -148,7 +265,7 @@ export default function PluginStorePage () { handleInstall(plugin.id)} + onInstall={() => handleInstall(plugin)} /> ))}