Files
koring-space/lib/github-releases.ts
T
dream_pep dcae468055 feat: 下载中心对接 GitHub Releases 并适配 latest.yml
- 新增 /download 下载页:正式版/预览版双渠道、latest.yml/latest-beta.yml 数据源、镜像下载源切换、SHA512 校验展示
- 新增 /api/download-version:GitHub API 自动识别 + 镜像兜底 + yml 解析 + 旧源兜底
- /launcher/join-beta 改为自动识别 GitHub 预览版,支持镜像下载源切换
- 新增 markdown-lite 轻量渲染器、lib/download-sources 与 lib/github-releases 共享模块
- 头部导航新增下载中心入口
2026-08-31 12:01:41 +08:00

203 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DATA_MIRRORS } from "./download-sources";
/**
* GitHub Releases 共享辅助函数
* 发布方案见《Electron Windows自动更新方案规划》:
* 仓库:dream-pep/koring-launcher
* 预览版 tagv{base}-beta.{RUN_NUMBER}(如 v1.2.5-beta.16),GitHub 标记为 prerelease
* 正式版 tagv{base}-{RUN_NUMBER}(如 v1.2.5-14),非 prerelease
* electron-builder 发布产物:latest.yml / latest-beta.yml + koring-launcher-{version}-setup.exe
*/
export const REPO = "dream-pep/koring-launcher";
export const GITHUB_API = `https://api.github.com/repos/${REPO}/releases?per_page=100`;
export const GITHUB_API_PROXY = `https://gh-proxy.com/https://api.github.com/repos/${REPO}/releases?per_page=100`;
export const RELEASES_PAGE = `https://github.com/${REPO}/releases`;
const GH_HEADERS: Record<string, string> = {
"User-Agent": "koring-space-website",
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
/** 预览版 tag 形态:v1.2.5-beta.16 */
export const BETA_TAG = /^v?\d+\.\d+\.\d+-beta\.\d+$/i;
interface FetchResult {
ok: boolean;
text: string;
json: any;
}
async function fetchRaw(
url: string,
headers?: Record<string, string>
): Promise<FetchResult> {
const res = await fetch(url, {
headers,
cache: "no-store",
signal: AbortSignal.timeout(15_000),
});
const text = await res.text();
let json: any = null;
try {
json = JSON.parse(text);
} catch {
// 非 JSON
}
return { ok: res.ok, text, json };
}
/** 依次尝试多个源,返回第一个成功的结果(raw: true 时返回文本,否则返回 JSON) */
export async function trySources(
sources: Array<{ url: string; headers?: Record<string, string>; raw?: boolean }>
): Promise<unknown | null> {
for (const s of sources) {
try {
const r = await fetchRaw(s.url, s.headers);
if (!r.ok) continue;
if (s.raw) return r.text;
if (r.json !== null && r.json !== undefined) return r.json;
} catch {
// 继续尝试下一个源
}
}
return null;
}
/** 拉取 release 列表(直连 -> 镜像代理) */
export async function fetchReleases(): Promise<any[] | null> {
const releases = await trySources([
{ url: GITHUB_API, headers: GH_HEADERS },
{ url: GITHUB_API_PROXY, headers: GH_HEADERS },
]);
return Array.isArray(releases) ? releases : null;
}
function byPublishedDesc(a: any, b: any) {
const ta = String(a.published_at || a.created_at || "");
const tb = String(b.published_at || b.created_at || "");
return tb.localeCompare(ta);
}
/** 最新预览版 releaseprerelease 标记或 -beta.N tag */
export function pickBetaRelease(releases: any[]) {
const betas = (releases || []).filter(
(r) =>
r &&
!r.draft &&
(r.prerelease === true || BETA_TAG.test(String(r.tag_name || "")))
);
betas.sort(byPublishedDesc);
return betas[0] || null;
}
/** 最新正式版 release(非 prerelease、非 draft */
export function pickStableRelease(releases: any[]) {
const stables = (releases || []).filter(
(r) => r && !r.draft && r.prerelease !== true
);
stables.sort(byPublishedDesc);
return stables[0] || null;
}
/** 按文件名匹配 release 资产 */
export function pickAsset(release: any, patterns: RegExp[]) {
const assets = release?.assets || [];
for (const p of patterns) {
const hit = assets.find((a: any) => p.test(String(a?.name || "")));
if (hit) return hit;
}
return null;
}
/** 构造 GitHub 下载直链 */
export function buildDownloadUrl(tag: string, fileName: string) {
return `https://github.com/${REPO}/releases/download/${tag}/${encodeURIComponent(
fileName
)}`;
}
/**
* 解析 electron-builder 的 latest.yml 格式:
* version / files: [{url, sha512, size}] / path / sha512 / releaseDate
*/
export function parseLatestYml(text: string) {
const out: {
version?: string;
path?: string;
sha512?: string;
size?: number;
releaseDate?: string;
files: Array<{ url: string; sha512?: string; size?: number }>;
} = { files: [] };
const unquote = (v: string) => v.trim().replace(/^['"](.*)['"]$/, "$1");
let currentFile: { url?: string; sha512?: string; size?: number } | null = null;
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.replace(/\r$/, "");
if (!line.trim() || line.trim().startsWith("#")) continue;
// 列表项: - url: xxx / sha512: yyy
const entry = line.match(/^(\s*)-\s+([A-Za-z0-9_-]+):\s*(.*)$/);
if (entry) {
currentFile = {};
out.files.push(currentFile as { url: string });
const key = entry[2];
const val = unquote(entry[3]);
if (key === "url") currentFile.url = val;
else if (key === "sha512") currentFile.sha512 = val;
else if (key === "size") currentFile.size = Number(val);
continue;
}
const kv = line.match(/^(\s*)([A-Za-z0-9_-]+):\s*(.*)$/);
if (!kv) continue;
const indent = kv[1].length;
const key = kv[2];
const val = unquote(kv[3]);
if (indent === 0) {
if (key === "version") out.version = val;
else if (key === "path") out.path = val;
else if (key === "sha512") out.sha512 = val;
else if (key === "size") out.size = Number(val);
else if (key === "releaseDate") out.releaseDate = val;
} else if (currentFile) {
if (key === "sha512") currentFile.sha512 = val;
else if (key === "size") currentFile.size = Number(val);
}
}
return out;
}
/**
* 拉取 release 的 latest.yml / latest-beta.yml 内容并解析
* (直连 -> 镜像兜底,自动过滤 HTML 垃圾页)
*/
export async function fetchYmlForRelease(
release: any,
names: string[]
): Promise<ReturnType<typeof parseLatestYml> | null> {
if (!release) return null;
const assets = release.assets || [];
const asset = names
.map((n) => assets.find((a: any) => a?.name === n))
.find(Boolean);
if (!asset?.browser_download_url) return null;
const text = await trySources([
{ url: asset.browser_download_url, headers: GH_HEADERS, raw: true },
...DATA_MIRRORS.map((m) => ({
url: `${m}/${asset.browser_download_url}`,
raw: true,
})),
]);
if (typeof text !== "string") return null;
const parsed = parseLatestYml(text);
return parsed.version ? parsed : null;
}