import { RequestHandler } from 'express'; import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response'; import * as fs from 'fs'; import * as path from 'path'; import * as https from 'https'; import compressing from 'compressing'; import { webUiPathWrapper, webUiLogger } from '../../index'; import { NapCatPathWrapper } from '@/napcat-common/src/path'; import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types'; import { getGitHubRelease, findAvailableDownloadUrl } from '@/napcat-common/src/mirror'; import { ILogWrapper } from '@/napcat-common/src/log-interface'; // 更新请求体接口 interface UpdateRequestBody { /** 要更新到的版本 tag,如 "v4.9.9",不传则更新到最新版本 */ targetVersion?: string; /** 是否强制更新(即使是降级也更新) */ force?: boolean; } // 更新配置文件接口 interface UpdateConfig { version: string; updateTime: string; files: Array<{ sourcePath: string; targetPath: string; backupPath?: string; }>; changelog?: string; } // 需要跳过更新的文件 const SKIP_UPDATE_FILES = [ 'NapCatWinBootMain.exe', 'NapCatWinBootHook.dll' ]; /** * 递归扫描目录中的所有文件 */ function scanFilesRecursively (dirPath: string, basePath: string = dirPath): Array<{ sourcePath: string; relativePath: string; }> { const files: Array<{ sourcePath: string; relativePath: string; }> = []; const items = fs.readdirSync(dirPath); for (const item of items) { const fullPath = path.join(dirPath, item); const relativePath = path.relative(basePath, fullPath); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { // 递归扫描子目录 files.push(...scanFilesRecursively(fullPath, basePath)); } else if (stat.isFile()) { files.push({ sourcePath: fullPath, relativePath: relativePath }); } } return files; } // 注:镜像配置已迁移到 @/napcat-common/src/mirror 模块统一管理 /** * 下载文件(带进度和重试) */ async function downloadFile (url: string, dest: string): Promise { webUiLogger?.log('[NapCat Update] Starting download from:', url); const file = fs.createWriteStream(dest); return new Promise((resolve, reject) => { const request = https.get(url, { headers: { 'User-Agent': 'NapCat-WebUI' } }, (res) => { webUiLogger?.log('[NapCat Update] Response status:', res.statusCode); webUiLogger?.log('[NapCat Update] Content-Type:', res.headers['content-type']); if (res.statusCode === 302 || res.statusCode === 301) { webUiLogger?.log('[NapCat Update] Following redirect to:', res.headers.location); file.close(); fs.unlinkSync(dest); downloadFile(res.headers.location!, dest).then(resolve).catch(reject); return; } if (res.statusCode !== 200) { file.close(); fs.unlinkSync(dest); reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); return; } res.pipe(file); file.on('finish', () => { file.close(); webUiLogger?.log('[NapCat Update] Download completed'); resolve(); }); }); request.on('error', (err) => { webUiLogger?.logError('[NapCat Update] Download error:', err); file.close(); fs.unlink(dest, () => { }); reject(err); }); }); } export const UpdateNapCatHandler: RequestHandler = async (req, res) => { try { // 从请求体获取目标版本(可选) const { targetVersion, force } = req.body as UpdateRequestBody; // 确定要下载的文件名 const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip'; // 确定目标版本 tag // 如果指定了版本,使用指定版本;否则使用 'latest' const targetTag = targetVersion || 'latest'; webUiLogger?.log(`[NapCat Update] Target version: ${targetTag}`); // 检查是否是 action 临时版本 const isActionVersion = targetTag.startsWith('action-'); let downloadUrl: string; let actualVersion: string; if (isActionVersion) { // 处理 action 临时版本 const runId = parseInt(targetTag.replace('action-', '')); if (isNaN(runId)) { throw new Error(`Invalid action version format: ${targetTag}`); } webUiLogger?.log(`[NapCat Update] Downloading action artifact from run: ${runId}`); // 根据当前工作环境确定 artifact 名称 const artifactName = ReleaseName.replace('.zip', ''); // NapCat.Framework 或 NapCat.Shell // Action artifacts 通过 nightly.link 下载 // 格式:https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip const baseUrl = `https://nightly.link/NapNeko/NapCatQQ/actions/runs/${runId}/${artifactName}.zip`; actualVersion = targetTag; webUiLogger?.log(`[NapCat Update] Action artifact URL: ${baseUrl}`); // 使用 mirror 模块查找可用的 nightly.link 镜像 try { downloadUrl = await findAvailableDownloadUrl(baseUrl, { validateContent: true, minFileSize: 1024 * 1024, timeout: 10000, }); webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`); } catch (error) { // 如果镜像都不可用,直接使用原始 URL webUiLogger?.logWarn(`[NapCat Update] All nightly.link mirrors failed, using original URL`); downloadUrl = baseUrl; } } else { // 处理标准 release 版本 // 使用 mirror 模块获取 release 信息(不依赖 API) // 通过 assetNames 参数直接构建下载 URL,避免调用 GitHub API const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, { assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'], fetchChangelog: false, // 不需要 changelog,避免 API 调用 }); const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName); if (!shellZipAsset) { throw new Error(`未找到${ReleaseName}文件`); } actualVersion = release.tag_name; // 使用 mirror 模块查找可用的下载 URL // 启用内容验证,确保返回的是有效文件而非错误页面 downloadUrl = await findAvailableDownloadUrl(shellZipAsset.browser_download_url, { validateContent: true, // 验证 Content-Type 和状态码 minFileSize: 1024 * 1024, // 最小 1MB,确保不是错误页面 timeout: 10000, // 10秒超时 }); } // 检查是否需要强制更新(降级警告) const currentVersion = WebUiDataRuntime.GetNapCatVersion(); webUiLogger?.log(`[NapCat Update] Current version: ${currentVersion}, Target version: ${actualVersion}`); if (!force && currentVersion && !isActionVersion) { // 简单的版本比较(可选的降级保护) const parseVersion = (v: string): [number, number, number] => { const match = v.match(/^v?(\d+)\.(\d+)\.(\d+)/); if (!match) return [0, 0, 0]; return [parseInt(match[1] || '0'), parseInt(match[2] || '0'), parseInt(match[3] || '0')]; }; const [currMajor, currMinor, currPatch] = parseVersion(currentVersion); const [targetMajor, targetMinor, targetPatch] = parseVersion(actualVersion); const isDowngrade = targetMajor < currMajor || (targetMajor === currMajor && targetMinor < currMinor) || (targetMajor === currMajor && targetMinor === currMinor && targetPatch < currPatch); if (isDowngrade) { webUiLogger?.log(`[NapCat Update] Downgrade from ${currentVersion} to ${actualVersion}, force=${force}`); // 不阻止降级,只是记录日志 } } webUiLogger?.log(`[NapCat Update] Updating to version: ${actualVersion}`); // 创建临时目录 const tempDir = path.join(webUiPathWrapper.binaryPath, './temp'); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`); // 下载zip const zipPath = path.join(tempDir, 'napcat-latest.zip'); webUiLogger?.log('[NapCat Update] Saving to:', zipPath); await downloadFile(downloadUrl, zipPath); // 检查文件大小 const stats = fs.statSync(zipPath); webUiLogger?.log('[NapCat Update] Downloaded file size:', stats.size, 'bytes'); // 解压到临时目录 const extractPath = path.join(tempDir, 'napcat-extract'); webUiLogger?.log('[NapCat Update] Extracting to:', extractPath); await compressing.zip.uncompress(zipPath, extractPath); // 获取解压后的实际内容目录(NapCat.Shell.zip直接包含文件,无额外根目录) const sourcePath = extractPath; // 执行更新操作 try { // 扫描需要更新的文件 const allFiles = scanFilesRecursively(sourcePath); const failedFiles: Array<{ sourcePath: string; targetPath: string; }> = []; // 先尝试直接替换文件 for (const fileInfo of allFiles) { const targetFilePath = path.join(webUiPathWrapper.binaryPath, fileInfo.relativePath); // 跳过指定的文件 if (SKIP_UPDATE_FILES.includes(path.basename(fileInfo.relativePath))) { webUiLogger?.log(`[NapCat Update] Skipping update for ${fileInfo.relativePath}`); continue; } try { // 确保目标目录存在 const targetDir = path.dirname(targetFilePath); if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } // 尝试直接替换文件 if (fs.existsSync(targetFilePath)) { fs.unlinkSync(targetFilePath); // 删除旧文件 } fs.copyFileSync(fileInfo.sourcePath, targetFilePath); } catch (error) { // 如果替换失败,添加到失败列表 webUiLogger?.logError(`[NapCat Update] Failed to update ${targetFilePath}, will retry on next startup:`, error); failedFiles.push({ sourcePath: fileInfo.sourcePath, targetPath: targetFilePath }); } } // 如果有替换失败的文件,创建更新配置文件 if (failedFiles.length > 0) { const updateConfig: UpdateConfig = { version: actualVersion, updateTime: new Date().toISOString(), files: failedFiles, changelog: '' }; // 保存更新配置文件 const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json'); fs.writeFileSync(configPath, JSON.stringify(updateConfig, null, 2)); webUiLogger?.log(`[NapCat Update] Update config saved for ${failedFiles.length} failed files: ${configPath}`); } // 发送成功响应 const message = failedFiles.length > 0 ? `更新完成,重启应用以应用剩余${failedFiles.length}个文件的更新` : '更新完成'; sendSuccess(res, { status: 'completed', message, newVersion: actualVersion, failedFilesCount: failedFiles.length }); } catch (error) { webUiLogger?.logError('[NapCat Update] 更新失败:', error); sendError(res, '更新失败: ' + (error instanceof Error ? error.message : '未知错误')); } } catch (error: any) { webUiLogger?.logError('[NapCat Update] 更新失败:', error); sendError(res, '更新失败: ' + error.message); } }; // 注:getLatestRelease 已移除,现在使用 mirror 模块的 getGitHubRelease /** * 应用待处理的更新(在应用启动时调用) */ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper, logger: ILogWrapper): Promise { const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json'); if (!fs.existsSync(configPath)) { logger.log('[NapCat Update] No pending updates found'); return; } try { logger.log('[NapCat Update] Applying pending updates...'); const updateConfig: UpdateConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); const remainingFiles: Array<{ sourcePath: string; targetPath: string; }> = []; for (const file of updateConfig.files) { try { // 检查源文件是否存在 if (!fs.existsSync(file.sourcePath)) { logger.logWarn(`[NapCat Update] Source file not found: ${file.sourcePath}`); continue; } // 确保目标目录存在 const targetDir = path.dirname(file.targetPath); if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } // 尝试替换文件 if (fs.existsSync(file.targetPath)) { fs.unlinkSync(file.targetPath); // 删除旧文件 } fs.copyFileSync(file.sourcePath, file.targetPath); logger.log(`[NapCat Update] Updated ${path.basename(file.targetPath)} on startup`); } catch (error) { logger.logError(`[NapCat Update] Failed to update ${file.targetPath} on startup:`, error); // 如果仍然失败,保留在列表中 remainingFiles.push(file); } } // 如果还有失败的文件,更新配置文件 if (remainingFiles.length > 0) { const updatedConfig: UpdateConfig = { ...updateConfig, files: remainingFiles }; fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); logger.log(`[NapCat Update] ${remainingFiles.length} files still pending update`); } else { // 所有文件都成功更新,删除配置文件 fs.unlinkSync(configPath); logger.log('[NapCat Update] All pending updates applied successfully'); } } catch (error) { logger.logError('[NapCat Update] Failed to apply pending updates:', error); } }