Skip to content

Commit 6b8cc67

Browse files
committed
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.
1 parent 24623f1 commit 6b8cc67

5 files changed

Lines changed: 263 additions & 48 deletions

File tree

packages/napcat-common/src/mirror.ts

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -383,17 +383,14 @@ export async function testUrlHead (url: string, timeout: number = 5000): Promise
383383
}, (res) => {
384384
const statusCode = res.statusCode || 0;
385385
const contentType = (res.headers['content-type'] as string) || '';
386-
const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
387386

388-
// 验证条件
387+
// 简化验证条件
389388
// 1. 状态码 2xx 或 3xx
390389
// 2. Content-Type 不应该是 text/html(表示错误页面)
391-
// 3. 对于 .zip 文件,Content-Length 应该 > 1MB(避免获取到错误页面)
392390
const isValidStatus = statusCode >= 200 && statusCode < 400;
393391
const isNotHtmlError = !contentType.includes('text/html');
394-
const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true;
395392

396-
resolve(isValidStatus && isNotHtmlError && isValidSize);
393+
resolve(isValidStatus && isNotHtmlError);
397394
});
398395

399396
req.on('error', () => resolve(false));
@@ -437,10 +434,9 @@ export async function validateUrl (url: string, timeout: number = 5000): Promise
437434
const contentType = (res.headers['content-type'] as string) || '';
438435
const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
439436

440-
// 验证条件
437+
// 简化验证条件
441438
const isValidStatus = statusCode >= 200 && statusCode < 400;
442439
const isNotHtmlError = !contentType.includes('text/html');
443-
const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true;
444440

