feat: add trusted mirror update source
Version checks and downloads previously depended on GitHub Release. About & Updates now offers GitHub or a validated mirror source, keeps the selector beneath the startup-check switch, and disables it when startup checks are off. The website resolves platform downloads from a bounded OSS release index with a GitHub fallback. Tagged releases publish and verify immutable OSS assets through OIDC before switching the latest-version index; deployment requires the configured Alibaba Cloud environment variables and role. Release note: “关于与更新”新增 GitHub 与镜像节点选择,启动检查、手动检查和下载页使用同一可信来源;官网下载也可按系统、架构和安装包类型直接选择。
This commit is contained in:
@@ -131,14 +131,17 @@ jobs:
|
|||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Publish GitHub Release
|
name: Publish GitHub and OSS release
|
||||||
if: github.event_name == 'push' && github.ref_type == 'tag'
|
if: github.event_name == 'push' && github.ref_type == 'tag'
|
||||||
needs: package
|
needs: package
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 20
|
timeout-minutes: 35
|
||||||
|
environment:
|
||||||
|
name: aliyun-oss-release
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
actions: read
|
actions: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
@@ -181,7 +184,102 @@ jobs:
|
|||||||
- name: Verify and aggregate release assets
|
- name: Verify and aggregate release assets
|
||||||
run: node build/aggregate-release.cjs --input dist/release-downloads --output dist/release-upload
|
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
|
shell: bash
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
@@ -196,3 +294,18 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
gh release upload "$tag" dist/release-upload/* --clobber
|
gh release upload "$tag" dist/release-upload/* --clobber
|
||||||
gh release edit "$tag" --draft=false --latest
|
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
|
||||||
|
|||||||
@@ -169,8 +169,9 @@ ZIP,以及 Linux 的 AppImage 与 DEB。Windows portable ZIP 解压后可直
|
|||||||
推送 `v${package.version}` 标签时,工作流运行验证和六平台打包。只有在
|
推送 `v${package.version}` 标签时,工作流运行验证和六平台打包。只有在
|
||||||
全部目标成功后,才会严格校验并聚合所有平台产物,生成按平台重命名的
|
全部目标成功后,才会严格校验并聚合所有平台产物,生成按平台重命名的
|
||||||
manifests、总 `release-manifest.json` 和 `SHA256SUMS`。随后工作流创建或
|
manifests、总 `release-manifest.json` 和 `SHA256SUMS`。随后工作流创建或
|
||||||
更新 draft GitHub Release,上传全部资产成功后才发布。重跑会保留人工
|
更新 draft GitHub Release,上传全部资产成功后才发布,并在全部下载资产
|
||||||
编辑的 Release notes 和未知附件。
|
验证通过后切换官网最新版本索引。任一步失败都不会切换官网最新版本。
|
||||||
|
重跑会保留人工编辑的 Release notes 和未知附件。
|
||||||
|
|
||||||
中英文发布说明统一维护在 `resources/release-notes.json`。新版本按“本次
|
中英文发布说明统一维护在 `resources/release-notes.json`。新版本按“本次
|
||||||
亮点 / Highlights”“功能更新 / Features”“问题修复 / Bug Fixes”“使用前
|
亮点 / Highlights”“功能更新 / Features”“问题修复 / Bug Fixes”“使用前
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@
|
|||||||
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
|
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
|
||||||
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、本地目录导入、切换和删除。
|
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、本地目录导入、切换和删除。
|
||||||
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
|
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
|
||||||
- [x] **版本检查**:仅检查固定官方 Release 和当前平台清单,不自动下载或安装。
|
- [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。
|
||||||
- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。
|
- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。
|
||||||
|
|
||||||
### 开源、构建与发布
|
### 开源、构建与发布
|
||||||
|
|||||||
@@ -534,6 +534,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
|||||||
- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。
|
- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。
|
||||||
- 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。
|
- 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。
|
||||||
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
|
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
|
||||||
|
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。
|
||||||
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
|
- 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 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 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。
|
- “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
+8
-6
@@ -39,25 +39,27 @@ node sites/scripts/validate.mjs
|
|||||||
node --check sites/app.js
|
node --check sites/app.js
|
||||||
```
|
```
|
||||||
|
|
||||||
校验脚本会检查必需文件、页内链接、本地资源、关键产品文案、主题与响应式规则,以及下载入口是否始终指向官方最新 Release。
|
校验脚本会检查必需文件、页内链接、本地资源、关键产品文案、主题与响应式
|
||||||
|
规则,以及下载选择器是否从受信任的正式发布索引加载并保留 GitHub
|
||||||
|
Release 回退入口。
|
||||||
|
|
||||||
## 下载入口
|
## 下载入口
|
||||||
|
|
||||||
官网正文不展示具体版本号,三个系统下载按钮直接指向 GitHub 最新正式
|
官网正文不写死版本号,页面启动后读取最新正式发布索引。
|
||||||
|
Windows、macOS 和 Linux 下载卡片分别提供处理器架构与安装包类型选择器,
|
||||||
|
选择后直接下载经过发布校验的不可变版本对象。发布索引请求失败、
|
||||||
|
格式无效或返回非受信任的官方下载地址时,按钮继续指向 GitHub 最新正式
|
||||||
Release:
|
Release:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://github.com/mesalogo/goodbuddy/releases/latest
|
https://github.com/mesalogo/goodbuddy/releases/latest
|
||||||
```
|
```
|
||||||
|
|
||||||
新版本发布后 GitHub 会自动更新该地址的目标,官网无需同步修改版本号
|
|
||||||
或安装资产名称。用户在 Release 页面按系统与架构选择文件并核对
|
|
||||||
SHA-256 清单。
|
|
||||||
|
|
||||||
## 文件
|
## 文件
|
||||||
|
|
||||||
- `index.html`:页面结构与简体中文内容
|
- `index.html`:页面结构与简体中文内容
|
||||||
- `styles.css`:语义令牌、浅深主题、焦点与响应式布局
|
- `styles.css`:语义令牌、浅深主题、焦点与响应式布局
|
||||||
- `app.js`:主题、移动导航和当前章节
|
- `app.js`:主题、移动导航和当前章节
|
||||||
- `assets/goodbuddy-light.png`、`assets/goodbuddy-dark.png`:由 `npm run icons` 与桌面应用同步生成的官方品牌图标
|
- `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`:无依赖静态检查
|
- `scripts/validate.mjs`:无依赖静态检查
|
||||||
|
|||||||
+132
@@ -12,6 +12,137 @@
|
|||||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)");
|
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)");
|
||||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
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 = () => {
|
const getSavedTheme = () => {
|
||||||
try {
|
try {
|
||||||
@@ -51,6 +182,7 @@
|
|||||||
|
|
||||||
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
|
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
|
||||||
setHeaderState();
|
setHeaderState();
|
||||||
|
void loadRelease();
|
||||||
|
|
||||||
themeToggle?.addEventListener("click", () => {
|
themeToggle?.addEventListener("click", () => {
|
||||||
applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true);
|
applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true);
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" clip-rule="evenodd" d="M113.823 104.595c-1.795-1.478-3.629-2.921-5.308-4.525-1.87-1.785-3.045-3.944-2.789-6.678.147-1.573-.216-2.926-2.113-3.452.446-1.154.864-1.928 1.033-2.753.188-.92.178-1.887.204-2.834.264-9.96-3.334-18.691-8.663-26.835-2.454-3.748-5.017-7.429-7.633-11.066-4.092-5.688-5.559-12.078-5.633-18.981a47.564 47.564 0 00-1.081-9.475C80.527 11.956 77.291 7.233 71.422 4.7c-4.497-1.942-9.152-2.327-13.901-1.084-6.901 1.805-11.074 6.934-10.996 14.088.074 6.885.417 13.779.922 20.648.288 3.893-.312 7.252-2.895 10.34-2.484 2.969-4.706 6.172-6.858 9.397-1.229 1.844-2.317 3.853-3.077 5.931-2.07 5.663-3.973 11.373-7.276 16.5-1.224 1.9-1.363 4.026-.494 6.199.225.563.363 1.429.089 1.882-2.354 3.907-5.011 7.345-10.066 8.095-3.976.591-4.172 1.314-4.051 5.413.1 3.337.061 6.705-.28 10.021-.363 3.555.008 4.521 3.442 5.373 7.924 1.968 15.913 3.647 23.492 6.854 3.227 1.365 6.465.891 9.064-1.763 2.713-2.771 6.141-3.855 9.844-3.859 6.285-.005 12.572.298 18.86.369 1.702.02 2.679.653 3.364 2.199.84 1.893 2.26 3.284 4.445 3.526 4.193.462 8.013-.16 11.19-3.359 3.918-3.948 8.436-7.066 13.615-9.227 1.482-.619 2.878-1.592 4.103-2.648 2.231-1.922 2.113-3.146-.135-5zM62.426 24.12c.758-2.601 2.537-4.289 5.243-4.801 2.276-.43 4.203.688 5.639 3.246 1.546 2.758 2.054 5.64.734 8.658-1.083 2.474-1.591 2.707-4.123 1.868-.474-.157-.937-.343-1.777-.652.708-.594 1.154-1.035 1.664-1.382 1.134-.772 1.452-1.858 1.346-3.148-.139-1.694-1.471-3.194-2.837-3.175-1.225.017-2.262 1.167-2.4 2.915-.086 1.089.095 2.199.173 3.589-3.446-1.023-4.711-3.525-3.662-7.118zm-12.75-2.251c1.274-1.928 3.197-2.314 5.101-1.024 2.029 1.376 3.547 5.256 2.763 7.576-.285.844-1.127 1.5-1.716 2.241l-.604-.374c-.23-1.253-.276-2.585-.757-3.733-.304-.728-1.257-1.184-1.919-1.762-.622.739-1.693 1.443-1.757 2.228-.088 1.084.477 2.28.969 3.331.311.661 1.001 1.145 1.713 1.916l-1.922 1.51c-3.018-2.7-3.915-8.82-1.871-11.909zM87.34 86.075c-.203 2.604-.5 2.713-3.118 3.098-1.859.272-2.359.756-2.453 2.964a101.744 101.744 0 00-.012 7.753c.061 1.77-.537 3.158-1.755 4.393-6.764 6.856-14.845 10.105-24.512 8.926-4.17-.509-6.896-3.047-9.097-6.639.98-.363 1.705-.607 2.412-.894 3.122-1.27 3.706-3.955 1.213-6.277-1.884-1.757-3.986-3.283-6.007-4.892-1.954-1.555-3.934-3.078-5.891-4.629-1.668-1.323-2.305-3.028-2.345-5.188-.094-5.182.972-10.03 3.138-14.747 1.932-4.209 3.429-8.617 5.239-12.885.935-2.202 1.906-4.455 3.278-6.388 1.319-1.854 2.134-3.669 1.988-5.94-.084-1.276-.016-2.562-.016-3.843l.707-.352c1.141.985 2.302 1.949 3.423 2.959 4.045 3.646 7.892 3.813 12.319.67 1.888-1.341 3.93-2.47 5.927-3.652.497-.294 1.092-.423 1.934-.738 2.151 5.066 4.262 10.033 6.375 15 1.072 2.524 1.932 5.167 3.264 7.547 2.671 4.775 4.092 9.813 4.07 15.272-.012 2.83.137 5.67-.081 8.482z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
+66
-9
@@ -229,14 +229,33 @@
|
|||||||
<path d="m3 5 8-1v8H3V5Zm10-1.3L21 3v9h-8V3.7ZM3 14h8v8l-8-1v-7Zm10 0h8v9l-8-1v-8Z" />
|
<path d="m3 5 8-1v8H3V5Zm10-1.3L21 3v9h-8V3.7ZM3 14h8v8l-8-1v-7Zm10 0h8v9l-8-1v-8Z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div><h3>Windows</h3><p>x64 / arm64 · NSIS / 便携版</p></div>
|
<div><h3>Windows</h3><p>x64 / arm64 · 安装版 / 便携版</p></div>
|
||||||
|
<div class="download-options" data-download-card="windows">
|
||||||
|
<label>
|
||||||
|
<span>处理器</span>
|
||||||
|
<select data-download-arch aria-label="Windows 处理器架构">
|
||||||
|
<option value="x64">x64(Intel / AMD)</option>
|
||||||
|
<option value="arm64">ARM64</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>类型</span>
|
||||||
|
<select data-download-format aria-label="Windows 安装包类型">
|
||||||
|
<option value="nsis">安装版(推荐)</option>
|
||||||
|
<option value="portable">便携版 ZIP</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<a
|
<a
|
||||||
class="button button--download"
|
class="button button--download"
|
||||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
data-release-link
|
data-release-link
|
||||||
>下载 Windows 版<span class="sr-only">(在新窗口打开)</span></a>
|
>下载 Windows 版 →<span class="sr-only">(在新窗口打开)</span></a>
|
||||||
|
<p class="download-meta" data-download-meta aria-live="polite">
|
||||||
|
正在获取最新版本,暂时可前往 GitHub 下载。
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="download-card">
|
<article class="download-card">
|
||||||
<div class="platform-icon">
|
<div class="platform-icon">
|
||||||
@@ -244,32 +263,70 @@
|
|||||||
<path d="M16.8 12.7c0-2.7 2.2-4 2.3-4.1A5 5 0 0 0 15.2 6c-1.7-.2-3.2 1-4.1 1-.9 0-2.2-1-3.6-1-1.8 0-3.5 1.1-4.5 2.7-2 3.5-.5 8.7 1.4 11.5.9 1.4 2 2.8 3.5 2.7 1.4 0 1.9-.9 3.7-.9 1.7 0 2.2.9 3.7.9s2.5-1.4 3.4-2.7a10 10 0 0 0 1.6-3.3 4.6 4.6 0 0 1-3.5-4.2ZM14.1 4.3A4.7 4.7 0 0 0 15.2 1a4.8 4.8 0 0 0-3.1 1.6A4.4 4.4 0 0 0 11 5.8c1.2.1 2.3-.5 3.1-1.5Z" />
|
<path d="M16.8 12.7c0-2.7 2.2-4 2.3-4.1A5 5 0 0 0 15.2 6c-1.7-.2-3.2 1-4.1 1-.9 0-2.2-1-3.6-1-1.8 0-3.5 1.1-4.5 2.7-2 3.5-.5 8.7 1.4 11.5.9 1.4 2 2.8 3.5 2.7 1.4 0 1.9-.9 3.7-.9 1.7 0 2.2.9 3.7.9s2.5-1.4 3.4-2.7a10 10 0 0 0 1.6-3.3 4.6 4.6 0 0 1-3.5-4.2ZM14.1 4.3A4.7 4.7 0 0 0 15.2 1a4.8 4.8 0 0 0-3.1 1.6A4.4 4.4 0 0 0 11 5.8c1.2.1 2.3-.5 3.1-1.5Z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div><h3>macOS</h3><p>x64 / arm64 · DMG / ZIP</p></div>
|
<div><h3>macOS</h3><p>Apple 芯片 / Intel · DMG / ZIP</p></div>
|
||||||
|
<div class="download-options" data-download-card="macos">
|
||||||
|
<label>
|
||||||
|
<span>处理器</span>
|
||||||
|
<select data-download-arch aria-label="macOS 处理器架构">
|
||||||
|
<option value="arm64">Apple 芯片(推荐)</option>
|
||||||
|
<option value="x64">Intel</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>类型</span>
|
||||||
|
<select data-download-format aria-label="macOS 安装包类型">
|
||||||
|
<option value="dmg">DMG(推荐)</option>
|
||||||
|
<option value="zip">ZIP</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<a
|
<a
|
||||||
class="button button--download"
|
class="button button--download"
|
||||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
data-release-link
|
data-release-link
|
||||||
>下载 macOS 版<span class="sr-only">(在新窗口打开)</span></a>
|
>下载 macOS 版 →<span class="sr-only">(在新窗口打开)</span></a>
|
||||||
|
<p class="download-meta" data-download-meta aria-live="polite">
|
||||||
|
正在获取最新版本,暂时可前往 GitHub 下载。
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="download-card">
|
<article class="download-card">
|
||||||
<div class="platform-icon">
|
<div class="platform-icon">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<img src="./assets/linux-plain.svg" alt="" />
|
||||||
<path d="M12 3c-3 0-4.7 2.5-4.5 5.4-1.3 1.4-2 3.4-2 5.6 0 3.9 2.9 7 6.5 7s6.5-3.1 6.5-7c0-2.2-.7-4.2-2-5.6C16.7 5.5 15 3 12 3Z" />
|
|
||||||
<path d="M9.3 10.2h.1M14.6 10.2h.1M9.5 15c1.6 1.2 3.4 1.2 5 0M7 19l-2 2M17 19l2 2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div><h3>Linux</h3><p>x64 / arm64 · AppImage / DEB</p></div>
|
<div><h3>Linux</h3><p>x64 / arm64 · AppImage / DEB</p></div>
|
||||||
|
<div class="download-options" data-download-card="linux">
|
||||||
|
<label>
|
||||||
|
<span>处理器</span>
|
||||||
|
<select data-download-arch aria-label="Linux 处理器架构">
|
||||||
|
<option value="x64">x64(Intel / AMD)</option>
|
||||||
|
<option value="arm64">ARM64</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>类型</span>
|
||||||
|
<select data-download-format aria-label="Linux 安装包类型">
|
||||||
|
<option value="AppImage">AppImage(推荐)</option>
|
||||||
|
<option value="deb">DEB</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<a
|
<a
|
||||||
class="button button--download"
|
class="button button--download"
|
||||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
data-release-link
|
data-release-link
|
||||||
>下载 Linux 版<span class="sr-only">(在新窗口打开)</span></a>
|
>下载 Linux 版 →<span class="sr-only">(在新窗口打开)</span></a>
|
||||||
|
<p class="download-meta" data-download-meta aria-live="polite">
|
||||||
|
正在获取最新版本,暂时可前往 GitHub 下载。
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="download-release-status" data-release-status role="status">
|
||||||
|
正在连接官方下载源…
|
||||||
|
</div>
|
||||||
|
|
||||||
<aside class="domestic-support" aria-labelledby="domestic-support-title">
|
<aside class="domestic-support" aria-labelledby="domestic-support-title">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const requiredFiles = [
|
|||||||
"app.js",
|
"app.js",
|
||||||
"assets/goodbuddy-light.png",
|
"assets/goodbuddy-light.png",
|
||||||
"assets/goodbuddy-dark.png",
|
"assets/goodbuddy-dark.png",
|
||||||
|
"assets/linux-plain.svg",
|
||||||
|
"assets/devicon-LICENSE",
|
||||||
"README.md",
|
"README.md",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -132,6 +134,31 @@ for (const link of releaseLinks) {
|
|||||||
report(/target="_blank"/.test(link), `下载入口必须在新窗口打开:${link}`);
|
report(/target="_blank"/.test(link), `下载入口必须在新窗口打开:${link}`);
|
||||||
report(/rel="[^"]*noreferrer[^"]*"/.test(link), `下载入口缺少 noreferrer:${link}`);
|
report(/rel="[^"]*noreferrer[^"]*"/.test(link), `下载入口缺少 noreferrer:${link}`);
|
||||||
}
|
}
|
||||||
|
report(
|
||||||
|
(html.match(/data-download-card="(?:windows|macos|linux)"/g) ?? []).length === 3,
|
||||||
|
"下载区必须包含 Windows、macOS 和 Linux 选择器",
|
||||||
|
);
|
||||||
|
report(
|
||||||
|
(html.match(/data-download-arch/g) ?? []).length === 3,
|
||||||
|
"每个平台必须提供处理器架构选择器",
|
||||||
|
);
|
||||||
|
report(
|
||||||
|
(html.match(/data-download-format/g) ?? []).length === 3,
|
||||||
|
"每个平台必须提供安装包类型选择器",
|
||||||
|
);
|
||||||
|
report(/data-release-status/.test(html), "下载区缺少发布源状态");
|
||||||
|
report(
|
||||||
|
appJs.includes(
|
||||||
|
"https://goodbuddy.oss-cn-hangzhou.aliyuncs.com/releases/latest.json",
|
||||||
|
),
|
||||||
|
"官网必须从 GoodBuddy OSS 加载最新发布索引",
|
||||||
|
);
|
||||||
|
report(
|
||||||
|
appJs.includes("https://github.com/mesalogo/goodbuddy/releases/latest"),
|
||||||
|
"官网必须保留 GitHub Release 回退地址",
|
||||||
|
);
|
||||||
|
report(/credentials:\s*"omit"/.test(appJs), "OSS 发布索引请求不得携带凭据");
|
||||||
|
report(/isTrustedReleaseUrl/.test(appJs), "OSS 下载链接缺少来源校验");
|
||||||
|
|
||||||
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
||||||
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index);
|
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index);
|
||||||
@@ -173,7 +200,7 @@ for (const link of externalBlankLinks) {
|
|||||||
|
|
||||||
report(
|
report(
|
||||||
!/<a\b[^>]*href="[^"]+\.(?:exe|dmg|zip|AppImage|deb)(?:[?#][^"]*)?"/i.test(html),
|
!/<a\b[^>]*href="[^"]+\.(?:exe|dmg|zip|AppImage|deb)(?:[?#][^"]*)?"/i.test(html),
|
||||||
"具体安装资产链接应由 Release 页面统一提供",
|
"具体安装资产链接应由 OSS 发布索引动态提供",
|
||||||
);
|
);
|
||||||
report(
|
report(
|
||||||
!/(?:react|vue|angular|bootstrap|tailwind)(?:\.min)?\.(?:js|css)/i.test(html),
|
!/(?:react|vue|angular|bootstrap|tailwind)(?:\.min)?\.(?:js|css)/i.test(html),
|
||||||
|
|||||||
+96
-8
@@ -1258,6 +1258,39 @@ p {
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-options {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-options label {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-caption);
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-options select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
border: 1px solid var(--border-control);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-options select:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--accent) 28%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.platform-icon {
|
.platform-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
@@ -1278,14 +1311,66 @@ p {
|
|||||||
stroke-width: 1.3;
|
stroke-width: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-card:nth-child(3) .platform-icon svg {
|
.platform-icon img {
|
||||||
fill: none;
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .platform-icon img {
|
||||||
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button--download {
|
.button--download {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
width: 100%;
|
width: fit-content;
|
||||||
|
min-height: 40px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
justify-self: start;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: transparent;
|
||||||
|
text-underline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--download:not(.is-disabled):hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-meta {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 18px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-release-status {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-release-status.is-ready {
|
||||||
|
border-color: color-mix(in srgb, var(--success) 34%, var(--border-default));
|
||||||
|
background: var(--success-subtle);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-release-status.is-fallback {
|
||||||
|
border-color: color-mix(in srgb, var(--warning) 34%, var(--border-default));
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.domestic-support {
|
.domestic-support {
|
||||||
@@ -1601,7 +1686,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.download-card {
|
.download-card {
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1610,9 +1695,8 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.button--download {
|
.button--download {
|
||||||
grid-column: auto;
|
grid-column: 1 / -1;
|
||||||
width: auto;
|
width: fit-content;
|
||||||
margin-top: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-grid {
|
.assistant-grid {
|
||||||
@@ -1802,9 +1886,13 @@ p {
|
|||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-options {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.button--download {
|
.button--download {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
width: 100%;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.domestic-support {
|
.domestic-support {
|
||||||
|
|||||||
@@ -62,19 +62,22 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
})
|
})
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
await expect(store.get()).resolves.toEqual({
|
await expect(store.get()).resolves.toEqual({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||||
version: 5,
|
version: 6,
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined',
|
magicNoteCommentFormat: 'combined',
|
||||||
@@ -98,6 +101,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
new ApplicationSettingsStore(filePath).get()
|
new ApplicationSettingsStore(filePath).get()
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
@@ -119,6 +123,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
|
|
||||||
await expect(store.get()).resolves.toEqual({
|
await expect(store.get()).resolves.toEqual({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
@@ -140,6 +145,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
|
|
||||||
await expect(store.get()).resolves.toEqual({
|
await expect(store.get()).resolves.toEqual({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
@@ -161,6 +167,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
|
|
||||||
await expect(store.get()).resolves.toEqual({
|
await expect(store.get()).resolves.toEqual({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'after-save-manual',
|
magicNoteCommentMode: 'after-save-manual',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
@@ -187,8 +194,9 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
|
new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
|
||||||
).resolves.toBe('0.8.18')
|
).resolves.toBe('0.8.18')
|
||||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||||
version: 5,
|
version: 6,
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'after-save-manual',
|
magicNoteCommentMode: 'after-save-manual',
|
||||||
magicNoteCommentFormat: 'narrative',
|
magicNoteCommentFormat: 'narrative',
|
||||||
@@ -196,6 +204,33 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('migrates version 5 settings to the default GitHub update source', async () => {
|
||||||
|
const { filePath, store } = await createStore()
|
||||||
|
await writeFile(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 5,
|
||||||
|
checkUpdatesOnStartup: false,
|
||||||
|
magicNotesEnabled: true,
|
||||||
|
magicNoteCommentMode: 'after-save-auto',
|
||||||
|
magicNoteCommentFormat: 'structured',
|
||||||
|
lastSeenReleaseNotesVersion: '0.8.18'
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(store.get()).resolves.toEqual({
|
||||||
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
|
magicNotesEnabled: true,
|
||||||
|
magicNoteCommentMode: 'after-save-auto',
|
||||||
|
magicNoteCommentFormat: 'structured'
|
||||||
|
})
|
||||||
|
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe(
|
||||||
|
'0.8.18'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('strictly rejects incomplete full settings', () => {
|
it('strictly rejects incomplete full settings', () => {
|
||||||
for (const input of [
|
for (const input of [
|
||||||
{},
|
{},
|
||||||
@@ -238,12 +273,28 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
store.update({ checkUpdatesOnStartup: false })
|
store.update({ checkUpdatesOnStartup: false })
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('persists the selected mirror update source', async () => {
|
||||||
|
const { filePath, store } = await createStore()
|
||||||
|
|
||||||
|
await expect(store.update({ updateSource: 'mirror' })).resolves.toEqual({
|
||||||
|
...defaultApplicationSettings,
|
||||||
|
updateSource: 'mirror'
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
new ApplicationSettingsStore(filePath).get()
|
||||||
|
).resolves.toEqual({
|
||||||
|
...defaultApplicationSettings,
|
||||||
|
updateSource: 'mirror'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
'{not-json',
|
'{not-json',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -335,13 +386,15 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
|
|
||||||
await expect(store.get()).resolves.toEqual({
|
await expect(store.get()).resolves.toEqual({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||||
version: 5,
|
version: 6,
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined',
|
magicNoteCommentFormat: 'combined',
|
||||||
@@ -365,6 +418,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
})
|
})
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export {
|
|||||||
} from '../shared/application-settings-contracts'
|
} from '../shared/application-settings-contracts'
|
||||||
export type { ApplicationSettings } from '../shared/application-settings-contracts'
|
export type { ApplicationSettings } from '../shared/application-settings-contracts'
|
||||||
|
|
||||||
const CURRENT_SETTINGS_VERSION = 5
|
const CURRENT_SETTINGS_VERSION = 6
|
||||||
|
|
||||||
const legacyStoredApplicationSettingsSchema = z
|
const legacyStoredApplicationSettingsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -47,11 +47,20 @@ const versionThreeStoredApplicationSettingsSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
|
const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
|
||||||
|
.omit({ updateSource: true })
|
||||||
.extend({
|
.extend({
|
||||||
version: z.literal(4)
|
version: z.literal(4)
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
|
const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
|
||||||
|
.omit({ updateSource: true })
|
||||||
|
.extend({
|
||||||
|
version: z.literal(5),
|
||||||
|
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
const storedApplicationSettingsSchema = applicationSettingsSchema
|
const storedApplicationSettingsSchema = applicationSettingsSchema
|
||||||
.extend({
|
.extend({
|
||||||
version: z.literal(CURRENT_SETTINGS_VERSION),
|
version: z.literal(CURRENT_SETTINGS_VERSION),
|
||||||
@@ -65,6 +74,7 @@ type StoredApplicationSettings = z.infer<
|
|||||||
|
|
||||||
export const defaultApplicationSettings: ApplicationSettings = {
|
export const defaultApplicationSettings: ApplicationSettings = {
|
||||||
checkUpdatesOnStartup: true,
|
checkUpdatesOnStartup: true,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
@@ -121,12 +131,23 @@ export class ApplicationSettingsStore {
|
|||||||
)
|
)
|
||||||
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
const versionFiveResult =
|
||||||
|
versionFiveStoredApplicationSettingsSchema.safeParse(parsed)
|
||||||
|
if (versionFiveResult.success) {
|
||||||
|
this.settings = {
|
||||||
|
...versionFiveResult.data,
|
||||||
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
|
updateSource: 'github'
|
||||||
|
}
|
||||||
|
return this.settings
|
||||||
|
}
|
||||||
const versionFourResult =
|
const versionFourResult =
|
||||||
versionFourStoredApplicationSettingsSchema.safeParse(parsed)
|
versionFourStoredApplicationSettingsSchema.safeParse(parsed)
|
||||||
if (versionFourResult.success) {
|
if (versionFourResult.success) {
|
||||||
this.settings = {
|
this.settings = {
|
||||||
...versionFourResult.data,
|
...versionFourResult.data,
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
|
updateSource: 'github',
|
||||||
lastSeenReleaseNotesVersion: null
|
lastSeenReleaseNotesVersion: null
|
||||||
}
|
}
|
||||||
return this.settings
|
return this.settings
|
||||||
@@ -137,6 +158,7 @@ export class ApplicationSettingsStore {
|
|||||||
this.settings = {
|
this.settings = {
|
||||||
...versionThreeResult.data,
|
...versionThreeResult.data,
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
|
updateSource: 'github',
|
||||||
magicNoteCommentFormat: 'combined',
|
magicNoteCommentFormat: 'combined',
|
||||||
lastSeenReleaseNotesVersion: null
|
lastSeenReleaseNotesVersion: null
|
||||||
}
|
}
|
||||||
@@ -148,6 +170,7 @@ export class ApplicationSettingsStore {
|
|||||||
this.settings = {
|
this.settings = {
|
||||||
...versionTwoResult.data,
|
...versionTwoResult.data,
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
|
updateSource: 'github',
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined',
|
magicNoteCommentFormat: 'combined',
|
||||||
lastSeenReleaseNotesVersion: null
|
lastSeenReleaseNotesVersion: null
|
||||||
@@ -161,6 +184,7 @@ export class ApplicationSettingsStore {
|
|||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
checkUpdatesOnStartup:
|
checkUpdatesOnStartup:
|
||||||
legacyResult.data.checkUpdatesOnStartup,
|
legacyResult.data.checkUpdatesOnStartup,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined',
|
magicNoteCommentFormat: 'combined',
|
||||||
@@ -200,6 +224,7 @@ export class ApplicationSettingsStore {
|
|||||||
const stored = await this.loadStored()
|
const stored = await this.loadStored()
|
||||||
return {
|
return {
|
||||||
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
|
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
|
||||||
|
updateSource: stored.updateSource,
|
||||||
magicNotesEnabled: stored.magicNotesEnabled,
|
magicNotesEnabled: stored.magicNotesEnabled,
|
||||||
magicNoteCommentMode: stored.magicNoteCommentMode,
|
magicNoteCommentMode: stored.magicNoteCommentMode,
|
||||||
magicNoteCommentFormat: stored.magicNoteCommentFormat,
|
magicNoteCommentFormat: stored.magicNoteCommentFormat,
|
||||||
@@ -231,6 +256,7 @@ export class ApplicationSettingsStore {
|
|||||||
this.warnings = []
|
this.warnings = []
|
||||||
return {
|
return {
|
||||||
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
||||||
|
updateSource: next.updateSource,
|
||||||
magicNotesEnabled: next.magicNotesEnabled,
|
magicNotesEnabled: next.magicNotesEnabled,
|
||||||
magicNoteCommentMode: next.magicNoteCommentMode,
|
magicNoteCommentMode: next.magicNoteCommentMode,
|
||||||
magicNoteCommentFormat: next.magicNoteCommentFormat
|
magicNoteCommentFormat: next.magicNoteCommentFormat
|
||||||
|
|||||||
@@ -334,6 +334,102 @@ describe('registerIpcHandlers computer capabilities', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('registerIpcHandlers update source routing', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
electronMocks.handlers.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the persisted source for checks and the download page', async () => {
|
||||||
|
const webContents = {
|
||||||
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||||
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||||
|
isDestroyed: vi.fn(() => false),
|
||||||
|
send: vi.fn()
|
||||||
|
}
|
||||||
|
const window = {
|
||||||
|
webContents,
|
||||||
|
isDestroyed: vi.fn(() => false),
|
||||||
|
isMaximized: vi.fn(() => false),
|
||||||
|
on: vi.fn(),
|
||||||
|
removeListener: vi.fn()
|
||||||
|
}
|
||||||
|
const event = {
|
||||||
|
sender: webContents,
|
||||||
|
senderFrame: webContents.mainFrame
|
||||||
|
}
|
||||||
|
const result = {
|
||||||
|
updateAvailable: true,
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
latestVersion: '1.1.0',
|
||||||
|
releaseUrl: 'https://mesalogo.github.io/goodbuddy/#download',
|
||||||
|
target: {
|
||||||
|
platform: 'windows',
|
||||||
|
arch: 'x64',
|
||||||
|
formats: ['nsis', 'portable'],
|
||||||
|
files: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const getApplicationSettings = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ updateSource: 'mirror' })
|
||||||
|
const versionChecker = {
|
||||||
|
check: vi.fn(async () => result)
|
||||||
|
}
|
||||||
|
const dispose = registerIpcHandlers(
|
||||||
|
window as never,
|
||||||
|
{ capability: 'text' } as never,
|
||||||
|
'CommandOrControl+Shift+Space',
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ clear: vi.fn() } as never,
|
||||||
|
{} as never,
|
||||||
|
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||||
|
{ clear: vi.fn() } as never,
|
||||||
|
{} as never,
|
||||||
|
vi.fn(async () => undefined),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ get: getApplicationSettings } as never,
|
||||||
|
versionChecker as never
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
electronMocks.handlers.get(ipcChannels.versionCheck)?.(event)
|
||||||
|
).resolves.toEqual(result)
|
||||||
|
expect(versionChecker.check).toHaveBeenCalledWith('mirror')
|
||||||
|
expect(webContents.send).toHaveBeenCalledWith(
|
||||||
|
ipcChannels.versionCheckResult,
|
||||||
|
result
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
electronMocks.handlers.get(
|
||||||
|
ipcChannels.versionOpenReleasePage
|
||||||
|
)?.(event)
|
||||||
|
).resolves.toBeUndefined()
|
||||||
|
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
|
||||||
|
'https://mesalogo.github.io/goodbuddy/#download'
|
||||||
|
)
|
||||||
|
|
||||||
|
getApplicationSettings.mockResolvedValueOnce({
|
||||||
|
updateSource: 'github'
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
electronMocks.handlers.get(
|
||||||
|
ipcChannels.versionOpenReleasePage
|
||||||
|
)?.(event)
|
||||||
|
).resolves.toBeUndefined()
|
||||||
|
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
|
||||||
|
'https://github.com/mesalogo/goodbuddy/releases'
|
||||||
|
)
|
||||||
|
|
||||||
|
await dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('electron', () => ({
|
vi.mock('electron', () => ({
|
||||||
app: {
|
app: {
|
||||||
getName: vi.fn(() => 'GoodBuddy'),
|
getName: vi.fn(() => 'GoodBuddy'),
|
||||||
|
|||||||
+12
-6
@@ -239,7 +239,10 @@ import {
|
|||||||
SqliteChannelOutbox
|
SqliteChannelOutbox
|
||||||
} from './channels/sqlite-channel-state'
|
} from './channels/sqlite-channel-state'
|
||||||
import type { ApplicationSettingsStore } from './application-settings-store'
|
import type { ApplicationSettingsStore } from './application-settings-store'
|
||||||
import type { VersionChecker } from './version-checker'
|
import {
|
||||||
|
getUpdateDownloadPage,
|
||||||
|
type VersionChecker
|
||||||
|
} from './version-checker'
|
||||||
import type { SpeechModelManager } from './speech/speech-model-manager'
|
import type { SpeechModelManager } from './speech/speech-model-manager'
|
||||||
import type { SpeechTranscriptionService } from './speech/speech-transcription-service'
|
import type { SpeechTranscriptionService } from './speech/speech-transcription-service'
|
||||||
import { diagnoseEmbeddingProvider } from './knowledge/embedding-index-coordinator'
|
import { diagnoseEmbeddingProvider } from './knowledge/embedding-index-coordinator'
|
||||||
@@ -267,8 +270,6 @@ import {
|
|||||||
import { AgentEventBuffer } from './agent-event-buffer'
|
import { AgentEventBuffer } from './agent-event-buffer'
|
||||||
|
|
||||||
const requestIdSchema = z.string().uuid()
|
const requestIdSchema = z.string().uuid()
|
||||||
const GOODBUDDY_RELEASES_URL =
|
|
||||||
'https://github.com/mesalogo/goodbuddy/releases'
|
|
||||||
const runtimeConfigFileMetadata = {
|
const runtimeConfigFileMetadata = {
|
||||||
opencode: {
|
opencode: {
|
||||||
filterName: 'OpenCode 配置',
|
filterName: 'OpenCode 配置',
|
||||||
@@ -3692,10 +3693,11 @@ export function registerIpcHandlers(
|
|||||||
|
|
||||||
registerHandler(ipcChannels.versionCheck, async (event) => {
|
registerHandler(ipcChannels.versionCheck, async (event) => {
|
||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
if (!versionChecker) {
|
if (!versionChecker || !applicationSettingsStore) {
|
||||||
throw new Error('版本检查服务不可用')
|
throw new Error('版本检查服务不可用')
|
||||||
}
|
}
|
||||||
const result = await versionChecker.check()
|
const { updateSource } = await applicationSettingsStore.get()
|
||||||
|
const result = await versionChecker.check(updateSource)
|
||||||
if (!window.isDestroyed()) {
|
if (!window.isDestroyed()) {
|
||||||
window.webContents.send(ipcChannels.versionCheckResult, result)
|
window.webContents.send(ipcChannels.versionCheckResult, result)
|
||||||
}
|
}
|
||||||
@@ -3704,7 +3706,11 @@ export function registerIpcHandlers(
|
|||||||
|
|
||||||
registerHandler(ipcChannels.versionOpenReleasePage, async (event) => {
|
registerHandler(ipcChannels.versionOpenReleasePage, async (event) => {
|
||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
await shell.openExternal(GOODBUDDY_RELEASES_URL)
|
if (!applicationSettingsStore) {
|
||||||
|
throw new Error('应用设置服务不可用')
|
||||||
|
}
|
||||||
|
const { updateSource } = await applicationSettingsStore.get()
|
||||||
|
await shell.openExternal(getUpdateDownloadPage(updateSource))
|
||||||
})
|
})
|
||||||
|
|
||||||
registerHandler(ipcChannels.releaseNotesGetPending, (event) => {
|
registerHandler(ipcChannels.releaseNotesGetPending, (event) => {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
checkMirrorForUpdates,
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
compareStrictSemVer,
|
compareStrictSemVer,
|
||||||
GOODBUDDY_LATEST_RELEASE_API_URL
|
getUpdateDownloadPage,
|
||||||
|
GOODBUDDY_LATEST_RELEASE_API_URL,
|
||||||
|
GOODBUDDY_MIRROR_RELEASE_INDEX_URL,
|
||||||
|
VersionChecker
|
||||||
} from './version-checker'
|
} from './version-checker'
|
||||||
|
|
||||||
const latestVersion = '1.2.3'
|
const latestVersion = '1.2.3'
|
||||||
@@ -95,6 +99,93 @@ function successfulFetch(): ReturnType<typeof vi.fn<typeof fetch>> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MirrorTestFile = {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
sha256: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MirrorTestTarget = {
|
||||||
|
platform: 'windows' | 'macos' | 'linux'
|
||||||
|
arch: 'x64' | 'arm64'
|
||||||
|
files: Record<string, MirrorTestFile>
|
||||||
|
}
|
||||||
|
|
||||||
|
type MirrorTestIndex = {
|
||||||
|
formatVersion: 1
|
||||||
|
productName: 'GoodBuddy'
|
||||||
|
version: string
|
||||||
|
targets: Record<string, MirrorTestTarget>
|
||||||
|
checksumUrl: string
|
||||||
|
fallbackUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function mirrorFileName(
|
||||||
|
platform: MirrorTestTarget['platform'],
|
||||||
|
arch: MirrorTestTarget['arch'],
|
||||||
|
format: string
|
||||||
|
): string {
|
||||||
|
const suffixes: Record<string, string> = {
|
||||||
|
nsis: 'setup.exe',
|
||||||
|
portable: 'portable.zip',
|
||||||
|
dmg: 'installer.dmg',
|
||||||
|
zip: 'portable.zip',
|
||||||
|
AppImage: 'portable.AppImage',
|
||||||
|
deb: 'installer.deb'
|
||||||
|
}
|
||||||
|
return `GoodBuddy-${latestVersion}-${platform}-${arch}-${suffixes[format]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function mirrorIndexPayload(): MirrorTestIndex {
|
||||||
|
const definitions: Array<{
|
||||||
|
platform: MirrorTestTarget['platform']
|
||||||
|
arch: MirrorTestTarget['arch']
|
||||||
|
formats: string[]
|
||||||
|
}> = [
|
||||||
|
{ platform: 'windows', arch: 'x64', formats: ['nsis', 'portable'] },
|
||||||
|
{ platform: 'windows', arch: 'arm64', formats: ['nsis', 'portable'] },
|
||||||
|
{ platform: 'macos', arch: 'x64', formats: ['dmg', 'zip'] },
|
||||||
|
{ platform: 'macos', arch: 'arm64', formats: ['dmg', 'zip'] },
|
||||||
|
{ platform: 'linux', arch: 'x64', formats: ['AppImage', 'deb'] },
|
||||||
|
{ platform: 'linux', arch: 'arm64', formats: ['AppImage', 'deb'] }
|
||||||
|
]
|
||||||
|
const releaseBase =
|
||||||
|
`https://goodbuddy.oss-cn-hangzhou.aliyuncs.com/releases/` +
|
||||||
|
`v${latestVersion}/`
|
||||||
|
const targets: Record<string, MirrorTestTarget> = {}
|
||||||
|
for (const definition of definitions) {
|
||||||
|
const targetFiles: Record<string, MirrorTestFile> = {}
|
||||||
|
for (const [index, format] of definition.formats.entries()) {
|
||||||
|
const name = mirrorFileName(
|
||||||
|
definition.platform,
|
||||||
|
definition.arch,
|
||||||
|
format
|
||||||
|
)
|
||||||
|
targetFiles[format] = {
|
||||||
|
name,
|
||||||
|
size: 100 + index,
|
||||||
|
sha256: (index === 0 ? 'a' : 'b').repeat(64),
|
||||||
|
url: new URL(encodeURIComponent(name), releaseBase).href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targets[`${definition.platform}-${definition.arch}`] = {
|
||||||
|
platform: definition.platform,
|
||||||
|
arch: definition.arch,
|
||||||
|
files: targetFiles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
formatVersion: 1,
|
||||||
|
productName: 'GoodBuddy',
|
||||||
|
version: latestVersion,
|
||||||
|
targets,
|
||||||
|
checksumUrl: new URL('SHA256SUMS', releaseBase).href,
|
||||||
|
fallbackUrl:
|
||||||
|
'https://github.com/mesalogo/goodbuddy/releases/latest'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('compareStrictSemVer', () => {
|
describe('compareStrictSemVer', () => {
|
||||||
it('implements SemVer precedence without treating build metadata as newer', () => {
|
it('implements SemVer precedence without treating build metadata as newer', () => {
|
||||||
expect(compareStrictSemVer('1.0.0-alpha.2', '1.0.0-alpha.10')).toBe(-1)
|
expect(compareStrictSemVer('1.0.0-alpha.2', '1.0.0-alpha.10')).toBe(-1)
|
||||||
@@ -474,3 +565,111 @@ describe('checkForUpdates', () => {
|
|||||||
).rejects.toMatchObject({ name: 'AbortError' })
|
).rejects.toMatchObject({ name: 'AbortError' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('checkMirrorForUpdates', () => {
|
||||||
|
it('reads the fixed mirror index and returns the current platform files', async () => {
|
||||||
|
const payload = mirrorIndexPayload()
|
||||||
|
const transport = vi.fn<typeof fetch>(async () =>
|
||||||
|
jsonResponse(payload)
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
checkMirrorForUpdates({
|
||||||
|
fetch: transport,
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
platform: 'win32',
|
||||||
|
arch: 'x64'
|
||||||
|
})
|
||||||
|
).resolves.toEqual({
|
||||||
|
updateAvailable: true,
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
latestVersion,
|
||||||
|
releaseUrl: 'https://mesalogo.github.io/goodbuddy/#download',
|
||||||
|
target: {
|
||||||
|
platform: 'windows',
|
||||||
|
arch: 'x64',
|
||||||
|
formats: ['nsis', 'portable'],
|
||||||
|
files: Object.values(
|
||||||
|
payload.targets['windows-x64']!.files
|
||||||
|
).map((file) => ({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
sha256: file.sha256
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(transport).toHaveBeenCalledTimes(1)
|
||||||
|
expect(String(transport.mock.calls[0]?.[0])).toBe(
|
||||||
|
GOODBUDDY_MIRROR_RELEASE_INDEX_URL
|
||||||
|
)
|
||||||
|
expect(transport.mock.calls[0]?.[1]).toMatchObject({
|
||||||
|
method: 'GET',
|
||||||
|
redirect: 'manual',
|
||||||
|
credentials: 'omit',
|
||||||
|
cache: 'no-store'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects redirects, incomplete targets, and untrusted file URLs', async () => {
|
||||||
|
const redirecting = vi.fn<typeof fetch>(async () =>
|
||||||
|
new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { location: 'https://attacker.invalid/latest.json' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await expect(
|
||||||
|
checkMirrorForUpdates({
|
||||||
|
fetch: redirecting,
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
platform: 'win32',
|
||||||
|
arch: 'x64'
|
||||||
|
})
|
||||||
|
).rejects.toThrow('must not redirect')
|
||||||
|
|
||||||
|
const incomplete = mirrorIndexPayload()
|
||||||
|
delete incomplete.targets['linux-arm64']
|
||||||
|
await expect(
|
||||||
|
checkMirrorForUpdates({
|
||||||
|
fetch: vi.fn<typeof fetch>(async () => jsonResponse(incomplete)),
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
platform: 'win32',
|
||||||
|
arch: 'x64'
|
||||||
|
})
|
||||||
|
).rejects.toThrow('targets are incomplete')
|
||||||
|
|
||||||
|
const untrusted = mirrorIndexPayload()
|
||||||
|
untrusted.targets['windows-x64']!.files.nsis!.url =
|
||||||
|
'https://attacker.invalid/GoodBuddy.exe'
|
||||||
|
await expect(
|
||||||
|
checkMirrorForUpdates({
|
||||||
|
fetch: vi.fn<typeof fetch>(async () => jsonResponse(untrusted)),
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
platform: 'win32',
|
||||||
|
arch: 'x64'
|
||||||
|
})
|
||||||
|
).rejects.toThrow('not a trusted mirror URL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes VersionChecker and download pages through the selected source', async () => {
|
||||||
|
const transport = vi.fn<typeof fetch>(async () =>
|
||||||
|
jsonResponse(mirrorIndexPayload())
|
||||||
|
)
|
||||||
|
const checker = new VersionChecker({
|
||||||
|
fetch: transport,
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
platform: 'win32',
|
||||||
|
arch: 'x64'
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(checker.check('mirror')).resolves.toMatchObject({
|
||||||
|
latestVersion
|
||||||
|
})
|
||||||
|
expect(getUpdateDownloadPage('github')).toBe(
|
||||||
|
'https://github.com/mesalogo/goodbuddy/releases'
|
||||||
|
)
|
||||||
|
expect(getUpdateDownloadPage('mirror')).toBe(
|
||||||
|
'https://mesalogo.github.io/goodbuddy/#download'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+216
-2
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type {
|
import type {
|
||||||
|
UpdateSource,
|
||||||
VersionCheckFile,
|
VersionCheckFile,
|
||||||
VersionCheckResult,
|
VersionCheckResult,
|
||||||
VersionCheckTarget
|
VersionCheckTarget
|
||||||
@@ -11,10 +12,14 @@ export type {
|
|||||||
|
|
||||||
export const GOODBUDDY_LATEST_RELEASE_API_URL =
|
export const GOODBUDDY_LATEST_RELEASE_API_URL =
|
||||||
'https://api.github.com/repos/mesalogo/goodbuddy/releases/latest'
|
'https://api.github.com/repos/mesalogo/goodbuddy/releases/latest'
|
||||||
|
export const GOODBUDDY_MIRROR_RELEASE_INDEX_URL =
|
||||||
|
'https://goodbuddy.oss-cn-hangzhou.aliyuncs.com/releases/latest.json'
|
||||||
|
|
||||||
const PRODUCT_NAME = 'GoodBuddy'
|
const PRODUCT_NAME = 'GoodBuddy'
|
||||||
const RELEASE_WEB_ROOT =
|
const RELEASE_WEB_ROOT =
|
||||||
'https://github.com/mesalogo/goodbuddy/releases'
|
'https://github.com/mesalogo/goodbuddy/releases'
|
||||||
|
const MIRROR_DOWNLOAD_PAGE =
|
||||||
|
'https://mesalogo.github.io/goodbuddy/#download'
|
||||||
const DEFAULT_TIMEOUT_MS = 10_000
|
const DEFAULT_TIMEOUT_MS = 10_000
|
||||||
const DEFAULT_MAX_JSON_BYTES = 512 * 1024
|
const DEFAULT_MAX_JSON_BYTES = 512 * 1024
|
||||||
const MAX_TIMEOUT_MS = 60_000
|
const MAX_TIMEOUT_MS = 60_000
|
||||||
@@ -93,6 +98,37 @@ const aggregateReleaseManifestSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
|
const mirrorReleaseFileSchema = releaseFileSchema
|
||||||
|
.extend({
|
||||||
|
url: z.url().max(2_048)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
const mirrorReleaseTargetSchema = z
|
||||||
|
.object({
|
||||||
|
platform: platformSchema,
|
||||||
|
arch: architectureSchema,
|
||||||
|
files: z.record(
|
||||||
|
z.string().min(1).max(32),
|
||||||
|
mirrorReleaseFileSchema
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
const mirrorReleaseIndexSchema = z
|
||||||
|
.object({
|
||||||
|
formatVersion: z.literal(1),
|
||||||
|
productName: z.literal(PRODUCT_NAME),
|
||||||
|
version: z.string().min(1).max(256),
|
||||||
|
targets: z.record(
|
||||||
|
z.string().min(1).max(64),
|
||||||
|
mirrorReleaseTargetSchema
|
||||||
|
),
|
||||||
|
checksumUrl: z.url().max(2_048),
|
||||||
|
fallbackUrl: z.url().max(2_048)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
type ParsedSemVer = {
|
type ParsedSemVer = {
|
||||||
major: bigint
|
major: bigint
|
||||||
minor: bigint
|
minor: bigint
|
||||||
@@ -329,6 +365,108 @@ function sameFile(left: ReleaseFile, right: ReleaseFile): boolean {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertExactMirrorUrl(
|
||||||
|
value: string,
|
||||||
|
expected: string,
|
||||||
|
label: string
|
||||||
|
): void {
|
||||||
|
const url = new URL(value)
|
||||||
|
if (
|
||||||
|
url.href !== expected ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.search ||
|
||||||
|
url.hash
|
||||||
|
) {
|
||||||
|
throw new Error(`${label} is not a trusted mirror URL`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMirrorIndex(
|
||||||
|
index: z.infer<typeof mirrorReleaseIndexSchema>,
|
||||||
|
platform: ReleasePlatform,
|
||||||
|
arch: ReleaseArchitecture
|
||||||
|
): VersionCheckTarget {
|
||||||
|
const parsedVersion = parseSemVer(index.version)
|
||||||
|
if (parsedVersion.prerelease.length > 0) {
|
||||||
|
throw new Error('Mirror release index must point to a stable version')
|
||||||
|
}
|
||||||
|
const targetKeys = [
|
||||||
|
'windows-x64',
|
||||||
|
'windows-arm64',
|
||||||
|
'macos-x64',
|
||||||
|
'macos-arm64',
|
||||||
|
'linux-x64',
|
||||||
|
'linux-arm64'
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
Object.keys(index.targets).length !== targetKeys.length ||
|
||||||
|
targetKeys.some((key) => !index.targets[key])
|
||||||
|
) {
|
||||||
|
throw new Error('Mirror release index targets are incomplete')
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseBase = new URL(
|
||||||
|
`v${index.version}/`,
|
||||||
|
GOODBUDDY_MIRROR_RELEASE_INDEX_URL
|
||||||
|
)
|
||||||
|
for (const key of targetKeys) {
|
||||||
|
const target = index.targets[key]
|
||||||
|
if (!target || `${target.platform}-${target.arch}` !== key) {
|
||||||
|
throw new Error(`Mirror release target is invalid: ${key}`)
|
||||||
|
}
|
||||||
|
const formats = expectedFormats[target.platform]
|
||||||
|
if (
|
||||||
|
Object.keys(target.files).length !== formats.length ||
|
||||||
|
formats.some((format) => !target.files[format])
|
||||||
|
) {
|
||||||
|
throw new Error(`Mirror release files are incomplete: ${key}`)
|
||||||
|
}
|
||||||
|
const files = formats.map((format) => target.files[format]!)
|
||||||
|
if (
|
||||||
|
!hasExpectedFileFormats(target.platform, files) ||
|
||||||
|
new Set(files.map((file) => file.name)).size !== files.length
|
||||||
|
) {
|
||||||
|
throw new Error(`Mirror release files are invalid: ${key}`)
|
||||||
|
}
|
||||||
|
for (const file of files) {
|
||||||
|
assertExactMirrorUrl(
|
||||||
|
file.url,
|
||||||
|
new URL(encodeURIComponent(file.name), releaseBase).href,
|
||||||
|
'Mirror release file'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertExactMirrorUrl(
|
||||||
|
index.checksumUrl,
|
||||||
|
new URL('SHA256SUMS', releaseBase).href,
|
||||||
|
'Mirror checksum manifest'
|
||||||
|
)
|
||||||
|
if (index.fallbackUrl !== `${RELEASE_WEB_ROOT}/latest`) {
|
||||||
|
throw new Error('Mirror fallback release URL is invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = index.targets[`${platform}-${arch}`]
|
||||||
|
if (!target) {
|
||||||
|
throw new Error(`Mirror release target is missing: ${platform}/${arch}`)
|
||||||
|
}
|
||||||
|
const formats = expectedFormats[platform]
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
arch,
|
||||||
|
formats: [...formats],
|
||||||
|
files: formats.map((format) => {
|
||||||
|
const file = target.files[format]!
|
||||||
|
return {
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
sha256: file.sha256
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function validateCurrentTarget(
|
function validateCurrentTarget(
|
||||||
manifest: z.infer<typeof aggregateReleaseManifestSchema>,
|
manifest: z.infer<typeof aggregateReleaseManifestSchema>,
|
||||||
platform: ReleasePlatform,
|
platform: ReleasePlatform,
|
||||||
@@ -427,6 +565,34 @@ async function fetchJson(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchMirrorJson(
|
||||||
|
transport: typeof fetch,
|
||||||
|
signal: AbortSignal,
|
||||||
|
maximumBytes: number
|
||||||
|
): Promise<unknown> {
|
||||||
|
const response = await transport(GOODBUDDY_MIRROR_RELEASE_INDEX_URL, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': 'GoodBuddy-Version-Checker'
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'omit',
|
||||||
|
redirect: 'manual',
|
||||||
|
referrerPolicy: 'no-referrer',
|
||||||
|
signal
|
||||||
|
})
|
||||||
|
if (REDIRECT_STATUSES.has(response.status)) {
|
||||||
|
throw new Error('Mirror release index must not redirect')
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Version check request failed with HTTP ${response.status}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return readBoundedJson(response, maximumBytes, signal)
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkForUpdates(
|
export async function checkForUpdates(
|
||||||
dependencies: VersionCheckerDependencies
|
dependencies: VersionCheckerDependencies
|
||||||
): Promise<VersionCheckResult> {
|
): Promise<VersionCheckResult> {
|
||||||
@@ -503,10 +669,58 @@ export async function checkForUpdates(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkMirrorForUpdates(
|
||||||
|
dependencies: VersionCheckerDependencies
|
||||||
|
): Promise<VersionCheckResult> {
|
||||||
|
const timeoutMs = boundedInteger(
|
||||||
|
dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||||
|
'timeoutMs',
|
||||||
|
1,
|
||||||
|
MAX_TIMEOUT_MS
|
||||||
|
)
|
||||||
|
const maximumBytes = boundedInteger(
|
||||||
|
dependencies.maxJsonBytes ?? DEFAULT_MAX_JSON_BYTES,
|
||||||
|
'maxJsonBytes',
|
||||||
|
1,
|
||||||
|
MAX_JSON_BYTES
|
||||||
|
)
|
||||||
|
parseSemVer(dependencies.currentVersion)
|
||||||
|
const platform = normalizePlatform(dependencies.platform)
|
||||||
|
const arch = normalizeArchitecture(dependencies.arch)
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
try {
|
||||||
|
const index = mirrorReleaseIndexSchema.parse(
|
||||||
|
await fetchMirrorJson(
|
||||||
|
dependencies.fetch,
|
||||||
|
controller.signal,
|
||||||
|
maximumBytes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const target = validateMirrorIndex(index, platform, arch)
|
||||||
|
return {
|
||||||
|
updateAvailable:
|
||||||
|
compareStrictSemVer(index.version, dependencies.currentVersion) > 0,
|
||||||
|
currentVersion: dependencies.currentVersion,
|
||||||
|
latestVersion: index.version,
|
||||||
|
releaseUrl: MIRROR_DOWNLOAD_PAGE,
|
||||||
|
target
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUpdateDownloadPage(source: UpdateSource): string {
|
||||||
|
return source === 'mirror' ? MIRROR_DOWNLOAD_PAGE : RELEASE_WEB_ROOT
|
||||||
|
}
|
||||||
|
|
||||||
export class VersionChecker {
|
export class VersionChecker {
|
||||||
constructor(private readonly dependencies: VersionCheckerDependencies) {}
|
constructor(private readonly dependencies: VersionCheckerDependencies) {}
|
||||||
|
|
||||||
check(): Promise<VersionCheckResult> {
|
check(source: UpdateSource = 'github'): Promise<VersionCheckResult> {
|
||||||
return checkForUpdates(this.dependencies)
|
return source === 'mirror'
|
||||||
|
? checkMirrorForUpdates(this.dependencies)
|
||||||
|
: checkForUpdates(this.dependencies)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1082,6 +1082,7 @@ describe('App', () => {
|
|||||||
api.updates = {
|
api.updates = {
|
||||||
getSettings: vi.fn(async () => ({
|
getSettings: vi.fn(async () => ({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github' as const,
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
@@ -1173,12 +1174,14 @@ describe('App', () => {
|
|||||||
api.updates = {
|
api.updates = {
|
||||||
getSettings: vi.fn(async () => ({
|
getSettings: vi.fn(async () => ({
|
||||||
checkUpdatesOnStartup: true,
|
checkUpdatesOnStartup: true,
|
||||||
|
updateSource: 'github' as const,
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
})),
|
})),
|
||||||
updateSettings: vi.fn(async () => ({
|
updateSettings: vi.fn(async () => ({
|
||||||
checkUpdatesOnStartup: true,
|
checkUpdatesOnStartup: true,
|
||||||
|
updateSource: 'github' as const,
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
@@ -1271,12 +1274,14 @@ describe('App', () => {
|
|||||||
api.updates = {
|
api.updates = {
|
||||||
getSettings: vi.fn(async () => ({
|
getSettings: vi.fn(async () => ({
|
||||||
checkUpdatesOnStartup: true,
|
checkUpdatesOnStartup: true,
|
||||||
|
updateSource: 'github' as const,
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
})),
|
})),
|
||||||
updateSettings: vi.fn(async () => ({
|
updateSettings: vi.fn(async () => ({
|
||||||
checkUpdatesOnStartup: true,
|
checkUpdatesOnStartup: true,
|
||||||
|
updateSource: 'github' as const,
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
@@ -7062,12 +7067,14 @@ describe('App', () => {
|
|||||||
api.updates = {
|
api.updates = {
|
||||||
getSettings: vi.fn(async () => ({
|
getSettings: vi.fn(async () => ({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github' as const,
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
})),
|
})),
|
||||||
updateSettings: vi.fn(async () => ({
|
updateSettings: vi.fn(async () => ({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github' as const,
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
@@ -7107,12 +7114,14 @@ describe('App', () => {
|
|||||||
api.updates = {
|
api.updates = {
|
||||||
getSettings: vi.fn(async () => ({
|
getSettings: vi.fn(async () => ({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github' as const,
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
})),
|
})),
|
||||||
updateSettings: vi.fn(async () => ({
|
updateSettings: vi.fn(async () => ({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github' as const,
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
@@ -7144,6 +7153,7 @@ describe('App', () => {
|
|||||||
it('keeps platform-feature switches in Settings without navigating', async () => {
|
it('keeps platform-feature switches in Settings without navigating', async () => {
|
||||||
let applicationSettings: ApplicationSettings = {
|
let applicationSettings: ApplicationSettings = {
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ const onAnalysisEvent = vi.fn<
|
|||||||
})
|
})
|
||||||
const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async () => ({
|
const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async () => ({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
@@ -178,6 +179,7 @@ beforeEach(() => {
|
|||||||
analysisEventListener = undefined
|
analysisEventListener = undefined
|
||||||
getApplicationSettings.mockResolvedValue({
|
getApplicationSettings.mockResolvedValue({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
@@ -677,6 +679,7 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
it('reuses the AI comments pane for selected todos', async () => {
|
it('reuses the AI comments pane for selected todos', async () => {
|
||||||
getApplicationSettings.mockResolvedValue({
|
getApplicationSettings.mockResolvedValue({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'after-save-manual',
|
magicNoteCommentMode: 'after-save-manual',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
@@ -707,6 +710,7 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
it('streams with snapshotted sidebar options while later changes stay local', async () => {
|
it('streams with snapshotted sidebar options while later changes stay local', async () => {
|
||||||
getApplicationSettings.mockResolvedValue({
|
getApplicationSettings.mockResolvedValue({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'after-save-manual',
|
magicNoteCommentMode: 'after-save-manual',
|
||||||
magicNoteCommentFormat: 'narrative'
|
magicNoteCommentFormat: 'narrative'
|
||||||
@@ -820,6 +824,7 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
it('automatically comments on a newly saved record in auto mode', async () => {
|
it('automatically comments on a newly saved record in auto mode', async () => {
|
||||||
getApplicationSettings.mockResolvedValue({
|
getApplicationSettings.mockResolvedValue({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'after-save-auto',
|
magicNoteCommentMode: 'after-save-auto',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ const diagnoseEmbedding = vi.fn(
|
|||||||
)
|
)
|
||||||
let applicationSettings: ApplicationSettings = {
|
let applicationSettings: ApplicationSettings = {
|
||||||
checkUpdatesOnStartup: true,
|
checkUpdatesOnStartup: true,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
@@ -565,6 +566,7 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
await changeUiLocale('zh-CN')
|
await changeUiLocale('zh-CN')
|
||||||
applicationSettings = {
|
applicationSettings = {
|
||||||
checkUpdatesOnStartup: true,
|
checkUpdatesOnStartup: true,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
waitFor
|
waitFor
|
||||||
} from '@testing-library/react'
|
} from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||||
import type { DesktopApi } from '../../shared/contracts'
|
import type { DesktopApi } from '../../shared/contracts'
|
||||||
import { UpdateSettingsSection } from './UpdateSettingsSection'
|
import { UpdateSettingsSection } from './UpdateSettingsSection'
|
||||||
|
|
||||||
@@ -16,17 +17,22 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe('UpdateSettingsSection', () => {
|
describe('UpdateSettingsSection', () => {
|
||||||
it('checks the official release manifest and updates the startup preference', async () => {
|
it('checks the official release manifest and updates the startup preference', async () => {
|
||||||
|
let applicationSettings: ApplicationSettings = {
|
||||||
|
checkUpdatesOnStartup: true,
|
||||||
|
updateSource: 'github',
|
||||||
|
magicNotesEnabled: true,
|
||||||
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
|
magicNoteCommentFormat: 'combined' as const
|
||||||
|
}
|
||||||
const updateSettings = vi.fn<
|
const updateSettings = vi.fn<
|
||||||
NonNullable<DesktopApi['updates']>['updateSettings']
|
NonNullable<DesktopApi['updates']>['updateSettings']
|
||||||
>(async (input) => ({
|
>(async (input) => {
|
||||||
checkUpdatesOnStartup:
|
applicationSettings = {
|
||||||
input.checkUpdatesOnStartup ?? true,
|
...applicationSettings,
|
||||||
magicNotesEnabled: input.magicNotesEnabled ?? true,
|
...input
|
||||||
magicNoteCommentMode:
|
}
|
||||||
input.magicNoteCommentMode ?? 'immediate',
|
return applicationSettings
|
||||||
magicNoteCommentFormat:
|
})
|
||||||
input.magicNoteCommentFormat ?? 'combined'
|
|
||||||
}))
|
|
||||||
const check = vi.fn<
|
const check = vi.fn<
|
||||||
NonNullable<DesktopApi['updates']>['check']
|
NonNullable<DesktopApi['updates']>['check']
|
||||||
>(async () => ({
|
>(async () => ({
|
||||||
@@ -62,10 +68,7 @@ describe('UpdateSettingsSection', () => {
|
|||||||
},
|
},
|
||||||
updates: {
|
updates: {
|
||||||
getSettings: vi.fn(async () => ({
|
getSettings: vi.fn(async () => ({
|
||||||
checkUpdatesOnStartup: true,
|
...applicationSettings
|
||||||
magicNotesEnabled: true,
|
|
||||||
magicNoteCommentMode: 'immediate',
|
|
||||||
magicNoteCommentFormat: 'combined'
|
|
||||||
})),
|
})),
|
||||||
updateSettings,
|
updateSettings,
|
||||||
check,
|
check,
|
||||||
@@ -80,12 +83,36 @@ describe('UpdateSettingsSection', () => {
|
|||||||
name: '启动时检查新版本'
|
name: '启动时检查新版本'
|
||||||
})
|
})
|
||||||
expect(startup).toBeChecked()
|
expect(startup).toBeChecked()
|
||||||
|
const source = screen.getByRole('combobox', {
|
||||||
|
name: '检查更新源'
|
||||||
|
})
|
||||||
|
const startupRow = startup.closest('label')
|
||||||
|
const sourceRow = source.closest('label')
|
||||||
|
expect(source).toHaveValue('github')
|
||||||
|
expect(sourceRow).toHaveClass('update-settings__source')
|
||||||
|
expect(
|
||||||
|
startupRow!.compareDocumentPosition(sourceRow!) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(source).toBeEnabled()
|
||||||
|
fireEvent.change(source, { target: { value: 'mirror' } })
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(updateSettings).toHaveBeenCalledWith({
|
||||||
|
updateSource: 'mirror'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(source).toHaveValue('mirror')
|
||||||
|
expect(
|
||||||
|
screen.getByRole('option', { name: '镜像节点' })
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.click(startup)
|
fireEvent.click(startup)
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(updateSettings).toHaveBeenCalledWith({
|
expect(updateSettings).toHaveBeenCalledWith({
|
||||||
checkUpdatesOnStartup: false
|
checkUpdatesOnStartup: false
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
expect(source).toBeDisabled()
|
||||||
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole('button', { name: '立即检查更新' })
|
screen.getByRole('button', { name: '立即检查更新' })
|
||||||
@@ -113,12 +140,14 @@ describe('UpdateSettingsSection', () => {
|
|||||||
updates: {
|
updates: {
|
||||||
getSettings: vi.fn(async () => ({
|
getSettings: vi.fn(async () => ({
|
||||||
checkUpdatesOnStartup: true,
|
checkUpdatesOnStartup: true,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})),
|
})),
|
||||||
updateSettings: vi.fn(async () => ({
|
updateSettings: vi.fn(async () => ({
|
||||||
checkUpdatesOnStartup: true,
|
checkUpdatesOnStartup: true,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
@@ -141,7 +170,7 @@ describe('UpdateSettingsSection', () => {
|
|||||||
|
|
||||||
const alert = await screen.findByRole('alert')
|
const alert = await screen.findByRole('alert')
|
||||||
expect(alert).toHaveTextContent(
|
expect(alert).toHaveTextContent(
|
||||||
'版本检查失败:无法连接 GoodBuddy 官方 GitHub Release,请检查网络或代理后重试'
|
'版本检查失败:无法连接更新源“GitHub”,请检查网络或代理后重试'
|
||||||
)
|
)
|
||||||
expect(alert).not.toHaveTextContent('Error invoking remote method')
|
expect(alert).not.toHaveTextContent('Error invoking remote method')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type {
|
import type {
|
||||||
ApplicationSettings,
|
ApplicationSettings,
|
||||||
|
UpdateSource,
|
||||||
VersionCheckResult
|
VersionCheckResult
|
||||||
} from '../../shared/application-settings-contracts'
|
} from '../../shared/application-settings-contracts'
|
||||||
import type { AppInfo } from '../../shared/contracts'
|
import type { AppInfo } from '../../shared/contracts'
|
||||||
@@ -110,6 +111,34 @@ export function UpdateSettingsSection(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const changeUpdateSource = async (
|
||||||
|
updateSource: UpdateSource
|
||||||
|
): Promise<void> => {
|
||||||
|
const updates = window.goodbuddy.updates
|
||||||
|
if (!updates || !settings) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(undefined)
|
||||||
|
try {
|
||||||
|
setSettings(await updates.updateSettings({ updateSource }))
|
||||||
|
setResult(undefined)
|
||||||
|
} catch (reason) {
|
||||||
|
const fallback = t('updates.errors.saveSourceFailed')
|
||||||
|
setError(
|
||||||
|
updateErrorMessage(
|
||||||
|
reason,
|
||||||
|
fallback,
|
||||||
|
t('updates.errors.network', {
|
||||||
|
fallback
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const check = async (): Promise<void> => {
|
const check = async (): Promise<void> => {
|
||||||
const updates = window.goodbuddy.updates
|
const updates = window.goodbuddy.updates
|
||||||
if (!updates) {
|
if (!updates) {
|
||||||
@@ -125,7 +154,12 @@ export function UpdateSettingsSection(): React.JSX.Element {
|
|||||||
updateErrorMessage(
|
updateErrorMessage(
|
||||||
reason,
|
reason,
|
||||||
fallback,
|
fallback,
|
||||||
t('updates.errors.network', { fallback })
|
t('updates.errors.sourceNetwork', {
|
||||||
|
fallback,
|
||||||
|
source: t(
|
||||||
|
`updates.source.names.${settings?.updateSource ?? 'github'}`
|
||||||
|
)
|
||||||
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -171,6 +205,33 @@ export function UpdateSettingsSection(): React.JSX.Element {
|
|||||||
<span>{t('updates.checkOnStartup')}</span>
|
<span>{t('updates.checkOnStartup')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="field update-settings__source">
|
||||||
|
<span>{t('updates.source.label')}</span>
|
||||||
|
<select
|
||||||
|
aria-label={t('updates.source.label')}
|
||||||
|
disabled={
|
||||||
|
!settings ||
|
||||||
|
!settings.checkUpdatesOnStartup ||
|
||||||
|
saving ||
|
||||||
|
checking
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
void changeUpdateSource(
|
||||||
|
event.target.value as UpdateSource
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value={settings?.updateSource ?? 'github'}
|
||||||
|
>
|
||||||
|
<option value="github">
|
||||||
|
{t('updates.source.options.github')}
|
||||||
|
</option>
|
||||||
|
<option value="mirror">
|
||||||
|
{t('updates.source.options.mirror')}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<small>{t('updates.source.description')}</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="update-settings__actions">
|
<div className="update-settings__actions">
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export const settings = {
|
|||||||
label: 'About and updates',
|
label: 'About and updates',
|
||||||
navigationDescription: 'Version checks and downloads',
|
navigationDescription: 'Version checks and downloads',
|
||||||
description:
|
description:
|
||||||
'Checks only the official GoodBuddy GitHub Release and never installs automatically'
|
'Checks the selected official update source and never installs automatically'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -285,16 +285,31 @@ export const settingsSections = {
|
|||||||
'Version checks are not available in this version',
|
'Version checks are not available in this version',
|
||||||
readSettingsFailed: 'Could not load application settings',
|
readSettingsFailed: 'Could not load application settings',
|
||||||
saveSettingsFailed: 'Could not save update settings',
|
saveSettingsFailed: 'Could not save update settings',
|
||||||
|
saveSourceFailed: 'Could not save the update source',
|
||||||
checkFailed: 'Version check failed',
|
checkFailed: 'Version check failed',
|
||||||
network:
|
network: '{{fallback}}. Check the system status and try again.',
|
||||||
'{{fallback}}: Could not connect to the official GoodBuddy GitHub Release. Check your network or proxy and try again.'
|
sourceNetwork:
|
||||||
|
'{{fallback}}: Could not connect to update source "{{source}}". Check your network or proxy and try again.'
|
||||||
},
|
},
|
||||||
loadingAppInfo: 'Loading application information…',
|
loadingAppInfo: 'Loading application information…',
|
||||||
|
source: {
|
||||||
|
label: 'Update source',
|
||||||
|
description:
|
||||||
|
'Used for manual checks, startup checks, and the download page.',
|
||||||
|
options: {
|
||||||
|
github: 'GitHub (default)',
|
||||||
|
mirror: 'Mirror node'
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
github: 'GitHub',
|
||||||
|
mirror: 'Mirror node'
|
||||||
|
}
|
||||||
|
},
|
||||||
checkOnStartup: 'Check for updates at startup',
|
checkOnStartup: 'Check for updates at startup',
|
||||||
actions: {
|
actions: {
|
||||||
checking: 'Checking…',
|
checking: 'Checking…',
|
||||||
checkNow: 'Check for updates now',
|
checkNow: 'Check for updates now',
|
||||||
openDownloadPage: 'Open official download page'
|
openDownloadPage: 'Open download page'
|
||||||
},
|
},
|
||||||
result: {
|
result: {
|
||||||
available: 'New version {{version}} is available',
|
available: 'New version {{version}} is available',
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const settings = {
|
|||||||
about: {
|
about: {
|
||||||
label: '关于与更新',
|
label: '关于与更新',
|
||||||
navigationDescription: '版本检查与下载页',
|
navigationDescription: '版本检查与下载页',
|
||||||
description: '只检查 GoodBuddy 官方 GitHub Release,不自动下载安装'
|
description: '检查所选官方更新源,不自动下载安装'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -266,16 +266,30 @@ export const settingsSections = {
|
|||||||
serviceUnavailable: '当前版本未提供版本检查服务',
|
serviceUnavailable: '当前版本未提供版本检查服务',
|
||||||
readSettingsFailed: '读取应用设置失败',
|
readSettingsFailed: '读取应用设置失败',
|
||||||
saveSettingsFailed: '保存更新设置失败',
|
saveSettingsFailed: '保存更新设置失败',
|
||||||
|
saveSourceFailed: '保存检查更新源失败',
|
||||||
checkFailed: '版本检查失败',
|
checkFailed: '版本检查失败',
|
||||||
network:
|
network: '{{fallback}}:请检查系统状态后重试',
|
||||||
'{{fallback}}:无法连接 GoodBuddy 官方 GitHub Release,请检查网络或代理后重试'
|
sourceNetwork:
|
||||||
|
'{{fallback}}:无法连接更新源“{{source}}”,请检查网络或代理后重试'
|
||||||
},
|
},
|
||||||
loadingAppInfo: '正在读取应用信息…',
|
loadingAppInfo: '正在读取应用信息…',
|
||||||
|
source: {
|
||||||
|
label: '检查更新源',
|
||||||
|
description: '用于手动检查、启动时检查和打开下载页。',
|
||||||
|
options: {
|
||||||
|
github: 'GitHub(默认)',
|
||||||
|
mirror: '镜像节点'
|
||||||
|
},
|
||||||
|
names: {
|
||||||
|
github: 'GitHub',
|
||||||
|
mirror: '镜像节点'
|
||||||
|
}
|
||||||
|
},
|
||||||
checkOnStartup: '启动时检查新版本',
|
checkOnStartup: '启动时检查新版本',
|
||||||
actions: {
|
actions: {
|
||||||
checking: '正在检查…',
|
checking: '正在检查…',
|
||||||
checkNow: '立即检查更新',
|
checkNow: '立即检查更新',
|
||||||
openDownloadPage: '打开官方下载页'
|
openDownloadPage: '打开下载页'
|
||||||
},
|
},
|
||||||
result: {
|
result: {
|
||||||
available: '发现新版本 {{version}}',
|
available: '发现新版本 {{version}}',
|
||||||
|
|||||||
@@ -6297,6 +6297,39 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field.update-settings__source {
|
||||||
|
display: grid;
|
||||||
|
min-height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
grid-template-columns: max-content minmax(160px, 220px) minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-settings__source > span {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-caption);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-settings__source > select {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-settings__source > select:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-settings__source:has(select:disabled) > span,
|
||||||
|
.update-settings__source:has(select:disabled) > small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-settings__source > small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-caption);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.update-settings__actions button {
|
.update-settings__actions button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -6359,6 +6392,12 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.field.update-settings__source {
|
||||||
|
align-items: stretch;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.speech-model-settings .settings-section__title--actions {
|
.speech-model-settings .settings-section__title--actions {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ export type MagicNoteCommentMode = z.infer<
|
|||||||
typeof magicNoteCommentModeSchema
|
typeof magicNoteCommentModeSchema
|
||||||
>
|
>
|
||||||
|
|
||||||
|
export const updateSourceSchema = z.enum([
|
||||||
|
'github',
|
||||||
|
'mirror'
|
||||||
|
])
|
||||||
|
export type UpdateSource = z.infer<typeof updateSourceSchema>
|
||||||
|
|
||||||
const applicationPreferencesSchema = z
|
const applicationPreferencesSchema = z
|
||||||
.object({
|
.object({
|
||||||
checkUpdatesOnStartup: z.boolean(),
|
checkUpdatesOnStartup: z.boolean(),
|
||||||
|
updateSource: updateSourceSchema,
|
||||||
magicNotesEnabled: z.boolean(),
|
magicNotesEnabled: z.boolean(),
|
||||||
magicNoteCommentMode: magicNoteCommentModeSchema,
|
magicNoteCommentMode: magicNoteCommentModeSchema,
|
||||||
magicNoteCommentFormat: magicNoteCommentFormatSchema
|
magicNoteCommentFormat: magicNoteCommentFormatSchema
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ describe('GoodBuddy configuration contracts', () => {
|
|||||||
const snapshot = {
|
const snapshot = {
|
||||||
application: {
|
application: {
|
||||||
checkUpdatesOnStartup: true,
|
checkUpdatesOnStartup: true,
|
||||||
|
updateSource: 'github',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
interface ReleaseFile {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
sha256: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteReleaseModule {
|
||||||
|
createSiteRelease: (
|
||||||
|
manifest: object,
|
||||||
|
baseUrl: string
|
||||||
|
) => {
|
||||||
|
version: string
|
||||||
|
targets: Record<string, {
|
||||||
|
files: Record<string, ReleaseFile>
|
||||||
|
}>
|
||||||
|
checksumUrl: string
|
||||||
|
fallbackUrl: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerifySiteReleaseModule {
|
||||||
|
verifySiteRelease: (
|
||||||
|
manifest: object,
|
||||||
|
request: typeof fetch
|
||||||
|
) => Promise<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const siteRelease = require(
|
||||||
|
'../build/create-site-release.cjs'
|
||||||
|
) as SiteReleaseModule
|
||||||
|
const verifier = require(
|
||||||
|
'../build/verify-site-release.cjs'
|
||||||
|
) as VerifySiteReleaseModule
|
||||||
|
|
||||||
|
const targetDefinitions = [
|
||||||
|
['windows', 'x64', ['nsis', 'portable']],
|
||||||
|
['windows', 'arm64', ['nsis', 'portable']],
|
||||||
|
['macos', 'x64', ['dmg', 'zip']],
|
||||||
|
['macos', 'arm64', ['dmg', 'zip']],
|
||||||
|
['linux', 'x64', ['AppImage', 'deb']],
|
||||||
|
['linux', 'arm64', ['AppImage', 'deb']]
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function createAggregateManifest() {
|
||||||
|
return {
|
||||||
|
formatVersion: 1,
|
||||||
|
productName: 'GoodBuddy',
|
||||||
|
version: '1.2.3',
|
||||||
|
targets: targetDefinitions.map(([platform, arch, formats]) => ({
|
||||||
|
platform,
|
||||||
|
arch,
|
||||||
|
formats,
|
||||||
|
files: [...formats].reverse().map((format, index) => ({
|
||||||
|
name:
|
||||||
|
platform === 'windows'
|
||||||
|
? `GoodBuddy-1.2.3-${platform}-${arch}-${format === 'nsis' ? 'setup.exe' : 'portable.zip'}`
|
||||||
|
: `GoodBuddy-1.2.3-${platform}-${arch}.${format}`,
|
||||||
|
size: index + 100,
|
||||||
|
sha256: 'a'.repeat(64)
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('site release manifest', () => {
|
||||||
|
it('creates direct HTTPS download entries for all release targets', () => {
|
||||||
|
const result = siteRelease.createSiteRelease(
|
||||||
|
createAggregateManifest(),
|
||||||
|
'https://goodbuddy.oss-cn-hangzhou.aliyuncs.com/releases/v1.2.3'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.version).toBe('1.2.3')
|
||||||
|
expect(Object.keys(result.targets)).toHaveLength(6)
|
||||||
|
expect(
|
||||||
|
result.targets['windows-x64']?.files.nsis?.url
|
||||||
|
).toBe(
|
||||||
|
'https://goodbuddy.oss-cn-hangzhou.aliyuncs.com/releases/v1.2.3/GoodBuddy-1.2.3-windows-x64-setup.exe'
|
||||||
|
)
|
||||||
|
expect(result.checksumUrl).toMatch(/\/SHA256SUMS$/u)
|
||||||
|
expect(result.fallbackUrl).toBe(
|
||||||
|
'https://github.com/mesalogo/goodbuddy/releases/latest'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects insecure or credentialed OSS base URLs', () => {
|
||||||
|
expect(() =>
|
||||||
|
siteRelease.createSiteRelease(
|
||||||
|
createAggregateManifest(),
|
||||||
|
'http://example.com/releases/v1.2.3/'
|
||||||
|
)
|
||||||
|
).toThrow('HTTPS')
|
||||||
|
expect(() =>
|
||||||
|
siteRelease.createSiteRelease(
|
||||||
|
createAggregateManifest(),
|
||||||
|
'https://user:secret@example.com/releases/v1.2.3/'
|
||||||
|
)
|
||||||
|
).toThrow('无凭据')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies every public OSS object with HEAD and size checks', async () => {
|
||||||
|
const manifest = siteRelease.createSiteRelease(
|
||||||
|
createAggregateManifest(),
|
||||||
|
'https://goodbuddy.oss-cn-hangzhou.aliyuncs.com/releases/v1.2.3/'
|
||||||
|
)
|
||||||
|
const sizes = new Map(
|
||||||
|
Object.values(manifest.targets).flatMap((target) =>
|
||||||
|
Object.values(target.files).map((file) => [file.url, file.size])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const request = vi.fn(async (url: string | URL | Request) => {
|
||||||
|
const size = sizes.get(String(url))
|
||||||
|
return new Response(null, {
|
||||||
|
status: size ? 200 : 404,
|
||||||
|
headers: size ? { 'content-length': String(size) } : {}
|
||||||
|
})
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
verifier.verifySiteRelease(manifest, request)
|
||||||
|
).resolves.toBe(12)
|
||||||
|
expect(request).toHaveBeenCalledTimes(12)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user