diff --git a/app/api/beta-version/route.ts b/app/api/beta-version/route.ts index e64d8ac..42353f2 100644 --- a/app/api/beta-version/route.ts +++ b/app/api/beta-version/route.ts @@ -1,12 +1,190 @@ import { NextResponse } from "next/server"; -export async function GET() { - const res = await fetch( - "https://koring-launcher-file-api.lenjing.cloud/launcher/beta/version.json" - ); - if (!res.ok) { - return NextResponse.json({ error: `HTTP ${res.status}` }, { status: res.status }); +/** + * 预览版(Beta)发布信息接口 + * + * 数据源优先级: + * 1. GitHub Releases API(直连)—— 主源,自动识别最新预览版 + * 2. GitHub Releases API(gh-proxy.com 镜像)—— 国内可达 + * 3. 旧静态源 version.json —— 兜底(同时提供 Beta 协议文本) + * + * 发布方案与仓库信息见《Electron Windows自动更新方案规划》: + * 仓库:dream-pep/koring-launcher + * 预览版 tag:v{base}-beta.{RUN_NUMBER}(如 v1.2.5-beta.16),GitHub 标记为 prerelease + * 正式版 tag:v{base}-{RUN_NUMBER}(如 v1.2.5-14),非 prerelease + * 安装包命名:koring-launcher-{version}-setup.exe(electron-builder artifactName) + */ + +const REPO = "dream-pep/koring-launcher"; +const GITHUB_API = `https://api.github.com/repos/${REPO}/releases?per_page=100`; +const GITHUB_API_PROXY = `https://gh-proxy.com/https://api.github.com/repos/${REPO}/releases?per_page=100`; +const LEGACY_URL = + "https://koring-launcher-file-api.lenjing.cloud/launcher/beta/version.json"; +const RELEASES_PAGE = `https://github.com/${REPO}/releases`; + +const GH_HEADERS: Record = { + "User-Agent": "koring-space-website", + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", +}; + +/** 预览版 tag 形态:v1.2.5-beta.16 */ +const BETA_TAG = /^v?\d+\.\d+\.\d+-beta\.\d+$/i; + +const EMPTY_LICENCE = { isNeedAgreat: "false", title: "", text: "", liceURL: "" }; + +const EMPTY_APPS = { + windows: { isNoVersion: "true", licence: EMPTY_LICENCE, DownlodeURL: "" }, + macos: { isNoVersion: "true", licence: EMPTY_LICENCE, DownlodeURL: "" }, + linux: { isNoVersion: "true", licence: EMPTY_LICENCE, DownlodeURL: "" }, +}; + +// 简单内存缓存(TTL 15 分钟),避免频繁打 GitHub API / 限流代理 +const CACHE_TTL = 15 * 60 * 1000; +const cache = new Map(); + +async function fetchJson(url: string, headers?: Record) { + const res = await fetch(url, { + headers, + cache: "no-store", + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) return null; + try { + return await res.json(); + } catch { + return null; } - const data = await res.json(); - return NextResponse.json(data); +} + +/** 依次尝试多个数据源,返回第一个成功的结果 */ +async function trySources( + sources: Array<{ url: string; headers?: Record }> +): Promise { + for (const s of sources) { + try { + const json = await fetchJson(s.url, s.headers); + if (json !== null && json !== undefined) return json; + } catch { + // 继续尝试下一个源 + } + } + return null; +} + +/** 选出最新预览版 release(prerelease 标记或 -beta.N tag) */ +function pickBetaRelease(releases: any[]) { + const betas = (releases || []).filter( + (r) => + r && + !r.draft && + (r.prerelease === true || BETA_TAG.test(String(r.tag_name || ""))) + ); + betas.sort((a, b) => { + const ta = String(a.published_at || a.created_at || ""); + const tb = String(b.published_at || b.created_at || ""); + return tb.localeCompare(ta); + }); + return betas[0] || null; +} + +/** 按文件名匹配 release 资产 */ +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; +} + +function buildPlatforms(release: any, legacyApp: any) { + const win = pickAsset(release, [/setup\.exe$/i, /\.exe$/i]); + const mac = pickAsset(release, [/\.dmg$/i, /\.zip$/i, /\.pkg$/i]); + const lin = pickAsset(release, [ + /\.appimage$/i, + /\.deb$/i, + /\.rpm$/i, + /\.tar\.gz$/i, + ]); + + const mk = (hit: any, key: string) => ({ + isNoVersion: hit ? "false" : "true", + licence: legacyApp?.[key]?.licence ?? EMPTY_LICENCE, + DownlodeURL: hit?.browser_download_url || "", + }); + + return { + windows: mk(win, "windows"), + macos: mk(mac, "macos"), + linux: mk(lin, "linux"), + }; +} + +function buildGithubPayload(release: any, legacy: any) { + const version = String(release.tag_name || "").replace(/^v/i, ""); + return { + source: "github", + version, + builddate: release.published_at || release.created_at || "", + tag: release.tag_name, + htmlUrl: release.html_url || `${RELEASES_PAGE}/tag/${release.tag_name}`, + releaseNotes: release.body || "", + app: buildPlatforms(release, legacy?.app), + }; +} + +function buildLegacyPayload(legacy: any) { + return { + source: "legacy", + version: String(legacy.version || ""), + builddate: String(legacy.builddate || ""), + app: legacy.app ?? EMPTY_APPS, + aboutversion: legacy.aboutversion, + }; +} + +export async function GET() { + const now = Date.now(); + const cached = cache.get("beta"); + if (cached && now - cached.time < CACHE_TTL) { + return NextResponse.json(cached.value); + } + + // 1) 主源:GitHub Releases API(直连 -> 镜像) + const releases = (await trySources([ + { url: GITHUB_API, headers: GH_HEADERS }, + { url: GITHUB_API_PROXY, headers: GH_HEADERS }, + ])) as any[] | null; + + // 2) 兜底源:旧静态 JSON(也用于补充 Beta 协议 licence) + const legacy = await trySources([{ url: LEGACY_URL }]); + + let payload: unknown; + + if (Array.isArray(releases) && releases.length > 0) { + const beta = pickBetaRelease(releases); + if (beta) { + payload = buildGithubPayload(beta, legacy); + } else { + // GitHub 可达但没有任何预览版 + payload = { + source: "github", + noRelease: true, + version: "", + builddate: "", + app: EMPTY_APPS, + }; + } + } else if (legacy) { + payload = buildLegacyPayload(legacy); + } else { + return NextResponse.json( + { error: "无法获取版本信息,请稍后重试" }, + { status: 502 } + ); + } + + cache.set("beta", { time: now, value: payload }); + return NextResponse.json(payload); } diff --git a/app/api/download-version/route.ts b/app/api/download-version/route.ts new file mode 100644 index 0000000..383673a --- /dev/null +++ b/app/api/download-version/route.ts @@ -0,0 +1,144 @@ +import { NextResponse } from "next/server"; +import { + fetchReleases, + pickBetaRelease, + pickStableRelease, + pickAsset, + buildDownloadUrl, + fetchYmlForRelease, + RELEASES_PAGE, +} from "@/lib/github-releases"; + +/** + * 下载页版本信息接口 + * 同时返回 正式版(latest.yml)与 预览版(latest-beta.yml / latest.yml)两个渠道, + * 版本 / 安装包文件名 / 大小 / SHA512 / 发布时间 均取自 electron-builder 发布的 yml, + * 与客户端自动更新机制(electron-updater)保持一致。 + */ + +const CACHE_TTL = 15 * 60 * 1000; +const cache = new Map(); + +const LEGACY_URL = + "https://koring-launcher-file-api.lenjing.cloud/launcher/beta/version.json"; + +async function buildChannelAsync( + release: any, + ymlNames: string[], + fallbackExePatterns: RegExp[] +) { + if (!release) return null; + const tag = String(release.tag_name || ""); + const version = tag.replace(/^v/i, ""); + + // 主源:latest.yml / latest-beta.yml(electron-updater 权威数据) + const yml = await fetchYmlForRelease(release, ymlNames); + const ymlFile = yml?.files?.[0]; + const fileName = ymlFile?.url || yml?.path || ""; + + const windowsFile = fileName + ? { name: fileName } + : pickAsset(release, fallbackExePatterns); + + const mac = pickAsset(release, [/\.dmg$/i, /\.zip$/i, /\.pkg$/i]); + const lin = pickAsset(release, [ + /\.appimage$/i, + /\.deb$/i, + /\.rpm$/i, + /\.tar\.gz$/i, + ]); + + return { + version, + tag, + releaseDate: yml?.releaseDate || release.published_at || release.created_at || "", + releaseNotes: release.body || "", + htmlUrl: release.html_url || `${RELEASES_PAGE}/tag/${tag}`, + windows: windowsFile + ? { + name: windowsFile.name, + url: buildDownloadUrl(tag, windowsFile.name), + size: ymlFile?.size ?? undefined, + sha512: yml?.sha512 || ymlFile?.sha512 || "", + } + : null, + macos: mac + ? { name: mac.name, url: buildDownloadUrl(tag, mac.name) } + : null, + linux: lin + ? { name: lin.name, url: buildDownloadUrl(tag, lin.name) } + : null, + }; +} + +function buildLegacyPayload(legacy: any) { + const win = legacy?.app?.windows; + const preview: any = { + version: String(legacy?.version || ""), + tag: "", + releaseDate: "", + releaseNotes: legacy?.aboutversion?.about || "", + htmlUrl: RELEASES_PAGE, + windows: win?.DownlodeURL + ? { + name: String(win.DownlodeURL).split("/").pop() || "", + url: win.DownlodeURL, + size: undefined, + sha512: "", + } + : null, + macos: null, + linux: null, + }; + return { source: "legacy", stable: null, preview }; +} + +export async function GET() { + const now = Date.now(); + const cached = cache.get("download"); + if (cached && now - cached.time < CACHE_TTL) { + return NextResponse.json(cached.value); + } + + let payload: unknown; + + const releases = await fetchReleases(); + if (Array.isArray(releases) && releases.length > 0) { + const stableRelease = pickStableRelease(releases); + const betaRelease = pickBetaRelease(releases); + + const [stable, preview] = await Promise.all([ + buildChannelAsync(stableRelease, ["latest.yml"], [/setup\.exe$/i, /\.exe$/i]), + buildChannelAsync( + betaRelease, + ["latest-beta.yml", "latest.yml"], + [/setup\.exe$/i, /\.exe$/i] + ), + ]); + + payload = { source: "github", stable, preview }; + } else { + // GitHub 不可达 -> 旧静态源兜底(仅预览渠道) + let legacy: any = null; + try { + const res = await fetch(LEGACY_URL, { + cache: "no-store", + signal: AbortSignal.timeout(15_000), + }); + if (res.ok) legacy = await res.json(); + } catch { + legacy = null; + } + if (legacy) { + payload = buildLegacyPayload(legacy); + } else { + return NextResponse.json( + { error: "无法获取版本信息,请稍后重试" }, + { status: 502 } + ); + } + } + + cache.set("download", { time: now, value: payload }); + return NextResponse.json(payload); +} diff --git a/app/download/page.tsx b/app/download/page.tsx new file mode 100644 index 0000000..0da9279 --- /dev/null +++ b/app/download/page.tsx @@ -0,0 +1,330 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Download, + Package, + Calendar, + HardDrive, + ShieldCheck, + Copy, + Check, + Loader2, + AlertCircle, + ArrowUpRight, + GitBranch, + FileText, + Inbox, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { MarkdownLite } from "@/components/markdown-lite"; +import { + DOWNLOAD_SOURCES, + resolveDownloadUrl, + type DownloadSourceKey, +} from "@/lib/download-sources"; + +interface ChannelPlatform { + name: string; + url: string; + size?: number; + sha512?: string; +} + +interface ChannelData { + version: string; + tag?: string; + releaseDate?: string; + releaseNotes?: string; + htmlUrl?: string; + windows?: ChannelPlatform | null; + macos?: ChannelPlatform | null; + linux?: ChannelPlatform | null; +} + +interface DownloadData { + source?: "github" | "legacy"; + stable: ChannelData | null; + preview: ChannelData | null; +} + +const CHANNEL_META = [ + { key: "stable" as const, label: "正式版", desc: "稳定渠道,与自动更新的慢走模式一致" }, + { key: "preview" as const, label: "预览版", desc: "抢先体验新功能,可能存在不稳定因素" }, +]; + +export default function DownloadPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [channel, setChannel] = useState<"stable" | "preview">("stable"); + const [downloadSource, setDownloadSource] = + useState("github"); + const [copied, setCopied] = useState(false); + + useEffect(() => { + fetch("/api/download-version") + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((json: DownloadData) => { + setData(json); + setLoading(false); + }) + .catch((err) => { + setError(err.message); + setLoading(false); + }); + }, []); + + // 当前渠道:选中的不存在时自动回退到可用渠道 + const activeKey = + data && data[channel] ? channel : data?.stable ? "stable" : "preview"; + const active = data ? data[activeKey] : null; + const win = active?.windows || null; + const isGithub = data?.source === "github"; + + const formatSize = (bytes?: number) => + bytes ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : "未知"; + const formatDate = (d?: string) => + d ? new Date(d).toLocaleDateString("zh-CN") : "未知"; + + const handleDownload = () => { + if (!win?.url) return; + window.open(resolveDownloadUrl(downloadSource, win.url), "_blank"); + }; + + const copySha = async () => { + if (!win?.sha512) return; + try { + await navigator.clipboard.writeText(win.sha512); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // 剪贴板不可用时忽略 + } + }; + + if (loading) { + return ( +
+ +

正在获取版本信息...

+
+ ); + } + + if (error || !data) { + return ( +
+ +

获取版本信息失败

+ +
+ ); + } + + if (!active) { + return ( +
+ +
+

暂无可用版本

+

+ 暂时没有可用的发布版本,请稍后再来看看 +

+
+ + + +
+ ); + } + + const meta = CHANNEL_META.find((c) => c.key === activeKey)!; + + return ( +
+ {/* Header */} +
+ + 下载中心 + +

+ Koring Launcher +

+

+ 基于 Electron 的 Minecraft 启动器,版本信息与自动更新机制同源( + latest.yml),下载安装包即可开始使用 +

+ {isGithub && ( + + + 版本信息由 GitHub Releases 自动识别 + + )} +
+ + {/* 渠道切换 */} +
+ {CHANNEL_META.map((c) => { + const d = c.key === "stable" ? data.stable : data.preview; + return ( + + ); + })} +
+ + {/* 渠道信息卡 */} +
+
+
+
+
+ +
+
+
+

+ {active.version} +

+ {meta.label} +
+

+ {meta.desc} +

+
+
+ {active.htmlUrl && ( + + 在 GitHub 查看发布页 + + + )} +
+ + {/* 元信息 */} +
+
+ +
+

发布时间

+

+ {formatDate(active.releaseDate)} +

+
+
+
+ +
+

文件大小

+

{formatSize(win?.size)}

+
+
+ +
+ + {/* 下载区 */} + {win ? ( +
+
+
+ 下载源 + {DOWNLOAD_SOURCES.map((s) => ( + + ))} +
+ + {win.name} + +
+ + {!active.macos && !active.linux && ( +

+ macOS / Linux 版本尚未提供,敬请期待 +

+ )} +
+ ) : ( +
+ 此渠道暂未提供安装包 +
+ )} +
+ + {/* 更新内容 */} + {active.releaseNotes && ( +
+
+ +

版本更新内容

+
+ +
+ )} +
+
+ ); +} diff --git a/app/launcher/join-beta/page.tsx b/app/launcher/join-beta/page.tsx index b09b8bb..aaf98a2 100644 --- a/app/launcher/join-beta/page.tsx +++ b/app/launcher/join-beta/page.tsx @@ -19,11 +19,20 @@ import { AlertCircle, Package, Hash, + Calendar, + GitBranch, + PackageOpen, ArrowUpRight, FileText, Download, } from "lucide-react"; import { cn } from "@/lib/utils"; +import { MarkdownLite } from "@/components/markdown-lite"; +import { + DOWNLOAD_SOURCES, + resolveDownloadUrl, + type DownloadSourceKey, +} from "@/lib/download-sources"; interface License { isNeedAgreat: string; @@ -46,12 +55,19 @@ interface AboutVersion { interface BetaData { version: string; builddate: string; + /** 数据来源:github = GitHub Releases 自动识别;legacy = 旧静态源兜底 */ + source?: "github" | "legacy"; + tag?: string; + htmlUrl?: string; + releaseNotes?: string; + /** GitHub 可达但没有预览版时的标记 */ + noRelease?: boolean; app: { windows: PlatformInfo; macos: PlatformInfo; linux: PlatformInfo; }; - aboutversion: AboutVersion; + aboutversion?: AboutVersion; } const platforms = [ @@ -66,6 +82,7 @@ export default function JoinBetaPage() { const [error, setError] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [activePlatform, setActivePlatform] = useState(null); + const [downloadSource, setDownloadSource] = useState("github"); useEffect(() => { fetch("/api/beta-version") @@ -88,13 +105,19 @@ export default function JoinBetaPage() { setActivePlatform(platform); setDialogOpen(true); } else { - window.open(platform.DownlodeURL, "_blank"); + window.open( + resolveDownloadUrl(downloadSource, platform.DownlodeURL), + "_blank" + ); } }; const confirmDownload = () => { if (activePlatform?.DownlodeURL) { - window.open(activePlatform.DownlodeURL, "_blank"); + window.open( + resolveDownloadUrl(downloadSource, activePlatform.DownlodeURL), + "_blank" + ); } setDialogOpen(false); setActivePlatform(null); @@ -121,6 +144,30 @@ export default function JoinBetaPage() { ); } + if (data.noRelease) { + return ( +
+ +
+

暂无预览版本

+

+ GitHub Releases 中暂时没有可用的预览(Beta)版本,请稍后再来看看 +

+
+ + + +
+ ); + } + return (
{/* Header */} @@ -134,6 +181,32 @@ export default function JoinBetaPage() {

参与 Koring Launcher 的 Beta 测试计划,抢先体验最新功能

+
+ {data.source === "github" ? ( + <> + + + 版本信息由 GitHub Releases 自动识别 + + {data.htmlUrl && ( + + 在 GitHub 查看发布页 + + + )} + + ) : ( + + + 版本信息来自缓存数据源 + + )} +
{/* Version Info Card */} @@ -149,18 +222,53 @@ export default function JoinBetaPage() {
- + {data.source === "github" ? ( + + ) : ( + + )}
-

编译号

-

{data.builddate}

+

+ {data.source === "github" ? "发布时间" : "编译号"} +

+

+ {data.source === "github" + ? data.builddate + ? new Date(data.builddate).toLocaleDateString("zh-CN") + : "未知" + : data.builddate} +

{/* Platform Cards */}
-

选择平台

+
+

选择平台

+ {data.source === "github" && + platforms.some(({ key }) => !!data.app[key].DownlodeURL) && ( +
+ 下载源 + {DOWNLOAD_SOURCES.map((s) => ( + + ))} +
+ )} +
{platforms.map(({ key, label, icon: Icon }) => { const platform = data.app[key]; @@ -211,25 +319,33 @@ export default function JoinBetaPage() {
- {/* About This Version */} + {/* Release Notes / About This Version */}
-

关于此版本

+

+ {data.releaseNotes ? "版本更新内容" : "关于此版本"} +

-

- {data.aboutversion.about} -

-
    - {Object.entries(data.aboutversion["about-list"]).map( - ([key, text]) => ( -
  • - - {text} -
  • - ) - )} -
+ {data.releaseNotes ? ( + + ) : data.aboutversion ? ( + <> +

+ {data.aboutversion.about} +

+
    + {Object.entries(data.aboutversion["about-list"]).map( + ([key, text]) => ( +
  • + + {text} +
  • + ) + )} +
+ + ) : null}
{/* License Dialog */} diff --git a/components/markdown-lite.tsx b/components/markdown-lite.tsx new file mode 100644 index 0000000..e4a074b --- /dev/null +++ b/components/markdown-lite.tsx @@ -0,0 +1,176 @@ +import React from "react"; +import { cn } from "@/lib/utils"; + +/** + * 轻量 Markdown 渲染器(无依赖) + * 用于渲染 GitHub Release Notes 这类常见格式: + * 标题、无序/有序列表、引用、代码块、粗体、行内代码、链接、分隔线、段落 + */ + +function renderInline(text: string, keyPrefix: string): React.ReactNode[] { + const parts: React.ReactNode[] = []; + const regex = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g; + let last = 0; + let m: RegExpExecArray | null; + let i = 0; + + while ((m = regex.exec(text)) !== null) { + if (m.index > last) { + parts.push(text.slice(last, m.index)); + } + const tok = m[0]; + if (tok.startsWith("**")) { + parts.push( + + {tok.slice(2, -2)} + + ); + } else if (tok.startsWith("`")) { + parts.push( + + {tok.slice(1, -1)} + + ); + } else { + const link = tok.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (link) { + parts.push( + + {link[1]} + + ); + } else { + parts.push(tok); + } + } + last = m.index + tok.length; + i += 1; + } + if (last < text.length) { + parts.push(text.slice(last)); + } + return parts; +} + +export function MarkdownLite({ + text, + className, +}: { + text: string; + className?: string; +}) { + const lines = text.split(/\r?\n/); + const blocks: React.ReactNode[] = []; + let key = 0; + + let inCode = false; + let codeBuf: string[] = []; + + const push = (node: React.ReactNode) => { + blocks.push(
{node}
); + }; + + for (const line of lines) { + // 围栏代码块 + if (/^```/.test(line.trim())) { + if (inCode) { + push( +
+            {codeBuf.join("\n")}
+          
+ ); + codeBuf = []; + inCode = false; + } else { + inCode = true; + } + continue; + } + if (inCode) { + codeBuf.push(line); + continue; + } + + const trimmed = line.trim(); + if (!trimmed) continue; // 空行跳过 + + // 标题 + const heading = trimmed.match(/^(#{1,4})\s+(.*)$/); + if (heading) { + const lv = heading[1].length; + const content = renderInline(heading[2], `h${key}`); + const cls = { + 1: "text-xl font-bold tracking-tight", + 2: "text-lg font-bold tracking-tight", + 3: "text-base font-semibold", + 4: "text-sm font-semibold", + }[lv as 1 | 2 | 3 | 4]; + push(

{content}

); + continue; + } + + // 分隔线 + if (/^-{3,}$/.test(trimmed)) { + push(
); + continue; + } + + // 引用 + if (/^>/.test(trimmed)) { + push( +
+ {renderInline(trimmed.replace(/^>\s?/, ""), `q${key}`)} +
+ ); + continue; + } + + // 无序列表 + const ul = trimmed.match(/^[-*]\s+(.*)$/); + if (ul) { + push( +
+ + + {renderInline(ul[1], `ul${key}`)} + +
+ ); + continue; + } + + // 有序列表 + const ol = trimmed.match(/^\d+\.\s+(.*)$/); + if (ol) { + push( +
+ + {trimmed.match(/^\d+/)?.[0]}. + + + {renderInline(ol[1], `ol${key}`)} + +
+ ); + continue; + } + + // 普通段落 + push( +

+ {renderInline(trimmed, `p${key}`)} +

+ ); + } + + return
{blocks}
; +} diff --git a/layout/header.tsx b/layout/header.tsx index 5973d4f..6ed8756 100644 --- a/layout/header.tsx +++ b/layout/header.tsx @@ -13,6 +13,7 @@ const navItems = [ textColor: "#fff", links: [ { label: "Koring Launcher", href: "/launcher", ariaLabel: "Koring Launcher" }, + { label: "下载中心", href: "/download", ariaLabel: "下载中心" }, { label: "Sanshe Play", href: "https://docs.play.lenjing.work", ariaLabel: "Sanshe Play" }, ], }, diff --git a/lib/download-sources.ts b/lib/download-sources.ts new file mode 100644 index 0000000..67a48f2 --- /dev/null +++ b/lib/download-sources.ts @@ -0,0 +1,21 @@ +/** 下载源列表:GitHub 官方直链 + 国内镜像(格式 {镜像}/{GitHub 直链}) */ +export const DOWNLOAD_SOURCES = [ + { key: "github", label: "GitHub 官方", url: (u: string) => u }, + { key: "ddlc", label: "镜像 · ddlc", url: (u: string) => `https://gh.ddlc.top/${u}` }, + { key: "proxy", label: "镜像 · proxy", url: (u: string) => `https://gh-proxy.com/${u}` }, + { key: "fast", label: "镜像 · fast", url: (u: string) => `https://ghfast.top/${u}` }, +] as const; + +export type DownloadSourceKey = (typeof DOWNLOAD_SOURCES)[number]["key"]; + +export function resolveDownloadUrl(source: DownloadSourceKey, url: string) { + const s = DOWNLOAD_SOURCES.find((d) => d.key === source); + return s ? s.url(url) : url; +} + +/** 服务端拉取 GitHub 数据(yml / release 页面)时使用的镜像前缀 */ +export const DATA_MIRRORS = [ + "https://gh.ddlc.top", + "https://gh-proxy.com", + "https://ghfast.top", +] as const; diff --git a/lib/github-releases.ts b/lib/github-releases.ts new file mode 100644 index 0000000..bb0d073 --- /dev/null +++ b/lib/github-releases.ts @@ -0,0 +1,202 @@ +import { DATA_MIRRORS } from "./download-sources"; + +/** + * GitHub Releases 共享辅助函数 + * 发布方案见《Electron Windows自动更新方案规划》: + * 仓库:dream-pep/koring-launcher + * 预览版 tag:v{base}-beta.{RUN_NUMBER}(如 v1.2.5-beta.16),GitHub 标记为 prerelease + * 正式版 tag:v{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 = { + "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 +): Promise { + 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; raw?: boolean }> +): Promise { + 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 { + 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); +} + +/** 最新预览版 release(prerelease 标记或 -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 | 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; +}