mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-01-18 14:30:29 +00:00
Support passing JWT secret key on worker restart
Added the ability to pass a JWT secret key when restarting the worker process by updating environment variable handling and message passing. Improved port retry logic in the backend to allow multiple attempts on the same port before incrementing. Also refactored process API to use getter for pid property. Ensure Electron app is ready before creating process manager Adds a check to await electron.app.whenReady() if the Electron app is not yet ready before instantiating the ElectronProcessManager. This prevents potential issues when accessing Electron APIs before the app is fully initialized. Add mirror selection support for version updates Introduces the ability to specify and select GitHub mirror sources for fetching tags, releases, and action artifacts throughout the backend and frontend. Updates API endpoints, internal helper functions, and UI components to allow users to choose a mirror for version queries and updates, improving reliability in regions with limited GitHub access. Also enhances version comparison logic and improves artifact metadata display. Refactor artifact fetching to use HTML parsing only Removed all GitHub API dependencies for fetching workflow runs and artifacts. Now, workflow runs are parsed directly from the HTML of the Actions page, and artifact URLs are constructed using nightly.link. Also added workflow title and mirror fields to ActionArtifact, and simplified mirror list without latency comments.
This commit is contained in:
parent
5284e0ac5a
commit
5de2664af4
@ -220,13 +220,13 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined {
|
|||||||
// ============== GitHub Tags 获取 ==============
|
// ============== GitHub Tags 获取 ==============
|
||||||
// 使用 mirror 模块统一管理镜像
|
// 使用 mirror 模块统一管理镜像
|
||||||
|
|
||||||
export async function getAllTags (): Promise<{ tags: string[], mirror: string; }> {
|
export async function getAllTags (mirror?: string): Promise<{ tags: string[], mirror: string; }> {
|
||||||
return getAllTagsFromMirror('NapNeko', 'NapCatQQ');
|
return getAllTagsFromMirror('NapNeko', 'NapCatQQ', mirror);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function getLatestTag (): Promise<string> {
|
export async function getLatestTag (mirror?: string): Promise<string> {
|
||||||
const { tags } = await getAllTags();
|
const { tags } = await getAllTags(mirror);
|
||||||
|
|
||||||
// 使用 SemVer 规范排序
|
// 使用 SemVer 规范排序
|
||||||
tags.sort((a, b) => compareSemVer(a, b));
|
tags.sort((a, b) => compareSemVer(a, b));
|
||||||
|
|||||||
@ -23,66 +23,50 @@ import { PromiseTimer } from './helper';
|
|||||||
* 懒加载测速:首次使用时自动测速,缓存 30 分钟
|
* 懒加载测速:首次使用时自动测速,缓存 30 分钟
|
||||||
*/
|
*/
|
||||||
export const GITHUB_FILE_MIRRORS = [
|
export const GITHUB_FILE_MIRRORS = [
|
||||||
// 延迟 < 800ms 的最快镜像
|
'https://github.chenc.dev/',
|
||||||
'https://github.chenc.dev/', // 666ms
|
'https://ghproxy.cfd/',
|
||||||
'https://ghproxy.cfd/', // 719ms - 支持重定向
|
'https://github.tbedu.top/',
|
||||||
'https://github.tbedu.top/', // 760ms
|
'https://ghproxy.cc/',
|
||||||
'https://ghps.cc/', // 768ms
|
'https://gh.monlor.com/',
|
||||||
'https://gh.llkk.cc/', // 774ms
|
'https://cdn.akaere.online/',
|
||||||
'https://ghproxy.cc/', // 777ms
|
'https://gh.idayer.com/',
|
||||||
'https://gh.monlor.com/', // 779ms
|
'https://gh.llkk.cc/',
|
||||||
'https://cdn.akaere.online/', // 784ms
|
'https://ghpxy.hwinzniej.top/',
|
||||||
// 延迟 800-1000ms 的快速镜像
|
'https://github-proxy.memory-echoes.cn/',
|
||||||
'https://gh.idayer.com/', // 869ms
|
'https://git.yylx.win/',
|
||||||
'https://gh-proxy.net/', // 885ms
|
'https://gitproxy.mrhjx.cn/',
|
||||||
'https://ghpxy.hwinzniej.top/', // 890ms
|
'https://gh.fhjhy.top/',
|
||||||
'https://github-proxy.memory-echoes.cn/', // 896ms
|
'https://gp.zkitefly.eu.org/',
|
||||||
'https://git.yylx.win/', // 917ms
|
'https://gh-proxy.com/',
|
||||||
'https://gitproxy.mrhjx.cn/', // 950ms
|
'https://ghfile.geekertao.top/',
|
||||||
'https://jiashu.1win.eu.org/', // 954ms
|
'https://j.1lin.dpdns.org/',
|
||||||
'https://ghproxy.cn/', // 981ms
|
'https://ghproxy.imciel.com/',
|
||||||
// 延迟 1000-1500ms 的中速镜像
|
'https://github-proxy.teach-english.tech/',
|
||||||
'https://gh.fhjhy.top/', // 1014ms
|
'https://gh.927223.xyz/',
|
||||||
'https://gp.zkitefly.eu.org/', // 1015ms
|
'https://github.ednovas.xyz/',
|
||||||
'https://gh-proxy.com/', // 1022ms
|
'https://ghf.xn--eqrr82bzpe.top/',
|
||||||
'https://hub.gitmirror.com/', // 1027ms
|
'https://gh.dpik.top/',
|
||||||
'https://ghfile.geekertao.top/', // 1029ms
|
'https://gh.jasonzeng.dev/',
|
||||||
'https://j.1lin.dpdns.org/', // 1037ms
|
'https://gh.xxooo.cf/',
|
||||||
'https://ghproxy.imciel.com/', // 1047ms
|
'https://gh.bugdey.us.kg/',
|
||||||
'https://github-proxy.teach-english.tech/', // 1047ms
|
'https://ghm.078465.xyz/',
|
||||||
'https://gh.927223.xyz/', // 1071ms
|
'https://j.1win.ggff.net/',
|
||||||
'https://github.ednovas.xyz/', // 1099ms
|
'https://tvv.tw/',
|
||||||
'https://ghf.xn--eqrr82bzpe.top/',// 1122ms
|
'https://gitproxy.127731.xyz/',
|
||||||
'https://gh.dpik.top/', // 1131ms
|
'https://gh.inkchills.cn/',
|
||||||
'https://gh.jasonzeng.dev/', // 1139ms
|
'https://ghproxy.cxkpro.top/',
|
||||||
'https://gh.xxooo.cf/', // 1157ms
|
'https://gh.sixyin.com/',
|
||||||
'https://gh.bugdey.us.kg/', // 1228ms
|
'https://github.geekery.cn/',
|
||||||
'https://ghm.078465.xyz/', // 1289ms
|
'https://git.669966.xyz/',
|
||||||
'https://j.1win.ggff.net/', // 1329ms
|
'https://gh.5050net.cn/',
|
||||||
'https://tvv.tw/', // 1393ms
|
'https://gh.felicity.ac.cn/',
|
||||||
'https://gh.chjina.com/', // 1446ms
|
'https://github.dpik.top/',
|
||||||
'https://gitproxy.127731.xyz/', // 1458ms
|
'https://ghp.keleyaa.com/',
|
||||||
// 延迟 1500-2500ms 的较慢镜像
|
'https://gh.wsmdn.dpdns.org/',
|
||||||
'https://gh.inkchills.cn/', // 1617ms
|
'https://ghproxy.monkeyray.net/',
|
||||||
'https://ghproxy.cxkpro.top/', // 1651ms
|
'https://fastgit.cc/',
|
||||||
'https://gh.sixyin.com/', // 1686ms
|
'https://gh.catmak.name/',
|
||||||
'https://github.geekery.cn/', // 1734ms
|
'https://gh.noki.icu/',
|
||||||
'https://git.669966.xyz/', // 1824ms
|
|
||||||
'https://gh.5050net.cn/', // 1858ms
|
|
||||||
'https://gh.felicity.ac.cn/', // 1903ms
|
|
||||||
'https://gh.ddlc.top/', // 2056ms
|
|
||||||
'https://cf.ghproxy.cc/', // 2058ms
|
|
||||||
'https://gitproxy.click/', // 2068ms
|
|
||||||
'https://github.dpik.top/', // 2313ms
|
|
||||||
'https://gh.zwnes.xyz/', // 2434ms
|
|
||||||
'https://ghp.keleyaa.com/', // 2440ms
|
|
||||||
'https://gh.wsmdn.dpdns.org/', // 2744ms
|
|
||||||
// 延迟 > 2500ms 的慢速镜像(作为备用)
|
|
||||||
'https://ghproxy.monkeyray.net/', // 3023ms
|
|
||||||
'https://fastgit.cc/', // 3369ms
|
|
||||||
'https://cdn.gh-proxy.com/', // 3394ms
|
|
||||||
'https://gh.catmak.name/', // 4119ms
|
|
||||||
'https://gh.noki.icu/', // 5990ms
|
|
||||||
'', // 原始 URL(无镜像)
|
'', // 原始 URL(无镜像)
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -109,7 +93,6 @@ export const GITHUB_RAW_MIRRORS = [
|
|||||||
// 测试确认支持 raw 文件的镜像
|
// 测试确认支持 raw 文件的镜像
|
||||||
'https://github.chenc.dev/https://raw.githubusercontent.com',
|
'https://github.chenc.dev/https://raw.githubusercontent.com',
|
||||||
'https://ghproxy.cfd/https://raw.githubusercontent.com',
|
'https://ghproxy.cfd/https://raw.githubusercontent.com',
|
||||||
'https://gh.llkk.cc/https://raw.githubusercontent.com',
|
|
||||||
'https://ghproxy.cc/https://raw.githubusercontent.com',
|
'https://ghproxy.cc/https://raw.githubusercontent.com',
|
||||||
'https://gh-proxy.net/https://raw.githubusercontent.com',
|
'https://gh-proxy.net/https://raw.githubusercontent.com',
|
||||||
];
|
];
|
||||||
@ -585,7 +568,7 @@ export async function findAvailableDownloadUrl (
|
|||||||
let testedCount = 0;
|
let testedCount = 0;
|
||||||
for (const mirror of mirrors) {
|
for (const mirror of mirrors) {
|
||||||
if (!mirror) continue; // 跳过空字符串
|
if (!mirror) continue; // 跳过空字符串
|
||||||
|
|
||||||
// 特殊处理 nightly.link URL
|
// 特殊处理 nightly.link URL
|
||||||
let mirrorUrl: string;
|
let mirrorUrl: string;
|
||||||
if (originalUrl.includes('nightly.link')) {
|
if (originalUrl.includes('nightly.link')) {
|
||||||
@ -594,7 +577,7 @@ export async function findAvailableDownloadUrl (
|
|||||||
} else {
|
} else {
|
||||||
mirrorUrl = buildMirrorUrl(originalUrl, mirror);
|
mirrorUrl = buildMirrorUrl(originalUrl, mirror);
|
||||||
}
|
}
|
||||||
|
|
||||||
testedCount++;
|
testedCount++;
|
||||||
if (await testWithValidation(mirrorUrl)) {
|
if (await testWithValidation(mirrorUrl)) {
|
||||||
return mirrorUrl;
|
return mirrorUrl;
|
||||||
@ -650,8 +633,15 @@ function compareSemVerSimple (a: string, b: string): number {
|
|||||||
* 从 tags 列表中获取最新的 release tag
|
* 从 tags 列表中获取最新的 release tag
|
||||||
* 不依赖 GitHub API
|
* 不依赖 GitHub API
|
||||||
*/
|
*/
|
||||||
export async function getLatestReleaseTag (owner: string, repo: string): Promise<string> {
|
// Update definitions validation locally first if possible.
|
||||||
const result = await getAllGitHubTags(owner, repo);
|
// I'll assume valid typescript.
|
||||||
|
// I will split this into two tool calls to avoid complexity.
|
||||||
|
// 1. Update mirror.ts
|
||||||
|
// 2. Update UpdateNapCat.ts
|
||||||
|
|
||||||
|
// This tool call: Update mirror.ts
|
||||||
|
export async function getLatestReleaseTag (owner: string, repo: string, mirror?: string): Promise<string> {
|
||||||
|
const result = await getAllGitHubTags(owner, repo, mirror);
|
||||||
|
|
||||||
// 过滤出符合 semver 的 tags
|
// 过滤出符合 semver 的 tags
|
||||||
const releaseTags = result.tags.filter(tag => SEMVER_REGEX.test(tag));
|
const releaseTags = result.tags.filter(tag => SEMVER_REGEX.test(tag));
|
||||||
@ -701,6 +691,8 @@ export async function getGitHubRelease (
|
|||||||
assetNames?: string[];
|
assetNames?: string[];
|
||||||
/** 是否需要获取 changelog(需要调用 API) */
|
/** 是否需要获取 changelog(需要调用 API) */
|
||||||
fetchChangelog?: boolean;
|
fetchChangelog?: boolean;
|
||||||
|
/** 指定镜像 */
|
||||||
|
mirror?: string;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<{
|
): Promise<{
|
||||||
tag_name: string;
|
tag_name: string;
|
||||||
@ -710,15 +702,16 @@ export async function getGitHubRelease (
|
|||||||
}>;
|
}>;
|
||||||
body?: string;
|
body?: string;
|
||||||
}> {
|
}> {
|
||||||
const { assetNames = [], fetchChangelog = false } = options;
|
const { assetNames = [], fetchChangelog = false, mirror } = options;
|
||||||
|
|
||||||
// 1. 获取实际的 tag 名称
|
// 1. 获取实际的 tag 名称
|
||||||
let actualTag: string;
|
let actualTag: string;
|
||||||
if (tag === 'latest') {
|
if (tag === 'latest') {
|
||||||
actualTag = await getLatestReleaseTag(owner, repo);
|
actualTag = await getLatestReleaseTag(owner, repo, mirror);
|
||||||
} else {
|
} else {
|
||||||
actualTag = tag;
|
actualTag = tag;
|
||||||
}
|
}
|
||||||
|
// ...
|
||||||
|
|
||||||
// 2. 构建 assets 列表(不需要 API)
|
// 2. 构建 assets 列表(不需要 API)
|
||||||
const assets = assetNames.map(name => ({
|
const assets = assetNames.map(name => ({
|
||||||
@ -782,8 +775,8 @@ 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, mirror?: string): Promise<{ tags: string[], mirror: string; }> {
|
||||||
const cacheKey = `${owner}/${repo}`;
|
const cacheKey = `${owner}/${repo}/${mirror || 'auto'}`;
|
||||||
|
|
||||||
// 检查缓存
|
// 检查缓存
|
||||||
const cached = tagsCache.get(cacheKey);
|
const cached = tagsCache.get(cacheKey);
|
||||||
@ -805,7 +798,7 @@ export async function getAllGitHubTags (owner: string, repo: string): Promise<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 尝试从 URL 获取 tags
|
// 尝试从 URL 获取 tags
|
||||||
const fetchFromUrl = async (url: string, mirror: string): Promise<{ tags: string[], mirror: string; } | null> => {
|
const fetchFromUrl = async (url: string, usedMirror: string): Promise<{ tags: string[], mirror: string; } | null> => {
|
||||||
try {
|
try {
|
||||||
const raw = await PromiseTimer(
|
const raw = await PromiseTimer(
|
||||||
RequestUtil.HttpGetText(url),
|
RequestUtil.HttpGetText(url),
|
||||||
@ -813,79 +806,55 @@ export async function getAllGitHubTags (owner: string, repo: string): Promise<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 检查返回内容是否有效(不是 HTML 错误页面)
|
// 检查返回内容是否有效(不是 HTML 错误页面)
|
||||||
if (raw.includes('<!DOCTYPE') || raw.includes('<html')) {
|
if (raw.includes('refs/tags')) {
|
||||||
return null;
|
return { tags: parseTags(raw), mirror: usedMirror };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = parseTags(raw);
|
|
||||||
if (tags.length > 0) {
|
|
||||||
return { tags, mirror };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
// 忽略错误
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取快速镜像列表
|
// 准备镜像列表
|
||||||
let fastMirrors: string[] = [];
|
let mirrors: string[] = [];
|
||||||
try {
|
if (mirror) {
|
||||||
fastMirrors = await getFastMirrors();
|
// 如果指定了镜像,只使用该镜像
|
||||||
} catch {
|
mirrors = [mirror];
|
||||||
// 忽略错误
|
} else {
|
||||||
|
// 否则使用 auto 逻辑
|
||||||
|
mirrors = ['', ...currentConfig.fileMirrors.filter(m => m)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建 URL 列表(取前 5 个快速镜像 + 原始 URL 并行请求)
|
// 并行请求
|
||||||
const topMirrors = fastMirrors.slice(0, 5);
|
const promises = mirrors.map(m => {
|
||||||
const mirrorUrls = [
|
const url = m ? buildMirrorUrl(baseUrl, m) : baseUrl;
|
||||||
{ url: baseUrl, mirror: 'github.com' }, // 原始 URL
|
return fetchFromUrl(url, m || 'https://github.com');
|
||||||
...topMirrors.filter(m => m).map(m => ({ url: buildMirrorUrl(baseUrl, m), mirror: m })),
|
});
|
||||||
];
|
|
||||||
|
|
||||||
// 并行请求所有镜像,使用 Promise.any 获取第一个成功的结果
|
|
||||||
try {
|
try {
|
||||||
const result = await Promise.any(
|
const result = await Promise.any(promises.filter(p => p !== null) as Promise<{ tags: string[], mirror: string; } | null>[]);
|
||||||
mirrorUrls.map(async ({ url, mirror }) => {
|
|
||||||
const res = await fetchFromUrl(url, mirror);
|
|
||||||
if (res) return res;
|
|
||||||
throw new Error('Failed');
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// 缓存结果
|
|
||||||
tagsCache.set(cacheKey, { tags: result.tags, mirror: result.mirror, timestamp: Date.now() });
|
|
||||||
return result;
|
|
||||||
} catch {
|
|
||||||
// Promise.any 全部失败,回退到顺序尝试剩余镜像
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回退:顺序尝试剩余镜像
|
|
||||||
const remainingMirrors = fastMirrors.slice(5).filter(m => m);
|
|
||||||
for (const mirror of remainingMirrors) {
|
|
||||||
const url = buildMirrorUrl(baseUrl, mirror);
|
|
||||||
const result = await fetchFromUrl(url, mirror);
|
|
||||||
if (result) {
|
if (result) {
|
||||||
tagsCache.set(cacheKey, { tags: result.tags, mirror: result.mirror, timestamp: Date.now() });
|
tagsCache.set(cacheKey, {
|
||||||
|
tags: result.tags,
|
||||||
|
mirror: result.mirror,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// all failed
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最后尝试所有镜像
|
if (mirror) {
|
||||||
const allMirrors = currentConfig.fileMirrors.filter(m => m && !fastMirrors.includes(m));
|
throw new Error(`指定镜像 ${mirror} 获取 tags 失败`);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('无法获取 tags,所有源都不可用');
|
throw new Error('无法获取 tags,所有镜像源都不可用');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== Action Artifacts 支持 ==============
|
// ============== Action Artifacts 支持 ==============
|
||||||
|
|
||||||
|
// ActionArtifact 接口定义
|
||||||
export interface ActionArtifact {
|
export interface ActionArtifact {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -895,12 +864,14 @@ export interface ActionArtifact {
|
|||||||
archive_download_url: string;
|
archive_download_url: string;
|
||||||
workflow_run_id?: number;
|
workflow_run_id?: number;
|
||||||
head_sha?: string;
|
head_sha?: string;
|
||||||
|
workflow_title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== Action Artifacts 缓存 ==============
|
// ============== Action Artifacts 缓存 ==============
|
||||||
|
|
||||||
interface ArtifactsCache {
|
interface ArtifactsCache {
|
||||||
artifacts: ActionArtifact[];
|
artifacts: ActionArtifact[];
|
||||||
|
mirror: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -920,68 +891,115 @@ export function clearArtifactsCache (): void {
|
|||||||
* 当 api.github.com 不可用时使用
|
* 当 api.github.com 不可用时使用
|
||||||
* 页面格式: https://github.com/{owner}/{repo}/actions/workflows/{workflow}
|
* 页面格式: https://github.com/{owner}/{repo}/actions/workflows/{workflow}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async function getWorkflowRunsFromHtml (
|
async function getWorkflowRunsFromHtml (
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
workflow: string = 'build.yml',
|
workflow: string = 'build.yml',
|
||||||
maxRuns: number = 10
|
maxRuns: number = 10,
|
||||||
): Promise<Array<{ id: number; created_at: string; }>> {
|
mirror?: string
|
||||||
|
): Promise<{ runs: Array<{ id: number; created_at: string; title: string; }>; mirror: string; }> {
|
||||||
const baseUrl = `https://github.com/${owner}/${repo}/actions/workflows/${workflow}`;
|
const baseUrl = `https://github.com/${owner}/${repo}/actions/workflows/${workflow}`;
|
||||||
|
|
||||||
// 尝试使用镜像获取 HTML
|
// 尝试使用镜像获取 HTML
|
||||||
const mirrors = ['', ...currentConfig.fileMirrors.filter(m => m)];
|
// 如果指定了 mirror,则只使用该 mirror
|
||||||
|
let mirrors: string[] = [];
|
||||||
for (const mirror of mirrors) {
|
if (mirror) {
|
||||||
|
mirrors = [mirror];
|
||||||
|
} else {
|
||||||
|
mirrors = ['', ...currentConfig.fileMirrors.filter(m => m)];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mirrorItem of mirrors) {
|
||||||
try {
|
try {
|
||||||
const url = mirror ? buildMirrorUrl(baseUrl, mirror) : baseUrl;
|
const allRuns: Array<{ id: number; created_at: string; title: string; }> = [];
|
||||||
|
|
||||||
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>();
|
const foundIds = new Set<number>();
|
||||||
let timeIndex = 0;
|
let page = 1;
|
||||||
|
const maxPages = 10; // 防止无限请求,最多翻10页(约250个条目)
|
||||||
let match;
|
|
||||||
while ((match = runPattern.exec(html)) !== null && runs.length < maxRuns) {
|
while (allRuns.length < maxRuns && page <= maxPages) {
|
||||||
const id = parseInt(match[1]);
|
const pageUrl = page > 1 ? `${baseUrl}?page=${page}` : baseUrl;
|
||||||
if (!foundIds.has(id)) {
|
const url = mirrorItem ? buildMirrorUrl(pageUrl, mirrorItem) : pageUrl;
|
||||||
foundIds.add(id);
|
|
||||||
// 尝试获取对应的时间,每个 run 通常有两个时间(桌面和移动端显示)
|
const html = await PromiseTimer(
|
||||||
// 所以每找到一个 run,跳过两个时间
|
RequestUtil.HttpGetText(url),
|
||||||
const created_at = times[timeIndex] || new Date().toISOString();
|
10000
|
||||||
timeIndex += 2; // 跳过两个时间(桌面端和移动端各一个)
|
);
|
||||||
runs.push({
|
|
||||||
id,
|
// 使用 Block 分割策略,更稳健地关联 ID 和时间
|
||||||
created_at,
|
const rows = html.split('<div class="Box-row');
|
||||||
});
|
let foundOnThisPage = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
// 提取 Run ID 和 Status
|
||||||
|
// <a href="/NapNeko/NapCatQQ/actions/runs/20799940346" ... aria-label="completed successfully: ...">
|
||||||
|
const runMatch = new RegExp(`href="/${owner}/${repo}/actions/runs/(\\d+)"[^>]*aria-label="([^"]*)"`, 'i').exec(row);
|
||||||
|
|
||||||
|
if (!runMatch || !runMatch[1] || !runMatch[2]) continue;
|
||||||
|
|
||||||
|
const id = parseInt(runMatch[1]);
|
||||||
|
const ariaLabel = runMatch[2];
|
||||||
|
const ariaLabelLower = ariaLabel.toLowerCase();
|
||||||
|
|
||||||
|
// 只需要判断 completed
|
||||||
|
if (ariaLabelLower.includes('completed')) {
|
||||||
|
if (!foundIds.has(id)) {
|
||||||
|
// 提取时间 (取 Block 内的第一个 relative-time)
|
||||||
|
const timeMatch = /<relative-time\s+datetime="([^"]+)"/.exec(row);
|
||||||
|
if (timeMatch && timeMatch[1]) {
|
||||||
|
foundIds.add(id);
|
||||||
|
foundOnThisPage++;
|
||||||
|
|
||||||
|
// 优先从 markdown-title class 提取标题
|
||||||
|
let title = '';
|
||||||
|
const titleMatch = /class="[^"]*markdown-title[^"]*"[^>]*>([\s\S]*?)<\/span>/i.exec(row);
|
||||||
|
if (titleMatch && titleMatch[1]) {
|
||||||
|
title = titleMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没找到,回退到 aria-label 逻辑
|
||||||
|
if (!title) {
|
||||||
|
title = ariaLabel;
|
||||||
|
const prefixMatch = /^(completed successfully:\s*)/i.exec(title);
|
||||||
|
if (prefixMatch) {
|
||||||
|
title = title.substring(prefixMatch[0].length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allRuns.push({
|
||||||
|
id,
|
||||||
|
created_at: timeMatch[1],
|
||||||
|
title: title.trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果本页没有找到任何 completed 的 run(但页面可能不为空),或者页面内容太少(可能是最后一页或错误)
|
||||||
|
// 这里简化判断: 如果本页没提取到任何有效数据,就认为没有更多数据了
|
||||||
|
if (foundOnThisPage === 0) {
|
||||||
|
// 也要考虑到可能是页面解析失败或者全是 failed 状态
|
||||||
|
// 检查是否有翻页按钮可能更复杂,暂时假设如果一整页都没有 successful run,可能后面也没有了,或者我们已经获取够多了
|
||||||
|
// 为了稳健,如果本页没找到,且 allRuns 还没满,尝试下一页 (除非页面很小说明是空页)
|
||||||
|
if (rows.length < 2) { // 只有 split 的第一个空元素
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页逻辑:总是尝试下一页,直到满足 maxRuns
|
||||||
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runs.length > 0) {
|
if (allRuns.length > 0) {
|
||||||
return runs;
|
return { runs: allRuns, mirror: mirrorItem || 'https://github.com' };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return { runs: [], mirror: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -996,122 +1014,49 @@ async function getArtifactsFromNightlyLink (
|
|||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
workflow: string = 'build.yml',
|
workflow: string = 'build.yml',
|
||||||
branch: string = 'main',
|
_branch: string = 'main',
|
||||||
maxRuns: number = 10
|
maxRuns: number = 10,
|
||||||
): Promise<ActionArtifact[]> {
|
mirror?: string
|
||||||
let workflowRuns: Array<{ id: number; head_sha?: string; created_at: string; }> = [];
|
): Promise<{ artifacts: ActionArtifact[], mirror: string; }> {
|
||||||
|
// 策略: 优先使用 nightly.link(更稳定,无需认证)+ HTML 解析
|
||||||
// 策略1: 优先尝试 GitHub API
|
|
||||||
try {
|
try {
|
||||||
const endpoint = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/runs?branch=${branch}&status=success&per_page=${maxRuns}`;
|
// 以前尝试使用 GitHub API,现在弃用,完全使用 HTML 解析逻辑
|
||||||
|
// 并获取 workflow // 直接从 HTML 页面解析
|
||||||
const runsResponse = await PromiseTimer(
|
const { runs: workflowRuns, mirror: runsMirror } = await getWorkflowRunsFromHtml(owner, repo, workflow, maxRuns, mirror);
|
||||||
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) {
|
if (workflowRuns.length === 0) {
|
||||||
workflowRuns = runsResponse.workflow_runs;
|
return { artifacts: [], mirror: runsMirror };
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// API 请求失败,继续尝试 HTML 解析
|
|
||||||
}
|
|
||||||
|
|
||||||
// 策略2: API 失败时,从 HTML 页面解析
|
// 直接拼接 nightly.link URL
|
||||||
if (workflowRuns.length === 0) {
|
// 格式: https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
|
||||||
workflowRuns = await getWorkflowRunsFromHtml(owner, repo, workflow, maxRuns);
|
const artifacts: ActionArtifact[] = [];
|
||||||
}
|
const artifactNames = ['NapCat.Framework', 'NapCat.Shell']; // 已知的 artifact 名称
|
||||||
|
|
||||||
if (workflowRuns.length === 0) {
|
// 如果 HTML 解析使用的 mirror 是 github.com(空),则 nightly.link 使用默认配置
|
||||||
return [];
|
// 如果使用了镜像,可能需要特殊的 nightly.link 镜像,或者这里仅记录 HTML 来源镜像
|
||||||
}
|
// 实际上 nightly.link 本身就是一个服务,我们使用配置中的 nightlyLinkMirrors
|
||||||
|
const baseNightlyMirror = currentConfig.nightlyLinkMirrors[0] || 'https://nightly.link';
|
||||||
|
|
||||||
// 直接拼接 nightly.link URL
|
for (const run of workflowRuns) {
|
||||||
// 格式: https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
|
for (const artifactName of artifactNames) {
|
||||||
const artifacts: ActionArtifact[] = [];
|
artifacts.push({
|
||||||
const artifactNames = ['NapCat.Framework', 'NapCat.Shell']; // 已知的 artifact 名称
|
id: run.id,
|
||||||
|
name: artifactName,
|
||||||
for (const run of workflowRuns) {
|
size_in_bytes: 0,
|
||||||
for (const artifactName of artifactNames) {
|
created_at: run.created_at,
|
||||||
const mirror = currentConfig.nightlyLinkMirrors[0] || 'https://nightly.link';
|
expires_at: new Date(new Date(run.created_at).getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
artifacts.push({
|
archive_download_url: `${baseNightlyMirror}/${owner}/${repo}/actions/runs/${run.id}/${artifactName}.zip`,
|
||||||
id: run.id,
|
workflow_run_id: run.id,
|
||||||
name: artifactName,
|
workflow_title: run.title,
|
||||||
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,
|
|
||||||
repo: string,
|
|
||||||
workflow: string = 'build.yml',
|
|
||||||
branch: string = 'main',
|
|
||||||
maxRuns: number = 10
|
|
||||||
): Promise<ActionArtifact[]> {
|
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
const workflowRuns = runsResponse.workflow_runs;
|
|
||||||
if (!workflowRuns || workflowRuns.length === 0) {
|
|
||||||
throw new Error('No successful workflow runs found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取所有 runs 的 artifacts
|
|
||||||
const allArtifacts: ActionArtifact[] = [];
|
|
||||||
|
|
||||||
for (const run of workflowRuns) {
|
|
||||||
try {
|
|
||||||
const artifactsEndpoint = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${run.id}/artifacts`;
|
|
||||||
const artifactsResponse = await PromiseTimer(
|
|
||||||
RequestUtil.HttpGetJson<{
|
|
||||||
artifacts: ActionArtifact[];
|
|
||||||
}>(artifactsEndpoint, 'GET', undefined, {
|
|
||||||
'User-Agent': 'NapCat',
|
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
|
||||||
}),
|
|
||||||
10000
|
|
||||||
);
|
|
||||||
|
|
||||||
if (artifactsResponse.artifacts) {
|
|
||||||
// 为每个 artifact 添加 run 信息
|
|
||||||
for (const artifact of artifactsResponse.artifacts) {
|
|
||||||
artifact.workflow_run_id = run.id;
|
|
||||||
artifact.head_sha = run.head_sha;
|
|
||||||
allArtifacts.push(artifact);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// 单个 run 获取失败,继续下一个
|
|
||||||
}
|
}
|
||||||
}
|
return { artifacts, mirror: runsMirror };
|
||||||
|
|
||||||
return allArtifacts;
|
} catch {
|
||||||
|
return { artifacts: [], mirror: '' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1121,48 +1066,41 @@ async function getArtifactsFromAPI (
|
|||||||
* 策略:
|
* 策略:
|
||||||
* 1. 检查缓存(10分钟有效)
|
* 1. 检查缓存(10分钟有效)
|
||||||
* 2. 优先尝试从 nightly.link 获取(无需认证,更稳定)
|
* 2. 优先尝试从 nightly.link 获取(无需认证,更稳定)
|
||||||
* 3. 如果失败,回退到 GitHub API
|
* 3. 这里的实现已经完全移除了对 GitHub API 的依赖,直接解析 HTML
|
||||||
*/
|
*/
|
||||||
export async function getLatestActionArtifacts (
|
export async function getLatestActionArtifacts (
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
workflow: string = 'build.yml',
|
workflow: string = 'build.yml',
|
||||||
branch: string = 'main',
|
branch: string = 'main',
|
||||||
maxRuns: number = 10
|
maxRuns: number = 10,
|
||||||
): Promise<ActionArtifact[]> {
|
mirror?: string
|
||||||
const cacheKey = `${owner}/${repo}/${workflow}/${branch}`;
|
): Promise<{ artifacts: ActionArtifact[], mirror: string; }> {
|
||||||
|
const cacheKey = `${owner}/${repo}/${workflow}/${branch}/${mirror || 'auto'}`;
|
||||||
|
|
||||||
// 检查缓存
|
// 检查缓存
|
||||||
const cached = artifactsCache.get(cacheKey);
|
const cached = artifactsCache.get(cacheKey);
|
||||||
if (cached && (Date.now() - cached.timestamp) < ARTIFACTS_CACHE_TTL) {
|
if (cached && (Date.now() - cached.timestamp) < ARTIFACTS_CACHE_TTL) {
|
||||||
return cached.artifacts;
|
return { artifacts: cached.artifacts, mirror: cached.mirror };
|
||||||
}
|
}
|
||||||
|
|
||||||
let artifacts: ActionArtifact[] = [];
|
let result: { artifacts: ActionArtifact[], mirror: string; } = { artifacts: [], mirror: '' };
|
||||||
|
|
||||||
// 策略1: 优先使用 nightly.link(更稳定,无需认证)
|
// 策略: 优先使用 nightly.link(更稳定,无需认证)+ HTML 解析
|
||||||
try {
|
try {
|
||||||
artifacts = await getArtifactsFromNightlyLink(owner, repo, workflow, branch, maxRuns);
|
result = await getArtifactsFromNightlyLink(owner, repo, workflow, branch, maxRuns, mirror);
|
||||||
} catch {
|
} catch {
|
||||||
// nightly.link 获取失败
|
// 获取失败
|
||||||
}
|
|
||||||
|
|
||||||
// 策略2: 回退到 GitHub API
|
|
||||||
if (artifacts.length === 0) {
|
|
||||||
try {
|
|
||||||
artifacts = await getArtifactsFromAPI(owner, repo, workflow, branch, maxRuns);
|
|
||||||
} catch {
|
|
||||||
// API 获取失败
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 缓存结果(即使为空也缓存,避免频繁请求)
|
// 缓存结果(即使为空也缓存,避免频繁请求)
|
||||||
if (artifacts.length > 0) {
|
if (result.artifacts.length > 0) {
|
||||||
artifactsCache.set(cacheKey, {
|
artifactsCache.set(cacheKey, {
|
||||||
artifacts,
|
artifacts: result.artifacts,
|
||||||
|
mirror: result.mirror,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return artifacts;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,9 +65,11 @@ export function compareSemVer (v1: string, v2: string): -1 | 0 | 1 {
|
|||||||
const a = parseSemVer(v1);
|
const a = parseSemVer(v1);
|
||||||
const b = parseSemVer(v2);
|
const b = parseSemVer(v2);
|
||||||
|
|
||||||
if (!a.valid || !b.valid) {
|
if (!a.valid && !b.valid) {
|
||||||
return 0;
|
return v1.localeCompare(v2) as -1 | 0 | 1;
|
||||||
}
|
}
|
||||||
|
if (!a.valid) return -1;
|
||||||
|
if (!b.valid) return 1;
|
||||||
|
|
||||||
// 比较主版本号
|
// 比较主版本号
|
||||||
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
|
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { NapCatPathWrapper } from '@/napcat-common/src/path';
|
|||||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||||
import { connectToNamedPipe } from './pipe';
|
import { connectToNamedPipe } from './pipe';
|
||||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||||
|
import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
|
||||||
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
|
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
@ -71,7 +72,7 @@ function forceKillProcess (pid: number): void {
|
|||||||
/**
|
/**
|
||||||
* 重启 Worker 进程
|
* 重启 Worker 进程
|
||||||
*/
|
*/
|
||||||
export async function restartWorker (): Promise<void> {
|
export async function restartWorker (secretKey?: string): Promise<void> {
|
||||||
logger.log('[NapCat] [Process] 正在重启Worker进程...');
|
logger.log('[NapCat] [Process] 正在重启Worker进程...');
|
||||||
isRestarting = true;
|
isRestarting = true;
|
||||||
|
|
||||||
@ -134,8 +135,8 @@ export async function restartWorker (): Promise<void> {
|
|||||||
logger.log('[NapCat] [Process] Worker进程已关闭,等待 3 秒后启动新进程...');
|
logger.log('[NapCat] [Process] Worker进程已关闭,等待 3 秒后启动新进程...');
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
// 5. 启动新进程(重启模式不传递快速登录参数)
|
// 5. 启动新进程(重启模式不传递快速登录参数,传递密钥)
|
||||||
await startWorker(false);
|
await startWorker(false, secretKey);
|
||||||
isRestarting = false;
|
isRestarting = false;
|
||||||
logger.log('[NapCat] [Process] Worker进程重启完成');
|
logger.log('[NapCat] [Process] Worker进程重启完成');
|
||||||
}
|
}
|
||||||
@ -144,7 +145,7 @@ export async function restartWorker (): Promise<void> {
|
|||||||
* 启动 Worker 进程
|
* 启动 Worker 进程
|
||||||
* @param passQuickLogin 是否传递快速登录参数,默认为 true,重启时为 false
|
* @param passQuickLogin 是否传递快速登录参数,默认为 true,重启时为 false
|
||||||
*/
|
*/
|
||||||
async function startWorker (passQuickLogin: boolean = true): Promise<void> {
|
async function startWorker (passQuickLogin: boolean = true, secretKey?: string): Promise<void> {
|
||||||
if (!processManager) {
|
if (!processManager) {
|
||||||
throw new Error('进程管理器未初始化');
|
throw new Error('进程管理器未初始化');
|
||||||
}
|
}
|
||||||
@ -170,6 +171,7 @@ async function startWorker (passQuickLogin: boolean = true): Promise<void> {
|
|||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
NAPCAT_WORKER_PROCESS: '1',
|
NAPCAT_WORKER_PROCESS: '1',
|
||||||
|
...(secretKey ? { NAPCAT_WEBUI_JWT_SECRET_KEY: secretKey } : {}),
|
||||||
},
|
},
|
||||||
stdio: isElectron ? 'pipe' : ['inherit', 'pipe', 'pipe', 'ipc'],
|
stdio: isElectron ? 'pipe' : ['inherit', 'pipe', 'pipe', 'ipc'],
|
||||||
});
|
});
|
||||||
@ -197,7 +199,8 @@ async function startWorker (passQuickLogin: boolean = true): Promise<void> {
|
|||||||
// 处理重启请求
|
// 处理重启请求
|
||||||
if (typeof msg === 'object' && msg !== null && 'type' in msg && msg.type === 'restart') {
|
if (typeof msg === 'object' && msg !== null && 'type' in msg && msg.type === 'restart') {
|
||||||
logger.log(`[NapCat] [${processType}] 收到重启请求,正在重启Worker进程...`);
|
logger.log(`[NapCat] [${processType}] 收到重启请求,正在重启Worker进程...`);
|
||||||
restartWorker().catch(e => {
|
const secretKey = 'secretKey' in msg ? (msg as any).secretKey : undefined;
|
||||||
|
restartWorker(secretKey).catch(e => {
|
||||||
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
|
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -292,7 +295,8 @@ async function startWorkerProcess (): Promise<void> {
|
|||||||
// 注册重启进程函数到 WebUI
|
// 注册重启进程函数到 WebUI
|
||||||
WebUiDataRuntime.setRestartProcessCall(async () => {
|
WebUiDataRuntime.setRestartProcessCall(async () => {
|
||||||
try {
|
try {
|
||||||
const success = processManager!.sendToParent({ type: 'restart' });
|
const success = processManager!.sendToParent({ type: 'restart', secretKey: AuthHelper.getSecretKey() });
|
||||||
|
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
return { result: true, message: '进程重启请求已发送' };
|
return { result: true, message: '进程重启请求已发送' };
|
||||||
|
|||||||
@ -60,7 +60,7 @@ class ElectronProcessManager implements IProcessManager {
|
|||||||
const child: any = this.utilityProcess.fork(modulePath, args, options);
|
const child: any = this.utilityProcess.fork(modulePath, args, options);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pid: child.pid as number | undefined,
|
get pid () { return child.pid as number | undefined; },
|
||||||
stdout: child.stdout as Readable | null,
|
stdout: child.stdout as Readable | null,
|
||||||
stderr: child.stderr as Readable | null,
|
stderr: child.stderr as Readable | null,
|
||||||
|
|
||||||
@ -113,7 +113,7 @@ class NodeProcessManager implements IProcessManager {
|
|||||||
const child = this.forkFn(modulePath, args, options as any);
|
const child = this.forkFn(modulePath, args, options as any);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pid: child.pid,
|
get pid () { return child.pid; },
|
||||||
stdout: child.stdout,
|
stdout: child.stdout,
|
||||||
stderr: child.stderr,
|
stderr: child.stderr,
|
||||||
|
|
||||||
@ -164,6 +164,9 @@ export async function createProcessManager (): Promise<{
|
|||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
// @ts-ignore - electron 运行时存在但类型声明可能缺失
|
// @ts-ignore - electron 运行时存在但类型声明可能缺失
|
||||||
const electron = await import('electron');
|
const electron = await import('electron');
|
||||||
|
if (electron.app && !electron.app.isReady()) {
|
||||||
|
await electron.app.whenReady();
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
manager: new ElectronProcessManager(electron.utilityProcess),
|
manager: new ElectronProcessManager(electron.utilityProcess),
|
||||||
isElectron: true,
|
isElectron: true,
|
||||||
|
|||||||
@ -72,7 +72,7 @@ export function setPendingTokenToSend (token: string | null) {
|
|||||||
export async function InitPort (parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
|
export async function InitPort (parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
|
||||||
try {
|
try {
|
||||||
await tryUseHost(parsedConfig.host);
|
await tryUseHost(parsedConfig.host);
|
||||||
const port = await tryUsePort(parsedConfig.port, parsedConfig.host);
|
const port = await tryUsePort(parsedConfig.port, parsedConfig.host, 0, 5);
|
||||||
return [parsedConfig.host, port, parsedConfig.token];
|
return [parsedConfig.host, port, parsedConfig.token];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('host或port不可用', error);
|
console.log('host或port不可用', error);
|
||||||
@ -356,7 +356,7 @@ async function tryUseHost (host: string): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tryUsePort (port: number, host: string, tryCount: number = 0): Promise<number> {
|
async function tryUsePort (port: number, host: string, tryCount: number = 0, retryCurrentCount: number = 0): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
@ -367,9 +367,17 @@ async function tryUsePort (port: number, host: string, tryCount: number = 0): Pr
|
|||||||
|
|
||||||
server.on('error', (err: any) => {
|
server.on('error', (err: any) => {
|
||||||
if (err.code === 'EADDRINUSE') {
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
if (retryCurrentCount > 0) {
|
||||||
|
console.log(`[NapCat] [WebUi] 端口 ${port} 被占用,1秒后重试... (剩余重试次数: ${retryCurrentCount})`);
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(tryUsePort(port, host, tryCount, retryCurrentCount - 1));
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (tryCount < MAX_PORT_TRY) {
|
if (tryCount < MAX_PORT_TRY) {
|
||||||
// 使用循环代替递归
|
// 使用循环代替递归
|
||||||
resolve(tryUsePort(port + 1, host, tryCount + 1));
|
resolve(tryUsePort(port + 1, host, tryCount + 1, 0));
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`));
|
reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`));
|
||||||
}
|
}
|
||||||
@ -386,3 +394,4 @@ async function tryUsePort (port: number, host: string, tryCount: number = 0): Pr
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
|||||||
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||||
import { getLatestTag, getAllTags, compareSemVer } from 'napcat-common/src/helper';
|
import { getLatestTag, getAllTags, compareSemVer } from 'napcat-common/src/helper';
|
||||||
import { getLatestActionArtifacts } from '@/napcat-common/src/mirror';
|
import { getLatestActionArtifacts, getMirrorConfig } from '@/napcat-common/src/mirror';
|
||||||
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
|
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
|
||||||
|
|
||||||
export const GetNapCatVersion: RequestHandler = (_, res) => {
|
export const GetNapCatVersion: RequestHandler = (_, res) => {
|
||||||
@ -35,6 +35,7 @@ export interface VersionInfo {
|
|||||||
size?: number;
|
size?: number;
|
||||||
workflowRunId?: number;
|
workflowRunId?: number;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
|
workflowTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -47,11 +48,17 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
|||||||
const pageSize = parseInt(req.query['pageSize'] as string) || 20;
|
const pageSize = parseInt(req.query['pageSize'] as string) || 20;
|
||||||
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();
|
||||||
|
const mirror = req.query['mirror'] as string | undefined;
|
||||||
|
|
||||||
let versions: VersionInfo[] = [];
|
let versions: VersionInfo[] = [];
|
||||||
let actionVersions: VersionInfo[] = [];
|
let actionVersions: VersionInfo[] = [];
|
||||||
let usedMirror = '';
|
let usedMirror = '';
|
||||||
|
|
||||||
|
// If mirror is specified, report it as used (will be confirmed by actual fetching response)
|
||||||
|
if (mirror) {
|
||||||
|
usedMirror = mirror;
|
||||||
|
}
|
||||||
|
|
||||||
// 懒加载:只获取需要的版本类型
|
// 懒加载:只获取需要的版本类型
|
||||||
const needReleases = !typeFilter || typeFilter === 'all' || typeFilter === 'release';
|
const needReleases = !typeFilter || typeFilter === 'all' || typeFilter === 'release';
|
||||||
const needActions = typeFilter === 'action' || typeFilter === 'all';
|
const needActions = typeFilter === 'action' || typeFilter === 'all';
|
||||||
@ -59,9 +66,12 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
|||||||
// 获取正式版本(仅当需要时)
|
// 获取正式版本(仅当需要时)
|
||||||
if (needReleases) {
|
if (needReleases) {
|
||||||
try {
|
try {
|
||||||
const result = await getAllTags();
|
const result = await getAllTags(mirror);
|
||||||
usedMirror = result.mirror;
|
// 如果没有指定镜像,使用实际上使用的镜像
|
||||||
|
if (!mirror) {
|
||||||
|
usedMirror = result.mirror;
|
||||||
|
}
|
||||||
|
|
||||||
versions = result.tags.map(tag => {
|
versions = result.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 {
|
||||||
@ -81,14 +91,19 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
|||||||
// 获取 Action Artifacts(仅当需要时)
|
// 获取 Action Artifacts(仅当需要时)
|
||||||
if (needActions) {
|
if (needActions) {
|
||||||
try {
|
try {
|
||||||
const artifacts = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main');
|
const { artifacts, mirror: actionMirror } = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main', 10, mirror);
|
||||||
|
|
||||||
// 根据当前工作环境自动过滤对应的 artifact 类型
|
// 根据当前工作环境自动过滤对应的 artifact 类型
|
||||||
const isFramework = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework;
|
const isFramework = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework;
|
||||||
const targetArtifactName = isFramework ? 'NapCat.Framework' : 'NapCat.Shell';
|
const targetArtifactName = isFramework ? 'NapCat.Framework' : 'NapCat.Shell';
|
||||||
|
|
||||||
|
// 如果没有指定镜像,且 action 实际上用了一个镜像(自动选择的),更新 usedMirror
|
||||||
|
if (!mirror && actionMirror) {
|
||||||
|
usedMirror = actionMirror;
|
||||||
|
}
|
||||||
|
|
||||||
actionVersions = artifacts
|
actionVersions = artifacts
|
||||||
.filter(a => a.name === targetArtifactName)
|
.filter(a => a && a.name === targetArtifactName)
|
||||||
.map(a => ({
|
.map(a => ({
|
||||||
tag: `action-${a.id}`,
|
tag: `action-${a.id}`,
|
||||||
type: 'action' as const,
|
type: 'action' as const,
|
||||||
@ -99,6 +114,7 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
|||||||
size: a.size_in_bytes,
|
size: a.size_in_bytes,
|
||||||
workflowRunId: a.workflow_run_id,
|
workflowRunId: a.workflow_run_id,
|
||||||
headSha: a.head_sha,
|
headSha: a.head_sha,
|
||||||
|
workflowTitle: a.workflow_title,
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
// 获取失败时返回空列表
|
// 获取失败时返回空列表
|
||||||
@ -114,7 +130,9 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
|||||||
allVersions = allVersions.filter(v => {
|
allVersions = allVersions.filter(v => {
|
||||||
const tagMatch = v.tag.toLowerCase().includes(searchQuery);
|
const tagMatch = v.tag.toLowerCase().includes(searchQuery);
|
||||||
const nameMatch = v.artifactName?.toLowerCase().includes(searchQuery);
|
const nameMatch = v.artifactName?.toLowerCase().includes(searchQuery);
|
||||||
return tagMatch || nameMatch;
|
const titleMatch = v.workflowTitle?.toLowerCase().includes(searchQuery);
|
||||||
|
const shaMatch = v.headSha?.toLowerCase().includes(searchQuery);
|
||||||
|
return tagMatch || nameMatch || titleMatch || shaMatch;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,3 +173,8 @@ export const SetThemeConfigHandler: RequestHandler = async (req, res) => {
|
|||||||
await WebUiConfig.UpdateTheme(theme);
|
await WebUiConfig.UpdateTheme(theme);
|
||||||
sendSuccess(res, { message: '更新成功' });
|
sendSuccess(res, { message: '更新成功' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const GetMirrorsHandler: RequestHandler = (_, res) => {
|
||||||
|
const config = getMirrorConfig();
|
||||||
|
sendSuccess(res, { mirrors: config.fileMirrors });
|
||||||
|
};
|
||||||
|
|||||||
@ -20,6 +20,8 @@ interface UpdateRequestBody {
|
|||||||
targetVersion?: string;
|
targetVersion?: string;
|
||||||
/** 是否强制更新(即使是降级也更新) */
|
/** 是否强制更新(即使是降级也更新) */
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
/** 指定使用的镜像 */
|
||||||
|
mirror?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新配置文件接口
|
// 更新配置文件接口
|
||||||
@ -124,7 +126,7 @@ async function downloadFile (url: string, dest: string): Promise<void> {
|
|||||||
export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// 从请求体获取目标版本(可选)
|
// 从请求体获取目标版本(可选)
|
||||||
const { targetVersion, force } = req.body as UpdateRequestBody;
|
const { targetVersion, force, mirror } = req.body as UpdateRequestBody;
|
||||||
|
|
||||||
// 确定要下载的文件名
|
// 确定要下载的文件名
|
||||||
const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip';
|
const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip';
|
||||||
@ -150,20 +152,21 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
|||||||
|
|
||||||
// 根据当前工作环境确定 artifact 名称
|
// 根据当前工作环境确定 artifact 名称
|
||||||
const artifactName = ReleaseName.replace('.zip', ''); // NapCat.Framework 或 NapCat.Shell
|
const artifactName = ReleaseName.replace('.zip', ''); // NapCat.Framework 或 NapCat.Shell
|
||||||
|
|
||||||
// Action artifacts 通过 nightly.link 下载
|
// Action artifacts 通过 nightly.link 下载
|
||||||
// 格式:https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
|
// 格式:https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
|
||||||
const baseUrl = `https://nightly.link/NapNeko/NapCatQQ/actions/runs/${runId}/${artifactName}.zip`;
|
const baseUrl = `https://nightly.link/NapNeko/NapCatQQ/actions/runs/${runId}/${artifactName}.zip`;
|
||||||
actualVersion = targetTag;
|
actualVersion = targetTag;
|
||||||
|
|
||||||
webUiLogger?.log(`[NapCat Update] Action artifact URL: ${baseUrl}`);
|
webUiLogger?.log(`[NapCat Update] Action artifact URL: ${baseUrl}`);
|
||||||
|
|
||||||
// 使用 mirror 模块查找可用的 nightly.link 镜像
|
// 使用 mirror 模块查找可用的 nightly.link 镜像
|
||||||
try {
|
try {
|
||||||
downloadUrl = await findAvailableDownloadUrl(baseUrl, {
|
downloadUrl = await findAvailableDownloadUrl(baseUrl, {
|
||||||
validateContent: true,
|
validateContent: true,
|
||||||
minFileSize: 1024 * 1024,
|
minFileSize: 1024 * 1024,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
|
customMirror: mirror,
|
||||||
});
|
});
|
||||||
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
|
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -178,6 +181,7 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
|||||||
const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, {
|
const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, {
|
||||||
assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'],
|
assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'],
|
||||||
fetchChangelog: false, // 不需要 changelog,避免 API 调用
|
fetchChangelog: false, // 不需要 changelog,避免 API 调用
|
||||||
|
mirror,
|
||||||
});
|
});
|
||||||
|
|
||||||
const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName);
|
const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName);
|
||||||
@ -193,6 +197,7 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
|||||||
validateContent: true, // 验证 Content-Type 和状态码
|
validateContent: true, // 验证 Content-Type 和状态码
|
||||||
minFileSize: 1024 * 1024, // 最小 1MB,确保不是错误页面
|
minFileSize: 1024 * 1024, // 最小 1MB,确保不是错误页面
|
||||||
timeout: 10000, // 10秒超时
|
timeout: 10000, // 10秒超时
|
||||||
|
customMirror: mirror,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,10 @@ import type { WebUiCredentialJson, WebUiCredentialInnerJson } from '@/napcat-web
|
|||||||
export class AuthHelper {
|
export class AuthHelper {
|
||||||
private static readonly secretKey = process.env['NAPCAT_WEBUI_JWT_SECRET_KEY'] || Math.random().toString(36).slice(2);
|
private static readonly secretKey = process.env['NAPCAT_WEBUI_JWT_SECRET_KEY'] || Math.random().toString(36).slice(2);
|
||||||
|
|
||||||
|
public static getSecretKey (): string {
|
||||||
|
return AuthHelper.secretKey;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 签名凭证方法。
|
* 签名凭证方法。
|
||||||
* @param hash 待签名的凭证字符串。
|
* @param hash 待签名的凭证字符串。
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler } from '../api/BaseInfo';
|
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler, GetMirrorsHandler } from '../api/BaseInfo';
|
||||||
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
|
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
|
||||||
import { GetProxyHandler } from '../api/Proxy';
|
import { GetProxyHandler } from '../api/Proxy';
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ router.get('/QQVersion', QQVersionHandler);
|
|||||||
router.get('/GetNapCatVersion', GetNapCatVersion);
|
router.get('/GetNapCatVersion', GetNapCatVersion);
|
||||||
router.get('/getLatestTag', getLatestTagHandler);
|
router.get('/getLatestTag', getLatestTagHandler);
|
||||||
router.get('/getAllReleases', getAllReleasesHandler);
|
router.get('/getAllReleases', getAllReleasesHandler);
|
||||||
|
router.get('/getMirrors', GetMirrorsHandler);
|
||||||
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
|
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
|
||||||
router.get('/proxy', GetProxyHandler);
|
router.get('/proxy', GetProxyHandler);
|
||||||
router.get('/Theme', GetThemeConfigHandler);
|
router.get('/Theme', GetThemeConfigHandler);
|
||||||
|
|||||||
@ -269,6 +269,7 @@ interface VersionInfo {
|
|||||||
size?: number;
|
size?: number;
|
||||||
workflowRunId?: number;
|
workflowRunId?: number;
|
||||||
headSha?: string;
|
headSha?: string;
|
||||||
|
workflowTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 版本选择对话框内容
|
// 版本选择对话框内容
|
||||||
@ -290,6 +291,14 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
|||||||
const [activeTab, setActiveTab] = useState<'release' | 'action'>('release');
|
const [activeTab, setActiveTab] = useState<'release' | 'action'>('release');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||||
|
|
||||||
|
const [selectedMirror, setSelectedMirror] = useState<string | undefined>(undefined);
|
||||||
|
const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, {
|
||||||
|
cacheKey: 'napcat-mirrors',
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
const mirrors = mirrorsData?.mirrors || [];
|
||||||
|
|
||||||
const pageSize = 15;
|
const pageSize = 15;
|
||||||
|
|
||||||
// 获取所有可用版本(带分页、过滤和搜索)
|
// 获取所有可用版本(带分页、过滤和搜索)
|
||||||
@ -299,15 +308,16 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
|||||||
page: currentPage,
|
page: currentPage,
|
||||||
pageSize,
|
pageSize,
|
||||||
type: activeTab,
|
type: activeTab,
|
||||||
search: debouncedSearch
|
search: debouncedSearch,
|
||||||
|
mirror: selectedMirror
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
refreshDeps: [currentPage, activeTab, debouncedSearch],
|
refreshDeps: [currentPage, activeTab, debouncedSearch, selectedMirror],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 版本列表已在后端过滤,直接使用
|
// 版本列表已在后端过滤,直接使用
|
||||||
const filteredVersions = releasesData?.versions || [];
|
const filteredVersions = (releasesData?.versions || []) as VersionInfo[];
|
||||||
|
|
||||||
// 检查是否是降级(使用语义化版本比较)
|
// 检查是否是降级(使用语义化版本比较)
|
||||||
const isDowngrade = useCallback((targetTag: string): boolean => {
|
const isDowngrade = useCallback((targetTag: string): boolean => {
|
||||||
@ -320,6 +330,22 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
|||||||
const selectedVersionTag = selectedVersion?.tag || '';
|
const selectedVersionTag = selectedVersion?.tag || '';
|
||||||
const isSelectedDowngrade = isDowngrade(selectedVersionTag);
|
const isSelectedDowngrade = isDowngrade(selectedVersionTag);
|
||||||
|
|
||||||
|
const performUpdate = async (force: boolean) => {
|
||||||
|
if (!selectedVersion) return;
|
||||||
|
setUpdateStatus('updating');
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await WebUIManager.UpdateNapCatToVersion(selectedVersionTag, force, selectedMirror);
|
||||||
|
setUpdateStatus('success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update failed:', err);
|
||||||
|
const errMsg = err instanceof Error ? err.message : '未知错误';
|
||||||
|
setErrorMessage(errMsg);
|
||||||
|
setUpdateStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
if (!selectedVersion) return;
|
if (!selectedVersion) return;
|
||||||
|
|
||||||
@ -346,22 +372,6 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
|||||||
await performUpdate(forceUpdate);
|
await performUpdate(forceUpdate);
|
||||||
};
|
};
|
||||||
|
|
||||||
const performUpdate = async (force: boolean) => {
|
|
||||||
if (!selectedVersion) return;
|
|
||||||
setUpdateStatus('updating');
|
|
||||||
setErrorMessage('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await WebUIManager.UpdateNapCatToVersion(selectedVersionTag, force);
|
|
||||||
setUpdateStatus('success');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Update failed:', err);
|
|
||||||
const errMsg = err instanceof Error ? err.message : '未知错误';
|
|
||||||
setErrorMessage(errMsg);
|
|
||||||
setUpdateStatus('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理分页变化
|
// 处理分页变化
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
@ -375,13 +385,30 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
|||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
|
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className='text-center'>
|
<div className='text-center w-full'>
|
||||||
<p className='text-sm font-medium text-success-600 dark:text-success-400'>
|
<p className='text-sm font-medium text-success-600 dark:text-success-400'>
|
||||||
更新到 {selectedVersionTag} 完成
|
更新到 {selectedVersionTag} 完成
|
||||||
</p>
|
</p>
|
||||||
<p className='text-xs text-default-500 mt-1'>
|
<p className='text-xs text-default-500 mt-1 mb-6'>
|
||||||
请重启 NapCat 以应用新版本
|
请重启 NapCat 以应用新版本
|
||||||
</p>
|
</p>
|
||||||
|
<div className='flex gap-3 justify-center'>
|
||||||
|
<button
|
||||||
|
className='px-4 py-2 text-sm rounded-lg bg-default-100 hover:bg-default-200 transition-colors text-default-700'
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
稍后重启
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className='px-4 py-2 text-sm rounded-lg bg-success-500 hover:bg-success-600 text-white shadow-sm transition-colors shadow-success-500/20'
|
||||||
|
onClick={async () => {
|
||||||
|
await WebUIManager.restart();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
立即重启
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -463,23 +490,46 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
|||||||
<Tab key='action' title='临时版本 (Action)' />
|
<Tab key='action' title='临时版本 (Action)' />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* 搜索框 */}
|
<div className="flex gap-2">
|
||||||
<Input
|
{/* 搜索框 */}
|
||||||
placeholder='搜索版本号...'
|
<Input
|
||||||
size='sm'
|
placeholder='搜索版本号...'
|
||||||
value={searchQuery}
|
size='sm'
|
||||||
onValueChange={(value) => {
|
value={searchQuery}
|
||||||
setSearchQuery(value);
|
onValueChange={(value) => {
|
||||||
setCurrentPage(1);
|
setSearchQuery(value);
|
||||||
setSelectedVersion(null);
|
setCurrentPage(1);
|
||||||
}}
|
setSelectedVersion(null);
|
||||||
startContent={<IoSearch className='text-default-400' />}
|
}}
|
||||||
isClearable
|
startContent={<IoSearch className='text-default-400' />}
|
||||||
onClear={() => setSearchQuery('')}
|
isClearable
|
||||||
classNames={{
|
onClear={() => setSearchQuery('')}
|
||||||
inputWrapper: 'h-9',
|
classNames={{
|
||||||
}}
|
inputWrapper: 'h-9',
|
||||||
/>
|
base: 'flex-1'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 镜像选择 */}
|
||||||
|
<Select
|
||||||
|
placeholder="自动选择 (默认)"
|
||||||
|
selectedKeys={selectedMirror ? [selectedMirror] : ['default']}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const m = Array.from(keys)[0] as string;
|
||||||
|
setSelectedMirror(m === 'default' ? undefined : m);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
className="w-48"
|
||||||
|
classNames={{ trigger: 'h-9 min-h-9' }}
|
||||||
|
aria-label="选择镜像源"
|
||||||
|
>
|
||||||
|
{['default', ...mirrors].map(m => (
|
||||||
|
<SelectItem key={m} textValue={m === 'default' ? '自动选择' : m}>
|
||||||
|
{m === 'default' ? '自动选择 (默认)' : m}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 版本选择 */}
|
{/* 版本选择 */}
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
@ -528,7 +578,12 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
|||||||
>
|
>
|
||||||
<div className='flex flex-col gap-0.5'>
|
<div className='flex flex-col gap-0.5'>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<span>{version.type === 'action' && version.artifactName ? version.artifactName : version.tag}</span>
|
<span className="truncate max-w-[300px]">
|
||||||
|
{version.type === 'action'
|
||||||
|
? (version.workflowTitle || version.artifactName || version.tag)
|
||||||
|
: version.tag
|
||||||
|
}
|
||||||
|
</span>
|
||||||
{version.type === 'prerelease' && (
|
{version.type === 'prerelease' && (
|
||||||
<Chip size='sm' color='secondary' variant='flat'>预发布</Chip>
|
<Chip size='sm' color='secondary' variant='flat'>预发布</Chip>
|
||||||
)}
|
)}
|
||||||
@ -543,10 +598,11 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{version.type === 'action' && (
|
{version.type === 'action' && (
|
||||||
<div className='text-xs text-default-400'>
|
<div className='text-xs text-default-400 flex items-center gap-2'>
|
||||||
{version.headSha && <span className='font-mono'>{version.headSha.slice(0, 7)}</span>}
|
<span className='font-mono bg-default-100 dark:bg-default-100/10 px-1 rounded'>{version.tag}</span>
|
||||||
{version.createdAt && <span className='ml-2'>{new Date(version.createdAt).toLocaleString()}</span>}
|
{version.headSha && <span className='font-mono' title={version.headSha}>{version.headSha.slice(0, 7)}</span>}
|
||||||
{version.size && <span className='ml-2'>{(version.size / 1024 / 1024).toFixed(1)} MB</span>}
|
{version.createdAt && <span>{new Date(version.createdAt).toLocaleString()}</span>}
|
||||||
|
{version.size && <span>{(version.size / 1024 / 1024).toFixed(1)} MB</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -568,6 +624,7 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -72,8 +72,9 @@ export default class WebUIManager {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
type?: 'release' | 'action' | 'all';
|
type?: 'release' | 'action' | 'all';
|
||||||
search?: string;
|
search?: string;
|
||||||
|
mirror?: string;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { page = 1, pageSize = 20, type = 'release', search = '' } = options;
|
const { page = 1, pageSize = 20, type = 'release', search = '', mirror } = options;
|
||||||
const { data } = await serverRequest.get<ServerResponse<{
|
const { data } = await serverRequest.get<ServerResponse<{
|
||||||
versions: Array<{
|
versions: Array<{
|
||||||
tag: string;
|
tag: string;
|
||||||
@ -94,15 +95,21 @@ export default class WebUIManager {
|
|||||||
};
|
};
|
||||||
mirror?: string;
|
mirror?: string;
|
||||||
}>>('/base/getAllReleases', {
|
}>>('/base/getAllReleases', {
|
||||||
params: { page, pageSize, type, search },
|
params: { page, pageSize, type, search, mirror },
|
||||||
});
|
});
|
||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async UpdateNapCat () {
|
public static async getMirrors () {
|
||||||
|
const { data } =
|
||||||
|
await serverRequest.get<ServerResponse<{ mirrors: string[]; }>>('/base/getMirrors');
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async UpdateNapCat (mirror?: string) {
|
||||||
const { data } = await serverRequest.post<ServerResponse<any>>(
|
const { data } = await serverRequest.post<ServerResponse<any>>(
|
||||||
'/UpdateNapCat/update',
|
'/UpdateNapCat/update',
|
||||||
{},
|
{ mirror },
|
||||||
{ timeout: 120000 } // 2分钟超时
|
{ timeout: 120000 } // 2分钟超时
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
@ -112,11 +119,12 @@ export default class WebUIManager {
|
|||||||
* 更新到指定版本
|
* 更新到指定版本
|
||||||
* @param targetVersion 目标版本 tag,如 "v4.9.9" 或 "action-123456"
|
* @param targetVersion 目标版本 tag,如 "v4.9.9" 或 "action-123456"
|
||||||
* @param force 是否强制更新(允许降级)
|
* @param force 是否强制更新(允许降级)
|
||||||
|
* @param mirror 指定使用的镜像
|
||||||
*/
|
*/
|
||||||
public static async UpdateNapCatToVersion (targetVersion: string, force: boolean = false) {
|
public static async UpdateNapCatToVersion (targetVersion: string, force: boolean = false, mirror?: string) {
|
||||||
const { data } = await serverRequest.post<ServerResponse<any>>(
|
const { data } = await serverRequest.post<ServerResponse<any>>(
|
||||||
'/UpdateNapCat/update',
|
'/UpdateNapCat/update',
|
||||||
{ targetVersion, force },
|
{ targetVersion, force, mirror },
|
||||||
{ timeout: 120000 } // 2分钟超时
|
{ timeout: 120000 } // 2分钟超时
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
@ -142,6 +150,16 @@ export default class WebUIManager {
|
|||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async restart () {
|
||||||
|
const { data } = await serverRequest.post<ServerResponse<any>>('/Process/Restart');
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getAllUsers (): Promise<any> {
|
||||||
|
const { data } = await serverRequest.get<ServerResponse<any>>('/QQLogin/GetAllUsers');
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
public static async getLogList () {
|
public static async getLogList () {
|
||||||
const { data } =
|
const { data } =
|
||||||
await serverRequest.get<ServerResponse<string[]>>('/Log/GetLogList');
|
await serverRequest.get<ServerResponse<string[]>>('/Log/GetLogList');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user