macOS
x64 / arm64 · DMG / ZIP
macOS
Apple 芯片 / Intel · DMG / ZIP
diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml
index b210619..f94398d 100644
--- a/.github/workflows/packages.yml
+++ b/.github/workflows/packages.yml
@@ -131,14 +131,17 @@ jobs:
retention-days: 30
release:
- name: Publish GitHub Release
+ name: Publish GitHub and OSS release
if: github.event_name == 'push' && github.ref_type == 'tag'
needs: package
runs-on: ubuntu-24.04
- timeout-minutes: 20
+ timeout-minutes: 35
+ environment:
+ name: aliyun-oss-release
permissions:
contents: write
actions: read
+ id-token: write
steps:
- uses: actions/checkout@v7
@@ -181,7 +184,102 @@ jobs:
- name: Verify and aggregate release assets
run: node build/aggregate-release.cjs --input dist/release-downloads --output dist/release-upload
- - name: Create or update draft release
+ - name: Verify OSS release configuration
+ shell: bash
+ env:
+ OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
+ OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
+ OIDC_PROVIDER_ARN: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }}
+ ROLE_ARN: ${{ vars.ALIYUN_ROLE_ARN }}
+ run: |
+ set -euo pipefail
+ test -n "$OSS_BUCKET"
+ test -n "$OSS_ENDPOINT"
+ test -n "$OIDC_PROVIDER_ARN"
+ test -n "$ROLE_ARN"
+ case "$OSS_BUCKET" in
+ *[!a-z0-9-]*|'') echo "OSS Bucket 名称无效" >&2; exit 1 ;;
+ esac
+ case "$OSS_ENDPOINT" in
+ https://oss-*.aliyuncs.com) ;;
+ *) echo "OSS Endpoint 必须使用标准 HTTPS 地址" >&2; exit 1 ;;
+ esac
+ case "$OIDC_PROVIDER_ARN" in
+ acs:ram::*:oidc-provider/*) ;;
+ *) echo "OIDC Provider ARN 无效" >&2; exit 1 ;;
+ esac
+ case "$ROLE_ARN" in
+ acs:ram::*:role/*) ;;
+ *) echo "RAM Role ARN 无效" >&2; exit 1 ;;
+ esac
+
+ - name: Authenticate to Alibaba Cloud
+ uses: aliyun/configure-aliyun-credentials-action@v1
+ with:
+ role-to-assume: ${{ vars.ALIYUN_ROLE_ARN }}
+ oidc-provider-arn: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }}
+ role-session-name: goodbuddy-release-${{ github.run_id }}
+ role-session-expiration: 3600
+ audience: sts.aliyuncs.com
+
+ - name: Install ossutil
+ shell: bash
+ run: |
+ set -euo pipefail
+ version="2.3.0"
+ archive="$RUNNER_TEMP/ossutil.zip"
+ directory="$RUNNER_TEMP/ossutil"
+ curl --fail --silent --show-error --location \
+ "https://gosspublic.alicdn.com/ossutil/v2/$version/ossutil-$version-linux-amd64.zip" \
+ --output "$archive"
+ mkdir "$directory"
+ unzip -q "$archive" -d "$directory"
+ binary="$(find "$directory" -type f -name ossutil -print -quit)"
+ test -n "$binary"
+ chmod +x "$binary"
+ echo "$(dirname "$binary")" >> "$GITHUB_PATH"
+
+ - name: Prepare OSS website release index
+ id: oss-release
+ shell: bash
+ env:
+ OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
+ OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
+ run: |
+ set -euo pipefail
+ endpoint_host="${OSS_ENDPOINT#https://}"
+ base_url="https://${OSS_BUCKET}.${endpoint_host}/releases/${GITHUB_REF_NAME}/"
+ node build/create-site-release.cjs \
+ --manifest dist/release-upload/release-manifest.json \
+ --base-url "$base_url" \
+ --output dist/site-release.json
+ echo "base-url=$base_url" >> "$GITHUB_OUTPUT"
+
+ - name: Upload immutable release assets to OSS
+ shell: bash
+ env:
+ OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
+ OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
+ run: |
+ set -euo pipefail
+ export OSS_ACCESS_KEY_ID="$ALIBABA_CLOUD_ACCESS_KEY_ID"
+ export OSS_ACCESS_KEY_SECRET="$ALIBABA_CLOUD_ACCESS_KEY_SECRET"
+ export OSS_SESSION_TOKEN="$ALIBABA_CLOUD_SECURITY_TOKEN"
+ test -n "$OSS_ACCESS_KEY_ID"
+ test -n "$OSS_ACCESS_KEY_SECRET"
+ test -n "$OSS_SESSION_TOKEN"
+ for file in dist/release-upload/* dist/site-release.json; do
+ name="$(basename "$file")"
+ ossutil cp "$file" \
+ "oss://${OSS_BUCKET}/releases/${GITHUB_REF_NAME}/${name}" \
+ --endpoint "$OSS_ENDPOINT" \
+ --update
+ done
+
+ - name: Verify public OSS release assets
+ run: node build/verify-site-release.cjs --manifest dist/site-release.json
+
+ - name: Create or update draft GitHub release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
@@ -196,3 +294,18 @@ jobs:
fi
gh release upload "$tag" dist/release-upload/* --clobber
gh release edit "$tag" --draft=false --latest
+
+ - name: Point website to verified OSS release
+ shell: bash
+ env:
+ OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
+ OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
+ run: |
+ set -euo pipefail
+ export OSS_ACCESS_KEY_ID="$ALIBABA_CLOUD_ACCESS_KEY_ID"
+ export OSS_ACCESS_KEY_SECRET="$ALIBABA_CLOUD_ACCESS_KEY_SECRET"
+ export OSS_SESSION_TOKEN="$ALIBABA_CLOUD_SECURITY_TOKEN"
+ ossutil cp dist/site-release.json \
+ "oss://${OSS_BUCKET}/releases/latest.json" \
+ --endpoint "$OSS_ENDPOINT" \
+ --force
diff --git a/BUILD.md b/BUILD.md
index 302e57c..de7dc0b 100644
--- a/BUILD.md
+++ b/BUILD.md
@@ -169,8 +169,9 @@ ZIP,以及 Linux 的 AppImage 与 DEB。Windows portable ZIP 解压后可直
推送 `v${package.version}` 标签时,工作流运行验证和六平台打包。只有在
全部目标成功后,才会严格校验并聚合所有平台产物,生成按平台重命名的
manifests、总 `release-manifest.json` 和 `SHA256SUMS`。随后工作流创建或
-更新 draft GitHub Release,上传全部资产成功后才发布。重跑会保留人工
-编辑的 Release notes 和未知附件。
+更新 draft GitHub Release,上传全部资产成功后才发布,并在全部下载资产
+验证通过后切换官网最新版本索引。任一步失败都不会切换官网最新版本。
+重跑会保留人工编辑的 Release notes 和未知附件。
中英文发布说明统一维护在 `resources/release-notes.json`。新版本按“本次
亮点 / Highlights”“功能更新 / Features”“问题修复 / Bug Fixes”“使用前
diff --git a/FEATURES.md b/FEATURES.md
index 9490018..c2cad21 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -71,7 +71,7 @@
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、本地目录导入、切换和删除。
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
-- [x] **版本检查**:仅检查固定官方 Release 和当前平台清单,不自动下载或安装。
+- [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。
- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。
### 开源、构建与发布
diff --git a/UI-DESIGN.md b/UI-DESIGN.md
index 531e0c5..e7f81d1 100644
--- a/UI-DESIGN.md
+++ b/UI-DESIGN.md
@@ -534,6 +534,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。
- 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
+- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
- “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。
diff --git a/build/create-site-release.cjs b/build/create-site-release.cjs
new file mode 100644
index 0000000..7383777
--- /dev/null
+++ b/build/create-site-release.cjs
@@ -0,0 +1,203 @@
+const {
+ mkdirSync,
+ readFileSync,
+ writeFileSync
+} = require('node:fs')
+const { dirname, resolve } = require('node:path')
+
+const supportedTargets = new Map([
+ ['windows-x64', ['nsis', 'portable']],
+ ['windows-arm64', ['nsis', 'portable']],
+ ['macos-x64', ['dmg', 'zip']],
+ ['macos-arm64', ['dmg', 'zip']],
+ ['linux-x64', ['AppImage', 'deb']],
+ ['linux-arm64', ['AppImage', 'deb']]
+])
+
+function parseArguments(argv) {
+ const options = {}
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index]
+ if (
+ argument === '--manifest' ||
+ argument === '--base-url' ||
+ argument === '--output'
+ ) {
+ const value = argv[index + 1]
+ if (!value) {
+ throw new Error(`${argument} 缺少值`)
+ }
+ options[argument.slice(2)] = value
+ index += 1
+ } else {
+ throw new Error(`未知参数:${argument}`)
+ }
+ }
+ if (!options.manifest || !options.baseUrl || !options.output) {
+ throw new Error('必须指定 --manifest、--base-url 和 --output')
+ }
+ return {
+ manifest: resolve(options.manifest),
+ baseUrl: options.baseUrl,
+ output: resolve(options.output)
+ }
+}
+
+function validateBaseUrl(value) {
+ let url
+ try {
+ url = new URL(value)
+ } catch {
+ throw new Error(`OSS 基础地址无效:${value}`)
+ }
+ if (
+ url.protocol !== 'https:' ||
+ url.username ||
+ url.password ||
+ url.search ||
+ url.hash
+ ) {
+ throw new Error('OSS 基础地址必须是无凭据、查询参数和片段的 HTTPS 地址')
+ }
+ if (!url.pathname.endsWith('/')) {
+ url.pathname += '/'
+ }
+ return url
+}
+
+function assertFile(file, targetKey) {
+ if (
+ !file ||
+ typeof file.name !== 'string' ||
+ !/^GoodBuddy-[A-Za-z0-9._-]+$/u.test(file.name) ||
+ !Number.isSafeInteger(file.size) ||
+ file.size < 1 ||
+ typeof file.sha256 !== 'string' ||
+ !/^[a-f0-9]{64}$/u.test(file.sha256)
+ ) {
+ throw new Error(`发布文件元数据无效:${targetKey}`)
+ }
+}
+
+function formatForFile(fileName, platform) {
+ if (platform === 'windows') {
+ if (/-setup\.exe$/u.test(fileName)) {
+ return 'nsis'
+ }
+ if (/-portable\.zip$/u.test(fileName)) {
+ return 'portable'
+ }
+ } else if (platform === 'macos') {
+ if (fileName.endsWith('.dmg')) {
+ return 'dmg'
+ }
+ if (fileName.endsWith('.zip')) {
+ return 'zip'
+ }
+ } else if (platform === 'linux') {
+ if (fileName.endsWith('.AppImage')) {
+ return 'AppImage'
+ }
+ if (fileName.endsWith('.deb')) {
+ return 'deb'
+ }
+ }
+ return undefined
+}
+
+function createSiteRelease(manifest, baseUrlValue) {
+ if (
+ !manifest ||
+ manifest.formatVersion !== 1 ||
+ manifest.productName !== 'GoodBuddy' ||
+ typeof manifest.version !== 'string' ||
+ !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(manifest.version) ||
+ !Array.isArray(manifest.targets)
+ ) {
+ throw new Error('聚合发布 manifest 元数据无效')
+ }
+ const baseUrl = validateBaseUrl(baseUrlValue)
+ const seenTargets = new Set()
+ const targets = {}
+ for (const target of manifest.targets) {
+ const key = `${target?.platform}-${target?.arch}`
+ const expectedFormats = supportedTargets.get(key)
+ if (
+ !expectedFormats ||
+ seenTargets.has(key) ||
+ !Array.isArray(target.formats) ||
+ !Array.isArray(target.files) ||
+ target.formats.length !== expectedFormats.length ||
+ !expectedFormats.every(
+ (format, index) => target.formats[index] === format
+ ) ||
+ target.files.length !== expectedFormats.length
+ ) {
+ throw new Error(`发布目标元数据无效:${key}`)
+ }
+ const files = {}
+ for (const file of target.files) {
+ assertFile(file, key)
+ const format = formatForFile(file.name, target.platform)
+ if (!format || !expectedFormats.includes(format) || files[format]) {
+ throw new Error(`发布文件格式无效:${file.name}`)
+ }
+ files[format] = {
+ name: file.name,
+ size: file.size,
+ sha256: file.sha256,
+ url: new URL(encodeURIComponent(file.name), baseUrl).href
+ }
+ }
+ if (expectedFormats.some((format) => !files[format])) {
+ throw new Error(`发布目标文件不完整:${key}`)
+ }
+ targets[key] = {
+ platform: target.platform,
+ arch: target.arch,
+ files
+ }
+ seenTargets.add(key)
+ }
+ if (seenTargets.size !== supportedTargets.size) {
+ throw new Error(
+ `发布目标数量错误:期望 ${supportedTargets.size},实际 ${seenTargets.size}`
+ )
+ }
+ return {
+ formatVersion: 1,
+ productName: manifest.productName,
+ version: manifest.version,
+ targets,
+ checksumUrl: new URL('SHA256SUMS', baseUrl).href,
+ fallbackUrl: 'https://github.com/mesalogo/goodbuddy/releases/latest'
+ }
+}
+
+function main(argv = process.argv.slice(2)) {
+ const options = parseArguments(argv)
+ const manifest = JSON.parse(readFileSync(options.manifest, 'utf8'))
+ const siteRelease = createSiteRelease(manifest, options.baseUrl)
+ mkdirSync(dirname(options.output), { recursive: true })
+ writeFileSync(
+ options.output,
+ `${JSON.stringify(siteRelease, null, 2)}\n`,
+ 'utf8'
+ )
+ console.log(`官网发布索引已生成:${options.output}`)
+}
+
+module.exports = {
+ createSiteRelease,
+ parseArguments,
+ validateBaseUrl
+}
+
+if (require.main === module) {
+ try {
+ main()
+ } catch (error) {
+ console.error(error)
+ process.exitCode = 1
+ }
+}
diff --git a/build/verify-site-release.cjs b/build/verify-site-release.cjs
new file mode 100644
index 0000000..78925ea
--- /dev/null
+++ b/build/verify-site-release.cjs
@@ -0,0 +1,63 @@
+const { readFileSync } = require('node:fs')
+const { resolve } = require('node:path')
+
+function parseArguments(argv) {
+ if (argv.length !== 2 || argv[0] !== '--manifest' || !argv[1]) {
+ throw new Error('必须指定 --manifest')
+ }
+ return { manifest: resolve(argv[1]) }
+}
+
+async function verifySiteRelease(manifest, request = fetch) {
+ const files = Object.values(manifest?.targets ?? {}).flatMap(
+ (target) => Object.values(target?.files ?? {})
+ )
+ if (files.length !== 12) {
+ throw new Error(`官网发布文件数量错误:${files.length}`)
+ }
+ const urls = new Set()
+ for (const file of files) {
+ if (
+ typeof file?.url !== 'string' ||
+ !Number.isSafeInteger(file.size) ||
+ file.size < 1 ||
+ urls.has(file.url)
+ ) {
+ throw new Error('官网发布文件元数据无效')
+ }
+ urls.add(file.url)
+ const response = await request(file.url, {
+ method: 'HEAD',
+ redirect: 'error'
+ })
+ if (!response.ok) {
+ throw new Error(`OSS 文件不可访问:${file.url}(${response.status})`)
+ }
+ const contentLength = Number(response.headers.get('content-length'))
+ if (contentLength !== file.size) {
+ throw new Error(
+ `OSS 文件大小不匹配:${file.url},期望 ${file.size},实际 ${contentLength}`
+ )
+ }
+ }
+ return files.length
+}
+
+async function main(argv = process.argv.slice(2)) {
+ const options = parseArguments(argv)
+ const manifest = JSON.parse(readFileSync(options.manifest, 'utf8'))
+ const count = await verifySiteRelease(manifest)
+ console.log(`OSS 发布文件验证通过:${count} 个`)
+}
+
+module.exports = {
+ parseArguments,
+ verifySiteRelease
+}
+
+if (require.main === module) {
+ main().catch((error) => {
+ console.error(error)
+ process.exitCode = 1
+ })
+}
diff --git a/sites/README.md b/sites/README.md
index 62029ab..4e0a0ed 100644
--- a/sites/README.md
+++ b/sites/README.md
@@ -39,25 +39,27 @@ node sites/scripts/validate.mjs
node --check sites/app.js
```
-校验脚本会检查必需文件、页内链接、本地资源、关键产品文案、主题与响应式规则,以及下载入口是否始终指向官方最新 Release。
+校验脚本会检查必需文件、页内链接、本地资源、关键产品文案、主题与响应式
+规则,以及下载选择器是否从受信任的正式发布索引加载并保留 GitHub
+Release 回退入口。
## 下载入口
-官网正文不展示具体版本号,三个系统下载按钮直接指向 GitHub 最新正式
+官网正文不写死版本号,页面启动后读取最新正式发布索引。
+Windows、macOS 和 Linux 下载卡片分别提供处理器架构与安装包类型选择器,
+选择后直接下载经过发布校验的不可变版本对象。发布索引请求失败、
+格式无效或返回非受信任的官方下载地址时,按钮继续指向 GitHub 最新正式
Release:
```text
https://github.com/mesalogo/goodbuddy/releases/latest
```
-新版本发布后 GitHub 会自动更新该地址的目标,官网无需同步修改版本号
-或安装资产名称。用户在 Release 页面按系统与架构选择文件并核对
-SHA-256 清单。
-
## 文件
- `index.html`:页面结构与简体中文内容
- `styles.css`:语义令牌、浅深主题、焦点与响应式布局
- `app.js`:主题、移动导航和当前章节
- `assets/goodbuddy-light.png`、`assets/goodbuddy-dark.png`:由 `npm run icons` 与桌面应用同步生成的官方品牌图标
+- `assets/linux-plain.svg`:Devicon v2.17.0 提供的黑白 Linux 图标,许可见 `assets/devicon-LICENSE`
- `scripts/validate.mjs`:无依赖静态检查
diff --git a/sites/app.js b/sites/app.js
index 9a08416..7f6184e 100644
--- a/sites/app.js
+++ b/sites/app.js
@@ -12,6 +12,137 @@
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)");
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const releaseManifestUrl =
+ "https://goodbuddy.oss-cn-hangzhou.aliyuncs.com/releases/latest.json";
+ const releaseFallbackUrl =
+ "https://github.com/mesalogo/goodbuddy/releases/latest";
+ const releaseStatus = document.querySelector("[data-release-status]");
+ const downloadCards = [
+ ...document.querySelectorAll("[data-download-card]"),
+ ];
+ const platformNames = {
+ windows: "Windows",
+ macos: "macOS",
+ linux: "Linux",
+ };
+ const formatNames = {
+ nsis: "安装版",
+ portable: "便携版",
+ dmg: "DMG",
+ zip: "ZIP",
+ AppImage: "AppImage",
+ deb: "DEB",
+ };
+
+ const formatFileSize = (bytes) => {
+ const megabytes = bytes / (1024 * 1024);
+ return `${megabytes >= 100 ? megabytes.toFixed(0) : megabytes.toFixed(1)} MB`;
+ };
+
+ const isTrustedReleaseUrl = (value) => {
+ try {
+ const url = new URL(value);
+ return (
+ url.protocol === "https:" &&
+ url.hostname === "goodbuddy.oss-cn-hangzhou.aliyuncs.com" &&
+ url.pathname.startsWith("/releases/")
+ );
+ } catch {
+ return false;
+ }
+ };
+
+ const configureDownloads = (release) => {
+ if (
+ release?.formatVersion !== 1 ||
+ release?.productName !== "GoodBuddy" ||
+ typeof release?.version !== "string" ||
+ !release?.targets
+ ) {
+ throw new Error("发布索引格式无效");
+ }
+
+ const updateCard = (card) => {
+ const platform = card.dataset.downloadCard;
+ const archSelect = card.querySelector("[data-download-arch]");
+ const formatSelect = card.querySelector("[data-download-format]");
+ const link = card.closest(".download-card")?.querySelector("[data-release-link]");
+ const meta = card.closest(".download-card")?.querySelector("[data-download-meta]");
+ if (
+ !platform ||
+ !(archSelect instanceof HTMLSelectElement) ||
+ !(formatSelect instanceof HTMLSelectElement) ||
+ !(link instanceof HTMLAnchorElement) ||
+ !(meta instanceof HTMLElement)
+ ) {
+ return;
+ }
+
+ const target = release.targets[`${platform}-${archSelect.value}`];
+ const file = target?.files?.[formatSelect.value];
+ if (
+ !file ||
+ typeof file.name !== "string" ||
+ !Number.isSafeInteger(file.size) ||
+ file.size < 1 ||
+ !isTrustedReleaseUrl(file.url)
+ ) {
+ link.href = releaseFallbackUrl;
+ link.textContent =
+ `前往 GitHub 下载 ${platformNames[platform] ?? platform} →`;
+ meta.textContent = "当前选项暂不可用,请在 GitHub Release 中选择文件。";
+ return;
+ }
+
+ link.href = file.url;
+ const platformName = platformNames[platform] ?? platform;
+ const archName =
+ platform === "macos" && archSelect.value === "arm64"
+ ? "Apple 芯片"
+ : archSelect.value === "arm64"
+ ? "ARM64"
+ : "x64";
+ const formatName = formatNames[formatSelect.value] ?? formatSelect.value;
+ link.textContent = `下载 ${platformName} ${archName} ${formatName} →`;
+ meta.textContent =
+ `GoodBuddy ${release.version} · ${formatFileSize(file.size)} · ` +
+ `${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`;
+ };
+
+ for (const card of downloadCards) {
+ const selects = card.querySelectorAll("select");
+ for (const select of selects) {
+ select.addEventListener("change", () => updateCard(card));
+ }
+ updateCard(card);
+ }
+
+ if (releaseStatus instanceof HTMLElement) {
+ releaseStatus.textContent =
+ `官方下载源已就绪:GoodBuddy ${release.version}。` +
+ "请选择处理器和安装包类型。";
+ releaseStatus.classList.add("is-ready");
+ }
+ };
+
+ const loadRelease = async () => {
+ try {
+ const response = await fetch(releaseManifestUrl, {
+ cache: "no-store",
+ credentials: "omit",
+ });
+ if (!response.ok) {
+ throw new Error(`发布索引请求失败:${response.status}`);
+ }
+ configureDownloads(await response.json());
+ } catch {
+ if (releaseStatus instanceof HTMLElement) {
+ releaseStatus.textContent =
+ "官方下载源暂不可用,下载按钮已切换到 GitHub Release。";
+ releaseStatus.classList.add("is-fallback");
+ }
+ }
+ };
const getSavedTheme = () => {
try {
@@ -51,6 +182,7 @@
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
setHeaderState();
+ void loadRelease();
themeToggle?.addEventListener("click", () => {
applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true);
diff --git a/sites/assets/devicon-LICENSE b/sites/assets/devicon-LICENSE
new file mode 100644
index 0000000..fcc9b66
--- /dev/null
+++ b/sites/assets/devicon-LICENSE
@@ -0,0 +1,20 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 konpa
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/sites/assets/linux-plain.svg b/sites/assets/linux-plain.svg
new file mode 100644
index 0000000..26ced8f
--- /dev/null
+++ b/sites/assets/linux-plain.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/sites/index.html b/sites/index.html
index 88876fb..1e7b341 100644
--- a/sites/index.html
+++ b/sites/index.html
@@ -229,14 +229,33 @@
x64 / arm64 · NSIS / 便携版
x64 / arm64 · 安装版 / 便携版
x64 / arm64 · DMG / ZIP
Apple 芯片 / Intel · DMG / ZIP
x64 / arm64 · AppImage / DEB