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.
This commit is contained in:
手瓜一十雪
2026-01-27 22:51:45 +08:00
parent de522a0db5
commit 887fc02452
5 changed files with 263 additions and 48 deletions

View File

@@ -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<PluginStoreList> {
* 下载文件,使用镜像系统
* 自动识别 GitHub Release URL 并使用镜像加速
*/
async function downloadFile (url: string, destPath: string): Promise<void> {
async function downloadFile (url: string, destPath: string, customMirror?: string): Promise<void> {
try {
let downloadUrl: string;
@@ -91,25 +87,36 @@ async function downloadFile (url: string, destPath: string): Promise<void> {
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<void> {
* 解压插件到指定目录
*/
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
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();
}
};