445441
if (!isValidStatus) {
446442
resolve({
@@ -458,14 +454,6 @@ export async function validateUrl (url: string, timeout: number = 5000): Promise
458454
contentLength,
459455
error: '返回了 HTML 页面而非文件',
460456
});
461-
} else if (!isValidSize) {
462-
resolve({
463-
valid: false,
464-
statusCode,
465-
contentType,
466-
contentLength,
467-
error: `文件过小 (${contentLength} bytes),可能是错误页面`,
468-
});
469457
} else {
470458
resolve({
471459
valid: true,
@@ -542,21 +530,21 @@ export async function findAvailableDownloadUrl (
542530
const testWithValidation = async (url: string): Promise<boolean> => {
543531
if (validateContent) {
544532
const result = await validateUrl(url, timeout);
545-
// 额外检查文件大小
533+
// 额外检查文件大小(仅当指定了 minFileSize 时)
546534
if (result.valid && minFileSize && result.contentLength && result.contentLength < minFileSize) {
547535
return false;
548536
}
549537
return result.valid;
550538
}
551-
return testMethod === 'head' ? testUrlHead(url, timeout) : testUrl(url, timeout);
539+
// 不验证内容,只检查状态码
540+
const isValid = testMethod === 'head' ? await testUrlHead(url, timeout) : await testUrl(url, timeout);
541+
return isValid;
552542
};
553543

554-
// 1. 如果设置了自定义镜像,优先使用
544+
// 1. 如果设置了自定义镜像,直接使用(不测试,信任用户选择)
555545
if (customMirror) {
556546
const customUrl = buildMirrorUrl(originalUrl, customMirror);
557-
if (await testWithValidation(customUrl)) {
558-
return customUrl;
559-
}
547+
return customUrl;
560548
}
561549

562550
// 2. 先测试原始 URL

packages/napcat-webui-backend/src/api/PluginStore.ts

Lines changed: 124 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,15 @@ import { pipeline } from 'stream/promises';
77
import { createWriteStream } from 'fs';
88
import compressing from 'compressing';
99
import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'napcat-common/src/mirror';
10+
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
1011

1112
// 插件商店源配置
1213
const PLUGIN_STORE_SOURCES = [
1314
'https://raw.githubusercontent.com/NapNeko/napcat-plugin-index/main/plugins.v4.json',
1415
];
1516

16-
// 插件目录
17-
const PLUGINS_DIR = path.join(process.cwd(), 'plugins');
18-
19-
// 确保插件目录存在
20-
if (!fs.existsSync(PLUGINS_DIR)) {
21-
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
22-
}
17+
// 插件目录 - 使用 pathWrapper
18+
const getPluginsDir = () => webUiPathWrapper.pluginPath;
2319

2420
// 插件列表缓存
2521
let pluginListCache: PluginStoreList | null = null;
@@ -80,7 +76,7 @@ async function fetchPluginList (): Promise<PluginStoreList> {
8076
* 下载文件,使用镜像系统
8177
* 自动识别 GitHub Release URL 并使用镜像加速
8278
*/
83-
async function downloadFile (url: string, destPath: string): Promise<void> {
79+
async function downloadFile (url: string, destPath: string, customMirror?: string): Promise<void> {
8480
try {
8581
let downloadUrl: string;
8682

@@ -91,25 +87,36 @@ async function downloadFile (url: string, destPath: string): Promise<void> {
9187
if (githubReleasePattern.test(url)) {
9288
// 使用镜像系统查找可用的下载 URL(支持 GitHub Release 镜像)
9389
console.log(`Detected GitHub Release URL: ${url}`);
90+
console.log(`Custom mirror: ${customMirror || 'auto'}`);
91+
9492
downloadUrl = await findAvailableDownloadUrl(url, {
95-
validateContent: true,
96-
minFileSize: 1024, // 最小 1KB
97-
timeout: 60000, // 60秒超时
98-
useFastMirrors: true, // 使用快速镜像列表
93+
validateContent: false, // 不验证内容,只检查状态码和 Content-Type
94+
timeout: 5000, // 每个镜像测试5秒超时
95+
useFastMirrors: false, // 不使用快速镜像列表(避免测速阻塞)
96+
customMirror: customMirror || undefined, // 使用用户选择的镜像
9997
});
98+
99+
console.log(`Selected download URL: ${downloadUrl}`);
100100
} else {
101101
// 其他URL直接下载
102102
console.log(`Direct download URL: ${url}`);
103103
downloadUrl = url;
104104
}
105105

106-
console.log(`Downloading from: ${downloadUrl}`);
106+
console.log(`Starting download from: ${downloadUrl}`);
107+
108+
// 确保目标目录存在
109+
const destDir = path.dirname(destPath);
110+
if (!fs.existsSync(destDir)) {
111+
fs.mkdirSync(destDir, { recursive: true });
112+
console.log(`Created directory: ${destDir}`);
113+
}
107114

108115
const response = await fetch(downloadUrl, {
109116
headers: {
110117
'User-Agent': 'NapCat-WebUI',
111118
},
112-
signal: AbortSignal.timeout(60000),
119+
signal: AbortSignal.timeout(120000), // 实际下载120秒超时
113120
});
114121

115122
if (!response.ok) {
@@ -138,20 +145,38 @@ async function downloadFile (url: string, destPath: string): Promise<void> {
138145
* 解压插件到指定目录
139146
*/
140147
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
148+
const PLUGINS_DIR = getPluginsDir();
141149
const pluginDir = path.join(PLUGINS_DIR, pluginId);
142150

151+
console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`);
152+
console.log(`[extractPlugin] pluginId: ${pluginId}`);
153+
console.log(`[extractPlugin] Target directory: ${pluginDir}`);
154+
console.log(`[extractPlugin] Zip file: ${zipPath}`);
155+
156+
// 确保插件根目录存在
157+
if (!fs.existsSync(PLUGINS_DIR)) {
158+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
159+
console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`);
160+
}
161+
143162
// 如果目录已存在,先删除
144163
if (fs.existsSync(pluginDir)) {
164+
console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`);
145165
fs.rmSync(pluginDir, { recursive: true, force: true });
146166
}
147167

148168
// 创建插件目录
149169
fs.mkdirSync(pluginDir, { recursive: true });
170+
console.log(`[extractPlugin] Created directory: ${pluginDir}`);
150171

151172
// 解压
152173
await compressing.zip.uncompress(zipPath, pluginDir);
153174

154-
//console.log(`Plugin extracted to: ${pluginDir}`);
175+
console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`);
176+
177+
// 列出解压后的文件
178+
const files = fs.readdirSync(pluginDir);
179+
console.log(`[extractPlugin] Extracted files:`, files);
155180
}
156181

157182
/**
@@ -186,11 +211,11 @@ export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
186211
};
187212

188213
/**
189-
* 安装插件(从商店)
214+
* 安装插件(从商店)- 普通 POST 接口
190215
*/
191216
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
192217
try {
193-
const { id } = req.body;
218+
const { id, mirror } = req.body;
194219

195220
if (!id) {
196221
return sendError(res, 'Plugin ID is required');
@@ -205,10 +230,11 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
205230
}
206231

207232
// 下载插件
233+
const PLUGINS_DIR = getPluginsDir();
208234
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
209235

210236
try {
211-
await downloadFile(plugin.downloadUrl, tempZipPath);
237+
await downloadFile(plugin.downloadUrl, tempZipPath, mirror);
212238

213239
// 解压插件
214240
await extractPlugin(tempZipPath, id);
@@ -232,3 +258,83 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
232258
return sendError(res, 'Failed to install plugin: ' + e.message);
233259
}
234260
};
261+
262+
/**
263+
* 安装插件(从商店)- SSE 版本,实时推送进度
264+
*/
265+
export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) => {
266+
const { id, mirror } = req.query;
267+
268+
if (!id || typeof id !== 'string') {
269+
res.status(400).json({ error: 'Plugin ID is required' });
270+
return;
271+
}
272+
273+
// 设置 SSE 响应头
274+
res.setHeader('Content-Type', 'text/event-stream');
275+
res.setHeader('Cache-Control', 'no-cache');
276+
res.setHeader('Connection', 'keep-alive');
277+
res.flushHeaders();
278+
279+
const sendProgress = (message: string, progress?: number) => {
280+
res.write(`data: ${JSON.stringify({ message, progress })}\n\n`);
281+
};
282+
283+
try {
284+
sendProgress('正在获取插件信息...', 10);
285+
286+
// 获取插件信息
287+
const data = await fetchPluginList();
288+
const plugin = data.plugins.find(p => p.id === id);
289+
290+
if (!plugin) {
291+
sendProgress('错误: 插件不存在', 0);
292+
res.write(`data: ${JSON.stringify({ error: 'Plugin not found in store' })}\n\n`);
293+
res.end();
294+
return;
295+
}
296+
297+
sendProgress(`找到插件: ${plugin.name} v${plugin.version}`, 20);
298+
sendProgress(`下载地址: ${plugin.downloadUrl}`, 25);
299+
300+
if (mirror && typeof mirror === 'string') {
301+
sendProgress(`使用镜像: ${mirror}`, 28);
302+
}
303+
304+
// 下载插件
305+
const PLUGINS_DIR = getPluginsDir();
306+
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
307+
308+
try {
309+
sendProgress('正在下载插件...', 30);
310+
await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined);
311+
312+
sendProgress('下载完成,正在解压...', 70);
313+
await extractPlugin(tempZipPath, id);
314+
315+
sendProgress('解压完成,正在清理...', 90);
316+
fs.unlinkSync(tempZipPath);
317+
318+
sendProgress('安装成功!', 100);
319+
res.write(`data: ${JSON.stringify({
320+
success: true,
321+
message: 'Plugin installed successfully',
322+
plugin: plugin,
323+
installPath: path.join(PLUGINS_DIR, id),
324+
})}\n\n`);
325+
res.end();
326+
} catch (downloadError: any) {
327+
// 清理临时文件
328+
if (fs.existsSync(tempZipPath)) {
329+
fs.unlinkSync(tempZipPath);
330+
}
331+
sendProgress(`错误: ${downloadError.message}`, 0);
332+
res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`);
333+
res.end();
334+
}
335+
} catch (e: any) {
336+
sendProgress(`错误: ${e.message}`, 0);
337+
res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`);
338+
res.end();
339+
}
340+
};

packages/napcat-webui-backend/src/router/Plugin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Router } from 'express';
22
import { GetPluginListHandler, ReloadPluginHandler, SetPluginStatusHandler, UninstallPluginHandler } from '@/napcat-webui-backend/src/api/Plugin';
3-
import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler } from '@/napcat-webui-backend/src/api/PluginStore';
3+
import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler, InstallPluginFromStoreSSEHandler } from '@/napcat-webui-backend/src/api/PluginStore';
44

55
const router: Router = Router();
66

@@ -13,5 +13,6 @@ router.post('/Uninstall', UninstallPluginHandler);
1313
router.get('/Store/List', GetPluginStoreListHandler);
1414
router.get('/Store/Detail/:id', GetPluginStoreDetailHandler);
1515
router.post('/Store/Install', InstallPluginFromStoreHandler);
16+
router.get('/Store/Install/SSE', InstallPluginFromStoreSSEHandler);
1617

1718
export { router as PluginRouter };

packages/napcat-webui-frontend/src/controllers/plugin_manager.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ export default class PluginManager {
5050
return data.data;
5151
}
5252

53-
public static async installPluginFromStore (id: string) {
54-
await serverRequest.post<ServerResponse<void>>('/Plugin/Store/Install', { id });
53+
public static async installPluginFromStore (id: string, mirror?: string) {
54+
// 插件安装可能需要较长时间(下载+解压),设置5分钟超时
55+
await serverRequest.post<ServerResponse<void>>('/Plugin/Store/Install', { id, mirror }, {
56+
timeout: 300000, // 5分钟
57+
});
5558
}
5659
}

0 commit comments

Comments
 (0)