Optimize version fetching and update logic

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.
This commit is contained in:
手瓜一十雪 2026-01-04 12:41:21 +08:00
parent 4940d72867
commit f1756c4d1c
8 changed files with 420 additions and 134 deletions

View File

@ -114,6 +114,16 @@ export const GITHUB_RAW_MIRRORS = [
'https://gh-proxy.net/https://raw.githubusercontent.com', 'https://gh-proxy.net/https://raw.githubusercontent.com',
]; ];
/**
* Nightly.link
* 访 GitHub Actions artifacts
* 使
*/
export const NIGHTLY_LINK_MIRRORS = [
'https://nightly.link',
// 可以添加其他 nightly.link 镜像(如果有的话)
];
// ============== 镜像配置接口 ============== // ============== 镜像配置接口 ==============
export interface MirrorConfig { export interface MirrorConfig {
@ -123,6 +133,8 @@ export interface MirrorConfig {
apiMirrors: string[]; apiMirrors: string[];
/** Raw 文件镜像 */ /** Raw 文件镜像 */
rawMirrors: string[]; rawMirrors: string[];
/** Nightly.link 镜像(用于 Actions artifacts */
nightlyLinkMirrors: string[];
/** 超时时间(毫秒) */ /** 超时时间(毫秒) */
timeout: number; timeout: number;
/** 是否启用镜像 */ /** 是否启用镜像 */
@ -137,6 +149,7 @@ const defaultConfig: MirrorConfig = {
fileMirrors: GITHUB_FILE_MIRRORS, fileMirrors: GITHUB_FILE_MIRRORS,
apiMirrors: GITHUB_API_MIRRORS, apiMirrors: GITHUB_API_MIRRORS,
rawMirrors: GITHUB_RAW_MIRRORS, rawMirrors: GITHUB_RAW_MIRRORS,
nightlyLinkMirrors: NIGHTLY_LINK_MIRRORS,
timeout: 10000, // 10秒超时平衡速度和可靠性 timeout: 10000, // 10秒超时平衡速度和可靠性
enabled: true, enabled: true,
customMirror: undefined, customMirror: undefined,
@ -530,7 +543,11 @@ export async function findAvailableDownloadUrl (
// 获取镜像列表 // 获取镜像列表
let mirrors = options.mirrors; let mirrors = options.mirrors;
if (!mirrors) { if (!mirrors) {
if (useFastMirrors) { // 检查是否是 nightly.link URL
if (originalUrl.includes('nightly.link')) {
// 使用 nightly.link 镜像列表(保持完整的 URL 格式)
mirrors = currentConfig.nightlyLinkMirrors;
} else if (useFastMirrors) {
// 使用懒加载的快速镜像列表 // 使用懒加载的快速镜像列表
mirrors = await getFastMirrors(); mirrors = await getFastMirrors();
} else { } else {
@ -564,11 +581,20 @@ export async function findAvailableDownloadUrl (
return originalUrl; return originalUrl;
} }
// 3. 测试镜像源(已按延迟排序) // 3. 测试镜像源
let testedCount = 0; let testedCount = 0;
for (const mirror of mirrors) { for (const mirror of mirrors) {
if (!mirror) continue; // 跳过空字符串 if (!mirror) continue; // 跳过空字符串
const mirrorUrl = buildMirrorUrl(originalUrl, mirror);
// 特殊处理 nightly.link URL
let mirrorUrl: string;
if (originalUrl.includes('nightly.link')) {
// 替换 nightly.link 域名
mirrorUrl = originalUrl.replace('https://nightly.link', mirror.startsWith('http') ? mirror : `https://${mirror}`);
} else {
mirrorUrl = buildMirrorUrl(originalUrl, mirror);
}
testedCount++; testedCount++;
if (await testWithValidation(mirrorUrl)) { if (await testWithValidation(mirrorUrl)) {
return mirrorUrl; return mirrorUrl;
@ -748,13 +774,13 @@ interface TagsCache {
timestamp: number; timestamp: number;
} }
// 缓存 tags 结果(5 分钟有效 // 缓存 tags 结果(10 分钟有效release 版本不会频繁变动
const TAGS_CACHE_TTL = 5 * 60 * 1000; const TAGS_CACHE_TTL = 10 * 60 * 1000;
const tagsCache: Map<string, TagsCache> = new Map(); const tagsCache: Map<string, TagsCache> = new Map();
/** /**
* GitHub tags * GitHub tags
* 使 * 使
*/ */
export async function getAllGitHubTags (owner: string, repo: string): Promise<{ tags: string[], mirror: string; }> { export async function getAllGitHubTags (owner: string, repo: string): Promise<{ tags: string[], mirror: string; }> {
const cacheKey = `${owner}/${repo}`; const cacheKey = `${owner}/${repo}`;
@ -779,7 +805,7 @@ export async function getAllGitHubTags (owner: string, repo: string): Promise<{
}; };
// 尝试从 URL 获取 tags // 尝试从 URL 获取 tags
const fetchFromUrl = async (url: string): Promise<string[] | null> => { const fetchFromUrl = async (url: string, mirror: string): Promise<{ tags: string[], mirror: string; } | null> => {
try { try {
const raw = await PromiseTimer( const raw = await PromiseTimer(
RequestUtil.HttpGetText(url), RequestUtil.HttpGetText(url),
@ -793,7 +819,7 @@ export async function getAllGitHubTags (owner: string, repo: string): Promise<{
const tags = parseTags(raw); const tags = parseTags(raw);
if (tags.length > 0) { if (tags.length > 0) {
return tags; return { tags, mirror };
} }
return null; return null;
} catch { } catch {
@ -801,40 +827,57 @@ export async function getAllGitHubTags (owner: string, repo: string): Promise<{
} }
}; };
// 获取快速镜像列表(懒加载,首次调用会测速,已按延迟排序) // 获取快速镜像列表
let fastMirrors: string[] = []; let fastMirrors: string[] = [];
try { try {
fastMirrors = await getFastMirrors(); fastMirrors = await getFastMirrors();
} catch (e) { } catch {
// 忽略错误,继续使用空列表 // 忽略错误
} }
// 构建 URL 列表(快速镜像 + 原始 URL // 构建 URL 列表(取前 5 个快速镜像 + 原始 URL 并行请求)
const mirrorUrls = fastMirrors.filter(m => m).map(m => ({ url: buildMirrorUrl(baseUrl, m), mirror: m })); const topMirrors = fastMirrors.slice(0, 5);
mirrorUrls.push({ url: baseUrl, mirror: 'github.com' }); // 添加原始 URL const mirrorUrls = [
{ url: baseUrl, mirror: 'github.com' }, // 原始 URL
...topMirrors.filter(m => m).map(m => ({ url: buildMirrorUrl(baseUrl, m), mirror: m })),
];
// 并行请求所有镜像,使用 Promise.any 获取第一个成功的结果
try {
const result = await Promise.any(
mirrorUrls.map(async ({ url, mirror }) => {
const res = await fetchFromUrl(url, mirror);
if (res) return res;
throw new Error('Failed');
})
);
// 按顺序尝试每个镜像(已按延迟排序),成功即返回
for (const { url, mirror } of mirrorUrls) {
const tags = await fetchFromUrl(url);
if (tags && tags.length > 0) {
// 缓存结果 // 缓存结果
tagsCache.set(cacheKey, { tags, mirror, timestamp: Date.now() }); tagsCache.set(cacheKey, { tags: result.tags, mirror: result.mirror, timestamp: Date.now() });
return { tags, mirror }; return result;
} } catch {
// Promise.any 全部失败,回退到顺序尝试剩余镜像
} }
// 如果快速镜像都失败,回退到原始镜像列表 // 回退:顺序尝试剩余镜像
const allMirrors = currentConfig.fileMirrors.filter(m => m); const remainingMirrors = fastMirrors.slice(5).filter(m => m);
for (const mirror of allMirrors) { for (const mirror of remainingMirrors) {
// 跳过已经尝试过的镜像
if (fastMirrors.includes(mirror)) continue;
const url = buildMirrorUrl(baseUrl, mirror); const url = buildMirrorUrl(baseUrl, mirror);
const tags = await fetchFromUrl(url); const result = await fetchFromUrl(url, mirror);
if (tags && tags.length > 0) { if (result) {
// 缓存结果 tagsCache.set(cacheKey, { tags: result.tags, mirror: result.mirror, timestamp: Date.now() });
tagsCache.set(cacheKey, { tags, mirror, timestamp: Date.now() }); return result;
return { tags, mirror }; }
}
// 最后尝试所有镜像
const allMirrors = currentConfig.fileMirrors.filter(m => m && !fastMirrors.includes(m));
for (const mirror of allMirrors) {
const url = buildMirrorUrl(baseUrl, mirror);
const result = await fetchFromUrl(url, mirror);
if (result) {
tagsCache.set(cacheKey, { tags: result.tags, mirror: result.mirror, timestamp: Date.now() });
return result;
} }
} }
@ -854,11 +897,168 @@ export interface ActionArtifact {
head_sha?: string; head_sha?: string;
} }
// ============== Action Artifacts 缓存 ==============
interface ArtifactsCache {
artifacts: ActionArtifact[];
timestamp: number;
}
// 缓存 artifacts 结果10 分钟有效)
const ARTIFACTS_CACHE_TTL = 10 * 60 * 1000;
const artifactsCache: Map<string, ArtifactsCache> = new Map();
/** /**
* GitHub Action artifacts * artifacts
* nightly/dev
*/ */
export async function getLatestActionArtifacts ( export function clearArtifactsCache (): void {
artifactsCache.clear();
}
/**
* GitHub Actions HTML workflow runs
* api.github.com 使
* 页面格式: https://github.com/{owner}/{repo}/actions/workflows/{workflow}
*/
async function getWorkflowRunsFromHtml (
owner: string,
repo: string,
workflow: string = 'build.yml',
maxRuns: number = 10
): Promise<Array<{ id: number; created_at: string; }>> {
const baseUrl = `https://github.com/${owner}/${repo}/actions/workflows/${workflow}`;
// 尝试使用镜像获取 HTML
const mirrors = ['', ...currentConfig.fileMirrors.filter(m => m)];
for (const mirror of mirrors) {
try {
const url = mirror ? buildMirrorUrl(baseUrl, mirror) : baseUrl;
const html = await PromiseTimer(
RequestUtil.HttpGetText(url),
10000
);
// 从 HTML 中提取 run IDs 和时间
// 格式: href="/NapNeko/NapCatQQ/actions/runs/20676123968"
// 时间格式: <relative-time datetime="2026-01-03T10:37:29Z"
const runPattern = new RegExp(`href="/${owner}/${repo}/actions/runs/(\\d+)"`, 'gi');
const timePattern = /<relative-time\s+datetime="([^"]+)"/gi;
// 提取所有时间
const times: string[] = [];
let timeMatch;
while ((timeMatch = timePattern.exec(html)) !== null) {
times.push(timeMatch[1]);
}
const runs: Array<{ id: number; created_at: string; }> = [];
const foundIds = new Set<number>();
let timeIndex = 0;
let match;
while ((match = runPattern.exec(html)) !== null && runs.length < maxRuns) {
const id = parseInt(match[1]);
if (!foundIds.has(id)) {
foundIds.add(id);
// 尝试获取对应的时间,每个 run 通常有两个时间(桌面和移动端显示)
// 所以每找到一个 run跳过两个时间
const created_at = times[timeIndex] || new Date().toISOString();
timeIndex += 2; // 跳过两个时间(桌面端和移动端各一个)
runs.push({
id,
created_at,
});
}
}
if (runs.length > 0) {
return runs;
}
} catch {
continue;
}
}
return [];
}
/**
* API workflow runs nightly.link
* HTML使 URL
*
*
* 1. 使 GitHub API
* 2. API GitHub Actions HTML
*/
async function getArtifactsFromNightlyLink (
owner: string,
repo: string,
workflow: string = 'build.yml',
branch: string = 'main',
maxRuns: number = 10
): Promise<ActionArtifact[]> {
let workflowRuns: Array<{ id: number; head_sha?: string; created_at: string; }> = [];
// 策略1: 优先尝试 GitHub API
try {
const endpoint = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/runs?branch=${branch}&status=success&per_page=${maxRuns}`;
const runsResponse = await PromiseTimer(
RequestUtil.HttpGetJson<{
workflow_runs: Array<{ id: number; head_sha: string; created_at: string; }>;
}>(endpoint, 'GET', undefined, {
'User-Agent': 'NapCat',
'Accept': 'application/vnd.github.v3+json',
}),
10000
);
if (runsResponse.workflow_runs && runsResponse.workflow_runs.length > 0) {
workflowRuns = runsResponse.workflow_runs;
}
} catch {
// API 请求失败,继续尝试 HTML 解析
}
// 策略2: API 失败时,从 HTML 页面解析
if (workflowRuns.length === 0) {
workflowRuns = await getWorkflowRunsFromHtml(owner, repo, workflow, maxRuns);
}
if (workflowRuns.length === 0) {
return [];
}
// 直接拼接 nightly.link URL
// 格式: https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
const artifacts: ActionArtifact[] = [];
const artifactNames = ['NapCat.Framework', 'NapCat.Shell']; // 已知的 artifact 名称
for (const run of workflowRuns) {
for (const artifactName of artifactNames) {
const mirror = currentConfig.nightlyLinkMirrors[0] || 'https://nightly.link';
artifacts.push({
id: run.id,
name: artifactName,
size_in_bytes: 0,
created_at: run.created_at,
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
archive_download_url: `${mirror}/${owner}/${repo}/actions/runs/${run.id}/${artifactName}.zip`,
workflow_run_id: run.id,
head_sha: run.head_sha,
});
}
}
return artifacts;
}
/**
* GitHub API artifacts
*/
async function getArtifactsFromAPI (
owner: string, owner: string,
repo: string, repo: string,
workflow: string = 'build.yml', workflow: string = 'build.yml',
@ -867,13 +1067,15 @@ export async function getLatestActionArtifacts (
): Promise<ActionArtifact[]> { ): Promise<ActionArtifact[]> {
const endpoint = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/runs?branch=${branch}&status=success&per_page=${maxRuns}`; const endpoint = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/runs?branch=${branch}&status=success&per_page=${maxRuns}`;
try { const runsResponse = await PromiseTimer(
const runsResponse = await RequestUtil.HttpGetJson<{ RequestUtil.HttpGetJson<{
workflow_runs: Array<{ id: number; head_sha: string; created_at: string; }>; workflow_runs: Array<{ id: number; head_sha: string; created_at: string; }>;
}>(endpoint, 'GET', undefined, { }>(endpoint, 'GET', undefined, {
'User-Agent': 'NapCat', 'User-Agent': 'NapCat',
'Accept': 'application/vnd.github.v3+json', 'Accept': 'application/vnd.github.v3+json',
}); }),
10000
);
const workflowRuns = runsResponse.workflow_runs; const workflowRuns = runsResponse.workflow_runs;
if (!workflowRuns || workflowRuns.length === 0) { if (!workflowRuns || workflowRuns.length === 0) {
@ -886,12 +1088,15 @@ export async function getLatestActionArtifacts (
for (const run of workflowRuns) { for (const run of workflowRuns) {
try { try {
const artifactsEndpoint = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${run.id}/artifacts`; const artifactsEndpoint = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${run.id}/artifacts`;
const artifactsResponse = await RequestUtil.HttpGetJson<{ const artifactsResponse = await PromiseTimer(
RequestUtil.HttpGetJson<{
artifacts: ActionArtifact[]; artifacts: ActionArtifact[];
}>(artifactsEndpoint, 'GET', undefined, { }>(artifactsEndpoint, 'GET', undefined, {
'User-Agent': 'NapCat', 'User-Agent': 'NapCat',
'Accept': 'application/vnd.github.v3+json', 'Accept': 'application/vnd.github.v3+json',
}); }),
10000
);
if (artifactsResponse.artifacts) { if (artifactsResponse.artifacts) {
// 为每个 artifact 添加 run 信息 // 为每个 artifact 添加 run 信息
@ -907,7 +1112,57 @@ export async function getLatestActionArtifacts (
} }
return allArtifacts; return allArtifacts;
}
/**
* GitHub Action artifacts
* nightly/dev
*
*
* 1. 10
* 2. nightly.link
* 3. 退 GitHub API
*/
export async function getLatestActionArtifacts (
owner: string,
repo: string,
workflow: string = 'build.yml',
branch: string = 'main',
maxRuns: number = 10
): Promise<ActionArtifact[]> {
const cacheKey = `${owner}/${repo}/${workflow}/${branch}`;
// 检查缓存
const cached = artifactsCache.get(cacheKey);
if (cached && (Date.now() - cached.timestamp) < ARTIFACTS_CACHE_TTL) {
return cached.artifacts;
}
let artifacts: ActionArtifact[] = [];
// 策略1: 优先使用 nightly.link更稳定无需认证
try {
artifacts = await getArtifactsFromNightlyLink(owner, repo, workflow, branch, maxRuns);
} catch { } catch {
return []; // nightly.link 获取失败
}
// 策略2: 回退到 GitHub API
if (artifacts.length === 0) {
try {
artifacts = await getArtifactsFromAPI(owner, repo, workflow, branch, maxRuns);
} catch {
// API 获取失败
} }
} }
// 缓存结果(即使为空也缓存,避免频繁请求)
if (artifacts.length > 0) {
artifactsCache.set(cacheKey, {
artifacts,
timestamp: Date.now(),
});
}
return artifacts;
}

View File

@ -17,7 +17,6 @@ import {
WrapperSessionInitConfig, WrapperSessionInitConfig,
} from '@/napcat-core/wrapper'; } from '@/napcat-core/wrapper';
import { LogLevel, LogWrapper } from '@/napcat-core/helper/log'; import { LogLevel, LogWrapper } from '@/napcat-core/helper/log';
import { NodeIKernelLoginService } from '@/napcat-core/services';
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info'; import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
import { NapCatPathWrapper } from 'napcat-common/src/path'; import { NapCatPathWrapper } from 'napcat-common/src/path';
import path from 'node:path'; import path from 'node:path';
@ -278,7 +277,6 @@ export interface InstanceContext {
readonly wrapper: WrapperNodeApi; readonly wrapper: WrapperNodeApi;
readonly session: NodeIQQNTWrapperSession; readonly session: NodeIQQNTWrapperSession;
readonly logger: LogWrapper; readonly logger: LogWrapper;
readonly loginService: NodeIKernelLoginService;
readonly basicInfoWrapper: QQBasicInfoWrapper; readonly basicInfoWrapper: QQBasicInfoWrapper;
readonly pathWrapper: NapCatPathWrapper; readonly pathWrapper: NapCatPathWrapper;
readonly packetHandler: NativePacketHandler; readonly packetHandler: NativePacketHandler;

View File

@ -73,7 +73,7 @@ export async function NCoreInitFramework (
// 过早进入会导致addKernelMsgListener等Listener添加失败 // 过早进入会导致addKernelMsgListener等Listener添加失败
// await sleep(2500); // await sleep(2500);
// 初始化 NapCatFramework // 初始化 NapCatFramework
const loaderObject = new NapCatFramework(wrapper, session, logger, loginService, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler); const loaderObject = new NapCatFramework(wrapper, session, logger, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler);
await loaderObject.core.initCore(); await loaderObject.core.initCore();
// 启动WebUi // 启动WebUi
@ -94,7 +94,6 @@ export class NapCatFramework {
wrapper: WrapperNodeApi, wrapper: WrapperNodeApi,
session: NodeIQQNTWrapperSession, session: NodeIQQNTWrapperSession,
logger: LogWrapper, logger: LogWrapper,
loginService: NodeIKernelLoginService,
selfInfo: SelfInfo, selfInfo: SelfInfo,
basicInfoWrapper: QQBasicInfoWrapper, basicInfoWrapper: QQBasicInfoWrapper,
pathWrapper: NapCatPathWrapper, pathWrapper: NapCatPathWrapper,
@ -106,7 +105,6 @@ export class NapCatFramework {
wrapper, wrapper,
session, session,
logger, logger,
loginService,
basicInfoWrapper, basicInfoWrapper,
pathWrapper, pathWrapper,
}; };

View File

@ -418,7 +418,6 @@ export async function NCoreInitShell () {
wrapper, wrapper,
session, session,
logger, logger,
loginService,
selfInfo, selfInfo,
basicInfoWrapper, basicInfoWrapper,
pathWrapper, pathWrapper,
@ -434,7 +433,6 @@ export class NapCatShell {
wrapper: WrapperNodeApi, wrapper: WrapperNodeApi,
session: NodeIQQNTWrapperSession, session: NodeIQQNTWrapperSession,
logger: LogWrapper, logger: LogWrapper,
loginService: NodeIKernelLoginService,
selfInfo: SelfInfo, selfInfo: SelfInfo,
basicInfoWrapper: QQBasicInfoWrapper, basicInfoWrapper: QQBasicInfoWrapper,
pathWrapper: NapCatPathWrapper, pathWrapper: NapCatPathWrapper,
@ -446,7 +444,6 @@ export class NapCatShell {
wrapper, wrapper,
session, session,
logger, logger,
loginService,
basicInfoWrapper, basicInfoWrapper,
pathWrapper, pathWrapper,
}; };

View File

@ -39,43 +39,47 @@ export interface VersionInfo {
/** /**
* release + action artifacts * release + action artifacts
* * type
*/ */
export const getAllReleasesHandler: RequestHandler = async (req, res) => { export const getAllReleasesHandler: RequestHandler = async (req, res) => {
try { try {
const page = parseInt(req.query['page'] as string) || 1; const page = parseInt(req.query['page'] as string) || 1;
const pageSize = parseInt(req.query['pageSize'] as string) || 20; const pageSize = parseInt(req.query['pageSize'] as string) || 20;
const includeActions = req.query['includeActions'] !== 'false';
const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all' const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all'
const searchQuery = (req.query['search'] as string || '').toLowerCase().trim(); const searchQuery = (req.query['search'] as string || '').toLowerCase().trim();
let tags: string[] = []; let versions: VersionInfo[] = [];
let actionVersions: VersionInfo[] = [];
let usedMirror = ''; let usedMirror = '';
// 懒加载:只获取需要的版本类型
const needReleases = !typeFilter || typeFilter === 'all' || typeFilter === 'release';
const needActions = typeFilter === 'action' || typeFilter === 'all';
// 获取正式版本(仅当需要时)
if (needReleases) {
try { try {
const result = await getAllTags(); const result = await getAllTags();
tags = result.tags;
usedMirror = result.mirror; usedMirror = result.mirror;
} catch {
// 如果获取 tags 失败,返回空列表而不是抛出错误
tags = [];
}
// 解析版本信息 versions = result.tags.map(tag => {
const versions: VersionInfo[] = tags.map(tag => {
// 检查是否是预发布版本
const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag); const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag);
return { return {
tag, tag,
type: isPrerelease ? 'prerelease' : 'release', type: isPrerelease ? 'prerelease' : 'release',
}; } as VersionInfo;
}); });
// 使用语义化版本排序(最新的在前) // 使用语义化版本排序(最新的在前)
versions.sort((a, b) => -compareSemVer(a.tag, b.tag)); versions.sort((a, b) => -compareSemVer(a.tag, b.tag));
} catch {
// 如果获取 tags 失败,返回空列表而不是抛出错误
versions = [];
}
}
// 获取 Action Artifacts如果请求 // 获取 Action Artifacts仅当需要时
let actionVersions: VersionInfo[] = []; if (needActions) {
if (includeActions) {
try { try {
const artifacts = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main'); const artifacts = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main');
@ -97,22 +101,14 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
headSha: a.head_sha, headSha: a.head_sha,
})); }));
} catch { } catch {
// 忽略 action artifacts 获取失败 // 获取失败时返回空列表
actionVersions = [];
} }
} }
// 合并版本列表action 在最前面) // 合并版本列表action 在最前面)
let allVersions = [...actionVersions, ...versions]; let allVersions = [...actionVersions, ...versions];
// 按类型过滤
if (typeFilter && typeFilter !== 'all') {
if (typeFilter === 'release') {
allVersions = allVersions.filter(v => v.type === 'release' || v.type === 'prerelease');
} else if (typeFilter === 'action') {
allVersions = allVersions.filter(v => v.type === 'action');
}
}
// 搜索过滤 // 搜索过滤
if (searchQuery) { if (searchQuery) {
allVersions = allVersions.filter(v => { allVersions = allVersions.filter(v => {

View File

@ -134,6 +134,45 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
const targetTag = targetVersion || 'latest'; const targetTag = targetVersion || 'latest';
webUiLogger?.log(`[NapCat Update] Target version: ${targetTag}`); 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 // 使用 mirror 模块获取 release 信息(不依赖 API
// 通过 assetNames 参数直接构建下载 URL避免调用 GitHub API // 通过 assetNames 参数直接构建下载 URL避免调用 GitHub API
const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, { const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, {
@ -146,11 +185,22 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
throw new Error(`未找到${ReleaseName}文件`); 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(); const currentVersion = WebUiDataRuntime.GetNapCatVersion();
webUiLogger?.log(`[NapCat Update] Current version: ${currentVersion}, Target version: ${release.tag_name}`); webUiLogger?.log(`[NapCat Update] Current version: ${currentVersion}, Target version: ${actualVersion}`);
if (!force && currentVersion) { if (!force && currentVersion && !isActionVersion) {
// 简单的版本比较(可选的降级保护) // 简单的版本比较(可选的降级保护)
const parseVersion = (v: string): [number, number, number] => { const parseVersion = (v: string): [number, number, number] => {
const match = v.match(/^v?(\d+)\.(\d+)\.(\d+)/); const match = v.match(/^v?(\d+)\.(\d+)\.(\d+)/);
@ -158,7 +208,7 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
return [parseInt(match[1] || '0'), parseInt(match[2] || '0'), parseInt(match[3] || '0')]; return [parseInt(match[1] || '0'), parseInt(match[2] || '0'), parseInt(match[3] || '0')];
}; };
const [currMajor, currMinor, currPatch] = parseVersion(currentVersion); const [currMajor, currMinor, currPatch] = parseVersion(currentVersion);
const [targetMajor, targetMinor, targetPatch] = parseVersion(release.tag_name); const [targetMajor, targetMinor, targetPatch] = parseVersion(actualVersion);
const isDowngrade = const isDowngrade =
targetMajor < currMajor || targetMajor < currMajor ||
@ -166,12 +216,12 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
(targetMajor === currMajor && targetMinor === currMinor && targetPatch < currPatch); (targetMajor === currMajor && targetMinor === currMinor && targetPatch < currPatch);
if (isDowngrade) { if (isDowngrade) {
webUiLogger?.log(`[NapCat Update] Downgrade from ${currentVersion} to ${release.tag_name}, force=${force}`); webUiLogger?.log(`[NapCat Update] Downgrade from ${currentVersion} to ${actualVersion}, force=${force}`);
// 不阻止降级,只是记录日志 // 不阻止降级,只是记录日志
} }
} }
webUiLogger?.log(`[NapCat Update] Updating to version: ${release.tag_name}`); webUiLogger?.log(`[NapCat Update] Updating to version: ${actualVersion}`);
// 创建临时目录 // 创建临时目录
const tempDir = path.join(webUiPathWrapper.binaryPath, './temp'); const tempDir = path.join(webUiPathWrapper.binaryPath, './temp');
@ -179,14 +229,6 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
fs.mkdirSync(tempDir, { recursive: true }); fs.mkdirSync(tempDir, { recursive: true });
} }
// 使用 mirror 模块查找可用的下载 URL
// 启用内容验证,确保返回的是有效文件而非错误页面
const downloadUrl = await findAvailableDownloadUrl(shellZipAsset.browser_download_url, {
validateContent: true, // 验证 Content-Type 和状态码
minFileSize: 1024 * 1024, // 最小 1MB确保不是错误页面
timeout: 10000, // 10秒超时
});
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`); webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
// 下载zip // 下载zip
@ -250,10 +292,10 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
// 如果有替换失败的文件,创建更新配置文件 // 如果有替换失败的文件,创建更新配置文件
if (failedFiles.length > 0) { if (failedFiles.length > 0) {
const updateConfig: UpdateConfig = { const updateConfig: UpdateConfig = {
version: release.tag_name, version: actualVersion,
updateTime: new Date().toISOString(), updateTime: new Date().toISOString(),
files: failedFiles, files: failedFiles,
changelog: release.body || '' changelog: ''
}; };
// 保存更新配置文件 // 保存更新配置文件
@ -269,7 +311,7 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
sendSuccess(res, { sendSuccess(res, {
status: 'completed', status: 'completed',
message, message,
newVersion: release.tag_name, newVersion: actualVersion,
failedFilesCount: failedFiles.length failedFilesCount: failedFiles.length
}); });

View File

@ -293,11 +293,11 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
const pageSize = 15; const pageSize = 15;
// 获取所有可用版本(带分页、过滤和搜索) // 获取所有可用版本(带分页、过滤和搜索)
// 懒加载:根据 activeTab 只获取对应类型的版本
const { data: releasesData, loading: releasesLoading, error: releasesError } = useRequest( const { data: releasesData, loading: releasesLoading, error: releasesError } = useRequest(
() => WebUIManager.getAllReleases({ () => WebUIManager.getAllReleases({
page: currentPage, page: currentPage,
pageSize, pageSize,
includeActions: true,
type: activeTab, type: activeTab,
search: debouncedSearch search: debouncedSearch
}), }),

View File

@ -65,15 +65,15 @@ export default class WebUIManager {
/** /**
* *
* type
*/ */
public static async getAllReleases (options: { public static async getAllReleases (options: {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
includeActions?: boolean;
type?: 'release' | 'action' | 'all'; type?: 'release' | 'action' | 'all';
search?: string; search?: string;
} = {}) { } = {}) {
const { page = 1, pageSize = 20, includeActions = true, type = 'all', search = '' } = options; const { page = 1, pageSize = 20, type = 'release', search = '' } = options;
const { data } = await serverRequest.get<ServerResponse<{ const { data } = await serverRequest.get<ServerResponse<{
versions: Array<{ versions: Array<{
tag: string; tag: string;
@ -94,7 +94,7 @@ export default class WebUIManager {
}; };
mirror?: string; mirror?: string;
}>>('/base/getAllReleases', { }>>('/base/getAllReleases', {
params: { page, pageSize, includeActions, type, search }, params: { page, pageSize, type, search },
}); });
return data.data; return data.data;
} }