mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-01-16 05:10:34 +00:00
Introduces lazy loading for release and action artifact versions, adds support for nightly.link mirrors, and improves artifact retrieval reliability. Removes unused loginService references, refactors update logic to handle action artifacts, and streamlines frontend/backend API parameters for version selection.
396 lines
14 KiB
TypeScript
396 lines
14 KiB
TypeScript
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<void> {
|
||
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<void> {
|
||
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);
|
||
}
|
||
}
|