Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ecb735db0 | ||
|
|
255df74d87 | ||
|
|
e8c1c1f3f3 | ||
|
|
abe017a465 | ||
|
|
dc8f86ff3e | ||
|
|
ce15e7021c | ||
|
|
fa9ead895f |
@@ -131,14 +131,17 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
name: Publish GitHub and OSS release
|
||||
if: github.event_name == 'push' && github.ref_type == 'tag'
|
||||
needs: package
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 35
|
||||
environment:
|
||||
name: aliyun-oss-release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -181,7 +184,102 @@ jobs:
|
||||
- name: Verify and aggregate release assets
|
||||
run: node build/aggregate-release.cjs --input dist/release-downloads --output dist/release-upload
|
||||
|
||||
- name: Create or update draft release
|
||||
- name: Verify OSS release configuration
|
||||
shell: bash
|
||||
env:
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
OIDC_PROVIDER_ARN: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }}
|
||||
ROLE_ARN: ${{ vars.ALIYUN_ROLE_ARN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$OSS_BUCKET"
|
||||
test -n "$OSS_ENDPOINT"
|
||||
test -n "$OIDC_PROVIDER_ARN"
|
||||
test -n "$ROLE_ARN"
|
||||
case "$OSS_BUCKET" in
|
||||
*[!a-z0-9-]*|'') echo "OSS Bucket 名称无效" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$OSS_ENDPOINT" in
|
||||
https://oss-*.aliyuncs.com) ;;
|
||||
*) echo "OSS Endpoint 必须使用标准 HTTPS 地址" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$OIDC_PROVIDER_ARN" in
|
||||
acs:ram::*:oidc-provider/*) ;;
|
||||
*) echo "OIDC Provider ARN 无效" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$ROLE_ARN" in
|
||||
acs:ram::*:role/*) ;;
|
||||
*) echo "RAM Role ARN 无效" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Authenticate to Alibaba Cloud
|
||||
uses: aliyun/configure-aliyun-credentials-action@v1
|
||||
with:
|
||||
role-to-assume: ${{ vars.ALIYUN_ROLE_ARN }}
|
||||
oidc-provider-arn: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }}
|
||||
role-session-name: goodbuddy-release-${{ github.run_id }}
|
||||
role-session-expiration: 3600
|
||||
audience: sts.aliyuncs.com
|
||||
|
||||
- name: Install ossutil
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="2.3.0"
|
||||
archive="$RUNNER_TEMP/ossutil.zip"
|
||||
directory="$RUNNER_TEMP/ossutil"
|
||||
curl --fail --silent --show-error --location \
|
||||
"https://gosspublic.alicdn.com/ossutil/v2/$version/ossutil-$version-linux-amd64.zip" \
|
||||
--output "$archive"
|
||||
mkdir "$directory"
|
||||
unzip -q "$archive" -d "$directory"
|
||||
binary="$(find "$directory" -type f -name ossutil -print -quit)"
|
||||
test -n "$binary"
|
||||
chmod +x "$binary"
|
||||
echo "$(dirname "$binary")" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Prepare OSS website release index
|
||||
id: oss-release
|
||||
shell: bash
|
||||
env:
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
endpoint_host="${OSS_ENDPOINT#https://}"
|
||||
base_url="https://${OSS_BUCKET}.${endpoint_host}/releases/${GITHUB_REF_NAME}/"
|
||||
node build/create-site-release.cjs \
|
||||
--manifest dist/release-upload/release-manifest.json \
|
||||
--base-url "$base_url" \
|
||||
--output dist/site-release.json
|
||||
echo "base-url=$base_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload immutable release assets to OSS
|
||||
shell: bash
|
||||
env:
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OSS_ACCESS_KEY_ID="$ALIBABA_CLOUD_ACCESS_KEY_ID"
|
||||
export OSS_ACCESS_KEY_SECRET="$ALIBABA_CLOUD_ACCESS_KEY_SECRET"
|
||||
export OSS_SESSION_TOKEN="$ALIBABA_CLOUD_SECURITY_TOKEN"
|
||||
test -n "$OSS_ACCESS_KEY_ID"
|
||||
test -n "$OSS_ACCESS_KEY_SECRET"
|
||||
test -n "$OSS_SESSION_TOKEN"
|
||||
for file in dist/release-upload/* dist/site-release.json; do
|
||||
name="$(basename "$file")"
|
||||
ossutil cp "$file" \
|
||||
"oss://${OSS_BUCKET}/releases/${GITHUB_REF_NAME}/${name}" \
|
||||
--endpoint "$OSS_ENDPOINT" \
|
||||
--update
|
||||
done
|
||||
|
||||
- name: Verify public OSS release assets
|
||||
run: node build/verify-site-release.cjs --manifest dist/site-release.json
|
||||
|
||||
- name: Create or update draft GitHub release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -196,3 +294,18 @@ jobs:
|
||||
fi
|
||||
gh release upload "$tag" dist/release-upload/* --clobber
|
||||
gh release edit "$tag" --draft=false --latest
|
||||
|
||||
- name: Point website to verified OSS release
|
||||
shell: bash
|
||||
env:
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OSS_ACCESS_KEY_ID="$ALIBABA_CLOUD_ACCESS_KEY_ID"
|
||||
export OSS_ACCESS_KEY_SECRET="$ALIBABA_CLOUD_ACCESS_KEY_SECRET"
|
||||
export OSS_SESSION_TOKEN="$ALIBABA_CLOUD_SECURITY_TOKEN"
|
||||
ossutil cp dist/site-release.json \
|
||||
"oss://${OSS_BUCKET}/releases/latest.json" \
|
||||
--endpoint "$OSS_ENDPOINT" \
|
||||
--force
|
||||
|
||||
@@ -169,8 +169,9 @@ ZIP,以及 Linux 的 AppImage 与 DEB。Windows portable ZIP 解压后可直
|
||||
推送 `v${package.version}` 标签时,工作流运行验证和六平台打包。只有在
|
||||
全部目标成功后,才会严格校验并聚合所有平台产物,生成按平台重命名的
|
||||
manifests、总 `release-manifest.json` 和 `SHA256SUMS`。随后工作流创建或
|
||||
更新 draft GitHub Release,上传全部资产成功后才发布。重跑会保留人工
|
||||
编辑的 Release notes 和未知附件。
|
||||
更新 draft GitHub Release,上传全部资产成功后才发布,并在全部下载资产
|
||||
验证通过后切换官网最新版本索引。任一步失败都不会切换官网最新版本。
|
||||
重跑会保留人工编辑的 Release notes 和未知附件。
|
||||
|
||||
中英文发布说明统一维护在 `resources/release-notes.json`。新版本按“本次
|
||||
亮点 / Highlights”“功能更新 / Features”“问题修复 / Bug Fixes”“使用前
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
|
||||
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、本地目录导入、切换和删除。
|
||||
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
|
||||
- [x] **版本检查**:仅检查固定官方 Release 和当前平台清单,不自动下载或安装。
|
||||
- [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。
|
||||
- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。
|
||||
|
||||
### 开源、构建与发布
|
||||
|
||||
@@ -73,6 +73,14 @@ npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## 社区交流
|
||||
|
||||
目前采用微信群的方式供大家高效交流,大家可以微信扫码进入社区群:
|
||||
|
||||
<img width="1279" height="1306" alt="056bfac87840a95547f9805a8122fc2d" src="https://github.com/user-attachments/assets/b3342635-60a1-484c-959c-f90ba3c39d69" />
|
||||
|
||||
|
||||
|
||||
## 开源许可
|
||||
|
||||
GoodBuddy 的原创代码采用 [0BSD License](LICENSE),可自由使用、修改、分发和商用。第三方组件和资源遵循各自许可证。
|
||||
|
||||
@@ -490,6 +490,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 使用 `standard` 壳层和统一 `PageHeader`。
|
||||
- 搜索、范围和时间筛选位于筛选工具栏。
|
||||
- 行项目统一显示标题、范围、最近更新时间和必要状态。
|
||||
- 侧栏最近会话的更新时间在当天显示本地时间,非当天显示本地日期;非当年记录必须同时显示年份。悬停时间信息时提供完整日期和时间。
|
||||
- 删除入口使用 `danger-ghost`,并按数据可恢复性执行确认或撤销策略。
|
||||
|
||||
### 13.3 知识库
|
||||
@@ -533,6 +534,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。
|
||||
- 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。
|
||||
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
|
||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。
|
||||
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
|
||||
- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
|
||||
- “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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 = {}
|
||||
const optionNames = new Map([
|
||||
['--manifest', 'manifest'],
|
||||
['--base-url', 'baseUrl'],
|
||||
['--output', 'output']
|
||||
])
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index]
|
||||
const optionName = optionNames.get(argument)
|
||||
if (optionName) {
|
||||
const value = argv[index + 1]
|
||||
if (!value) {
|
||||
throw new Error(`${argument} 缺少值`)
|
||||
}
|
||||
options[optionName] = 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
|
||||
})
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.10.3",
|
||||
"releasedAt": "2026-08-17",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.10.3 新增可选的镜像节点,并改进最近对话的时间显示。"
|
||||
],
|
||||
"features": [
|
||||
"**镜像节点与更新源。** 在“关于与更新”中可选择 GitHub(默认)或镜像节点。手动检查、启动时检查和打开下载页会使用同一选择,应用仍只检查版本,不会自动下载安装。"
|
||||
],
|
||||
"fixes": [
|
||||
"**最近对话时间。** 侧栏中的当天对话显示本地时间,较早对话显示日期,跨年记录同时显示年份;悬停可查看完整日期和时间。"
|
||||
],
|
||||
"notices": [
|
||||
"**默认更新源。** 现有用户升级后仍默认使用 GitHub;只有手动选择后才会使用镜像节点。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.10.3 adds an optional mirror node and improves how recent conversation times are displayed."
|
||||
],
|
||||
"features": [
|
||||
"**Mirror node and update source.** About & Updates now lets you choose GitHub (default) or the mirror node. Manual checks, startup checks, and the download page use the same selection. GoodBuddy still checks versions only and never downloads or installs updates automatically."
|
||||
],
|
||||
"fixes": [
|
||||
"**Recent conversation times.** Today’s conversations show local time in the sidebar, older conversations show the date, and entries from another year include the year. Hovering reveals the complete date and time."
|
||||
],
|
||||
"notices": [
|
||||
"**Default update source.** Existing users continue to use GitHub after upgrading. The mirror node is used only after it is selected manually."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.10.1",
|
||||
"releasedAt": "2026-08-17",
|
||||
|
||||
+8
-6
@@ -39,25 +39,27 @@ node sites/scripts/validate.mjs
|
||||
node --check sites/app.js
|
||||
```
|
||||
|
||||
校验脚本会检查必需文件、页内链接、本地资源、关键产品文案、主题与响应式规则,以及下载入口是否始终指向官方最新 Release。
|
||||
校验脚本会检查必需文件、页内链接、本地资源、关键产品文案、主题与响应式
|
||||
规则,以及下载选择器是否从受信任的正式发布索引加载并保留 GitHub
|
||||
Release 回退入口。
|
||||
|
||||
## 下载入口
|
||||
|
||||
官网正文不展示具体版本号,三个系统下载按钮直接指向 GitHub 最新正式
|
||||
官网正文不写死版本号,页面启动后读取最新正式发布索引。
|
||||
Windows、macOS 和 Linux 下载卡片分别提供处理器架构与安装包类型选择器,
|
||||
选择后直接下载经过发布校验的不可变版本对象。发布索引请求失败、
|
||||
格式无效或返回非受信任的官方下载地址时,按钮继续指向 GitHub 最新正式
|
||||
Release:
|
||||
|
||||
```text
|
||||
https://github.com/mesalogo/goodbuddy/releases/latest
|
||||
```
|
||||
|
||||
新版本发布后 GitHub 会自动更新该地址的目标,官网无需同步修改版本号
|
||||
或安装资产名称。用户在 Release 页面按系统与架构选择文件并核对
|
||||
SHA-256 清单。
|
||||
|
||||
## 文件
|
||||
|
||||
- `index.html`:页面结构与简体中文内容
|
||||
- `styles.css`:语义令牌、浅深主题、焦点与响应式布局
|
||||
- `app.js`:主题、移动导航和当前章节
|
||||
- `assets/goodbuddy-light.png`、`assets/goodbuddy-dark.png`:由 `npm run icons` 与桌面应用同步生成的官方品牌图标
|
||||
- `assets/linux-plain.svg`:Devicon v2.17.0 提供的黑白 Linux 图标,许可见 `assets/devicon-LICENSE`
|
||||
- `scripts/validate.mjs`:无依赖静态检查
|
||||
|
||||
+132
@@ -12,6 +12,137 @@
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)");
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const releaseManifestUrl =
|
||||
"https://goodbuddy.oss-cn-hangzhou.aliyuncs.com/releases/latest.json";
|
||||
const releaseFallbackUrl =
|
||||
"https://github.com/mesalogo/goodbuddy/releases/latest";
|
||||
const releaseStatus = document.querySelector("[data-release-status]");
|
||||
const downloadCards = [
|
||||
...document.querySelectorAll("[data-download-card]"),
|
||||
];
|
||||
const platformNames = {
|
||||
windows: "Windows",
|
||||
macos: "macOS",
|
||||
linux: "Linux",
|
||||
};
|
||||
const formatNames = {
|
||||
nsis: "安装版",
|
||||
portable: "便携版",
|
||||
dmg: "DMG",
|
||||
zip: "ZIP",
|
||||
AppImage: "AppImage",
|
||||
deb: "DEB",
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
const megabytes = bytes / (1024 * 1024);
|
||||
return `${megabytes >= 100 ? megabytes.toFixed(0) : megabytes.toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const isTrustedReleaseUrl = (value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname === "goodbuddy.oss-cn-hangzhou.aliyuncs.com" &&
|
||||
url.pathname.startsWith("/releases/")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const configureDownloads = (release) => {
|
||||
if (
|
||||
release?.formatVersion !== 1 ||
|
||||
release?.productName !== "GoodBuddy" ||
|
||||
typeof release?.version !== "string" ||
|
||||
!release?.targets
|
||||
) {
|
||||
throw new Error("发布索引格式无效");
|
||||
}
|
||||
|
||||
const updateCard = (card) => {
|
||||
const platform = card.dataset.downloadCard;
|
||||
const archSelect = card.querySelector("[data-download-arch]");
|
||||
const formatSelect = card.querySelector("[data-download-format]");
|
||||
const link = card.closest(".download-card")?.querySelector("[data-release-link]");
|
||||
const meta = card.closest(".download-card")?.querySelector("[data-download-meta]");
|
||||
if (
|
||||
!platform ||
|
||||
!(archSelect instanceof HTMLSelectElement) ||
|
||||
!(formatSelect instanceof HTMLSelectElement) ||
|
||||
!(link instanceof HTMLAnchorElement) ||
|
||||
!(meta instanceof HTMLElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = release.targets[`${platform}-${archSelect.value}`];
|
||||
const file = target?.files?.[formatSelect.value];
|
||||
if (
|
||||
!file ||
|
||||
typeof file.name !== "string" ||
|
||||
!Number.isSafeInteger(file.size) ||
|
||||
file.size < 1 ||
|
||||
!isTrustedReleaseUrl(file.url)
|
||||
) {
|
||||
link.href = releaseFallbackUrl;
|
||||
link.textContent =
|
||||
`前往 GitHub 下载 ${platformNames[platform] ?? platform} →`;
|
||||
meta.textContent = "当前选项暂不可用,请在 GitHub Release 中选择文件。";
|
||||
return;
|
||||
}
|
||||
|
||||
link.href = file.url;
|
||||
const platformName = platformNames[platform] ?? platform;
|
||||
const archName =
|
||||
platform === "macos" && archSelect.value === "arm64"
|
||||
? "Apple 芯片"
|
||||
: archSelect.value === "arm64"
|
||||
? "ARM64"
|
||||
: "x64";
|
||||
const formatName = formatNames[formatSelect.value] ?? formatSelect.value;
|
||||
link.textContent = `下载 ${platformName} ${archName} ${formatName} →`;
|
||||
meta.textContent =
|
||||
`GoodBuddy ${release.version} · ${formatFileSize(file.size)} · ` +
|
||||
`${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`;
|
||||
};
|
||||
|
||||
for (const card of downloadCards) {
|
||||
const selects = card.querySelectorAll("select");
|
||||
for (const select of selects) {
|
||||
select.addEventListener("change", () => updateCard(card));
|
||||
}
|
||||
updateCard(card);
|
||||
}
|
||||
|
||||
if (releaseStatus instanceof HTMLElement) {
|
||||
releaseStatus.textContent =
|
||||
`官方下载源已就绪:GoodBuddy ${release.version}。` +
|
||||
"请选择处理器和安装包类型。";
|
||||
releaseStatus.classList.add("is-ready");
|
||||
}
|
||||
};
|
||||
|
||||
const loadRelease = async () => {
|
||||
try {
|
||||
const response = await fetch(releaseManifestUrl, {
|
||||
cache: "no-store",
|
||||
credentials: "omit",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`发布索引请求失败:${response.status}`);
|
||||
}
|
||||
configureDownloads(await response.json());
|
||||
} catch {
|
||||
if (releaseStatus instanceof HTMLElement) {
|
||||
releaseStatus.textContent =
|
||||
"官方下载源暂不可用,下载按钮已切换到 GitHub Release。";
|
||||
releaseStatus.classList.add("is-fallback");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getSavedTheme = () => {
|
||||
try {
|
||||
@@ -51,6 +182,7 @@
|
||||
|
||||
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
|
||||
setHeaderState();
|
||||
void loadRelease();
|
||||
|
||||
themeToggle?.addEventListener("click", () => {
|
||||
applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true);
|
||||
|
||||
@@ -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 |
+67
-10
@@ -213,9 +213,9 @@
|
||||
|
||||
<section class="proof-strip" aria-label="平台与 Runtime 支持概览">
|
||||
<div class="section-inner proof-grid">
|
||||
<div><strong>4 类 Runtime</strong><span>多种 AI 编程路径</span></div>
|
||||
<div><strong>3 大系统</strong><span>Windows / macOS / Linux</span></div>
|
||||
<div><strong>2 种架构</strong><span>x64 / arm64</span></div>
|
||||
<div><strong>4 类 Runtime</strong><span>多种 AI 编程路径</span></div>
|
||||
<div><strong>1 个入口</strong><span>选择、配置、运行、审计</span></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -229,14 +229,33 @@
|
||||
<path d="m3 5 8-1v8H3V5Zm10-1.3L21 3v9h-8V3.7ZM3 14h8v8l-8-1v-7Zm10 0h8v9l-8-1v-8Z" />
|
||||
</svg>
|
||||
</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
|
||||
class="button button--download"
|
||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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 class="download-card">
|
||||
<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" />
|
||||
</svg>
|
||||
</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
|
||||
class="button button--download"
|
||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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 class="download-card">
|
||||
<div class="platform-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<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>
|
||||
<img src="./assets/linux-plain.svg" alt="" />
|
||||
</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
|
||||
class="button button--download"
|
||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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>
|
||||
</div>
|
||||
<div class="download-release-status" data-release-status role="status">
|
||||
正在连接官方下载源…
|
||||
</div>
|
||||
|
||||
<aside class="domestic-support" aria-labelledby="domestic-support-title">
|
||||
<div>
|
||||
|
||||
@@ -11,6 +11,8 @@ const requiredFiles = [
|
||||
"app.js",
|
||||
"assets/goodbuddy-light.png",
|
||||
"assets/goodbuddy-dark.png",
|
||||
"assets/linux-plain.svg",
|
||||
"assets/devicon-LICENSE",
|
||||
"README.md",
|
||||
];
|
||||
|
||||
@@ -132,6 +134,31 @@ for (const link of releaseLinks) {
|
||||
report(/target="_blank"/.test(link), `下载入口必须在新窗口打开:${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 duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index);
|
||||
@@ -173,7 +200,7 @@ for (const link of externalBlankLinks) {
|
||||
|
||||
report(
|
||||
!/<a\b[^>]*href="[^"]+\.(?:exe|dmg|zip|AppImage|deb)(?:[?#][^"]*)?"/i.test(html),
|
||||
"具体安装资产链接应由 Release 页面统一提供",
|
||||
"具体安装资产链接应由 OSS 发布索引动态提供",
|
||||
);
|
||||
report(
|
||||
!/(?:react|vue|angular|bootstrap|tailwind)(?:\.min)?\.(?:js|css)/i.test(html),
|
||||
|
||||
+96
-8
@@ -1258,6 +1258,39 @@ p {
|
||||
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 {
|
||||
display: grid;
|
||||
width: 48px;
|
||||
@@ -1278,14 +1311,66 @@ p {
|
||||
stroke-width: 1.3;
|
||||
}
|
||||
|
||||
.download-card:nth-child(3) .platform-icon svg {
|
||||
fill: none;
|
||||
.platform-icon img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .platform-icon img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.button--download {
|
||||
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);
|
||||
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 {
|
||||
@@ -1601,7 +1686,7 @@ p {
|
||||
}
|
||||
|
||||
.download-card {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -1610,9 +1695,8 @@ p {
|
||||
}
|
||||
|
||||
.button--download {
|
||||
grid-column: auto;
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
grid-column: 1 / -1;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.assistant-grid {
|
||||
@@ -1802,9 +1886,13 @@ p {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.download-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.button--download {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.domestic-support {
|
||||
|
||||
@@ -62,19 +62,22 @@ describe('ApplicationSettingsStore', () => {
|
||||
})
|
||||
).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 5,
|
||||
version: 6,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined',
|
||||
@@ -98,6 +101,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
new ApplicationSettingsStore(filePath).get()
|
||||
).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -119,6 +123,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -140,6 +145,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -161,6 +167,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-manual',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -187,8 +194,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
|
||||
).resolves.toBe('0.8.18')
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 5,
|
||||
version: 6,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-manual',
|
||||
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', () => {
|
||||
for (const input of [
|
||||
{},
|
||||
@@ -238,12 +273,28 @@ describe('ApplicationSettingsStore', () => {
|
||||
store.update({ checkUpdatesOnStartup: false })
|
||||
).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
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([
|
||||
'{not-json',
|
||||
JSON.stringify({
|
||||
@@ -335,13 +386,15 @@ describe('ApplicationSettingsStore', () => {
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 5,
|
||||
version: 6,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined',
|
||||
@@ -365,6 +418,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
})
|
||||
).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
|
||||
@@ -20,7 +20,7 @@ export {
|
||||
} 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
|
||||
.object({
|
||||
@@ -47,11 +47,20 @@ const versionThreeStoredApplicationSettingsSchema = z
|
||||
.strict()
|
||||
|
||||
const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
|
||||
.omit({ updateSource: true })
|
||||
.extend({
|
||||
version: z.literal(4)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
|
||||
.omit({ updateSource: true })
|
||||
.extend({
|
||||
version: z.literal(5),
|
||||
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
|
||||
})
|
||||
.strict()
|
||||
|
||||
const storedApplicationSettingsSchema = applicationSettingsSchema
|
||||
.extend({
|
||||
version: z.literal(CURRENT_SETTINGS_VERSION),
|
||||
@@ -65,6 +74,7 @@ type StoredApplicationSettings = z.infer<
|
||||
|
||||
export const defaultApplicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -121,12 +131,23 @@ export class ApplicationSettingsStore {
|
||||
)
|
||||
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
||||
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 =
|
||||
versionFourStoredApplicationSettingsSchema.safeParse(parsed)
|
||||
if (versionFourResult.success) {
|
||||
this.settings = {
|
||||
...versionFourResult.data,
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
updateSource: 'github',
|
||||
lastSeenReleaseNotesVersion: null
|
||||
}
|
||||
return this.settings
|
||||
@@ -137,6 +158,7 @@ export class ApplicationSettingsStore {
|
||||
this.settings = {
|
||||
...versionThreeResult.data,
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
updateSource: 'github',
|
||||
magicNoteCommentFormat: 'combined',
|
||||
lastSeenReleaseNotesVersion: null
|
||||
}
|
||||
@@ -148,6 +170,7 @@ export class ApplicationSettingsStore {
|
||||
this.settings = {
|
||||
...versionTwoResult.data,
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
updateSource: 'github',
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined',
|
||||
lastSeenReleaseNotesVersion: null
|
||||
@@ -161,6 +184,7 @@ export class ApplicationSettingsStore {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
checkUpdatesOnStartup:
|
||||
legacyResult.data.checkUpdatesOnStartup,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined',
|
||||
@@ -200,6 +224,7 @@ export class ApplicationSettingsStore {
|
||||
const stored = await this.loadStored()
|
||||
return {
|
||||
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
|
||||
updateSource: stored.updateSource,
|
||||
magicNotesEnabled: stored.magicNotesEnabled,
|
||||
magicNoteCommentMode: stored.magicNoteCommentMode,
|
||||
magicNoteCommentFormat: stored.magicNoteCommentFormat,
|
||||
@@ -231,6 +256,7 @@ export class ApplicationSettingsStore {
|
||||
this.warnings = []
|
||||
return {
|
||||
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
||||
updateSource: next.updateSource,
|
||||
magicNotesEnabled: next.magicNotesEnabled,
|
||||
magicNoteCommentMode: next.magicNoteCommentMode,
|
||||
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', () => ({
|
||||
app: {
|
||||
getName: vi.fn(() => 'GoodBuddy'),
|
||||
|
||||
+12
-6
@@ -239,7 +239,10 @@ import {
|
||||
SqliteChannelOutbox
|
||||
} from './channels/sqlite-channel-state'
|
||||
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 { SpeechTranscriptionService } from './speech/speech-transcription-service'
|
||||
import { diagnoseEmbeddingProvider } from './knowledge/embedding-index-coordinator'
|
||||
@@ -267,8 +270,6 @@ import {
|
||||
import { AgentEventBuffer } from './agent-event-buffer'
|
||||
|
||||
const requestIdSchema = z.string().uuid()
|
||||
const GOODBUDDY_RELEASES_URL =
|
||||
'https://github.com/mesalogo/goodbuddy/releases'
|
||||
const runtimeConfigFileMetadata = {
|
||||
opencode: {
|
||||
filterName: 'OpenCode 配置',
|
||||
@@ -3692,10 +3693,11 @@ export function registerIpcHandlers(
|
||||
|
||||
registerHandler(ipcChannels.versionCheck, async (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
if (!versionChecker) {
|
||||
if (!versionChecker || !applicationSettingsStore) {
|
||||
throw new Error('版本检查服务不可用')
|
||||
}
|
||||
const result = await versionChecker.check()
|
||||
const { updateSource } = await applicationSettingsStore.get()
|
||||
const result = await versionChecker.check(updateSource)
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send(ipcChannels.versionCheckResult, result)
|
||||
}
|
||||
@@ -3704,7 +3706,11 @@ export function registerIpcHandlers(
|
||||
|
||||
registerHandler(ipcChannels.versionOpenReleasePage, async (event) => {
|
||||
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) => {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
checkMirrorForUpdates,
|
||||
checkForUpdates,
|
||||
compareStrictSemVer,
|
||||
GOODBUDDY_LATEST_RELEASE_API_URL
|
||||
getUpdateDownloadPage,
|
||||
GOODBUDDY_LATEST_RELEASE_API_URL,
|
||||
GOODBUDDY_MIRROR_RELEASE_INDEX_URL,
|
||||
VersionChecker
|
||||
} from './version-checker'
|
||||
|
||||
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', () => {
|
||||
it('implements SemVer precedence without treating build metadata as newer', () => {
|
||||
expect(compareStrictSemVer('1.0.0-alpha.2', '1.0.0-alpha.10')).toBe(-1)
|
||||
@@ -474,3 +565,111 @@ describe('checkForUpdates', () => {
|
||||
).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 type {
|
||||
UpdateSource,
|
||||
VersionCheckFile,
|
||||
VersionCheckResult,
|
||||
VersionCheckTarget
|
||||
@@ -11,10 +12,14 @@ export type {
|
||||
|
||||
export const GOODBUDDY_LATEST_RELEASE_API_URL =
|
||||
'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 RELEASE_WEB_ROOT =
|
||||
'https://github.com/mesalogo/goodbuddy/releases'
|
||||
const MIRROR_DOWNLOAD_PAGE =
|
||||
'https://mesalogo.github.io/goodbuddy/#download'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const DEFAULT_MAX_JSON_BYTES = 512 * 1024
|
||||
const MAX_TIMEOUT_MS = 60_000
|
||||
@@ -93,6 +98,37 @@ const aggregateReleaseManifestSchema = z
|
||||
})
|
||||
.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 = {
|
||||
major: 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(
|
||||
manifest: z.infer<typeof aggregateReleaseManifestSchema>,
|
||||
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(
|
||||
dependencies: VersionCheckerDependencies
|
||||
): 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 {
|
||||
constructor(private readonly dependencies: VersionCheckerDependencies) {}
|
||||
|
||||
check(): Promise<VersionCheckResult> {
|
||||
return checkForUpdates(this.dependencies)
|
||||
check(source: UpdateSource = 'github'): Promise<VersionCheckResult> {
|
||||
return source === 'mirror'
|
||||
? checkMirrorForUpdates(this.dependencies)
|
||||
: checkForUpdates(this.dependencies)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1082,6 +1082,7 @@ describe('App', () => {
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1173,12 +1174,14 @@ describe('App', () => {
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
})),
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1271,12 +1274,14 @@ describe('App', () => {
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
})),
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7062,12 +7067,14 @@ describe('App', () => {
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
})),
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7107,12 +7114,14 @@ describe('App', () => {
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
})),
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7144,6 +7153,7 @@ describe('App', () => {
|
||||
it('keeps platform-feature switches in Settings without navigating', async () => {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
|
||||
@@ -181,7 +181,11 @@ import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts'
|
||||
import { ReleaseNotesDialog } from './ReleaseNotesDialog'
|
||||
import { scheduleIdleRoutePreload } from './idle-route-preload'
|
||||
import { createPreloadableComponent } from './preloadable-component'
|
||||
import { formatTime, type TimeFormatLocale } from './time-format'
|
||||
import {
|
||||
formatConversationListTime,
|
||||
type TimeFormatLocale
|
||||
} from './time-format'
|
||||
import { formatMediumDateTime } from './locale-formatters'
|
||||
import { formatCompactTokens } from './token-format'
|
||||
import {
|
||||
pruneKeepAliveEntries,
|
||||
@@ -6464,7 +6468,22 @@ function App(): React.JSX.Element {
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<small>{formatTime(conversation.updatedAt, locale)}</small>
|
||||
<small>
|
||||
<time
|
||||
dateTime={new Date(
|
||||
conversation.updatedAt
|
||||
).toISOString()}
|
||||
title={formatMediumDateTime(
|
||||
conversation.updatedAt,
|
||||
locale
|
||||
)}
|
||||
>
|
||||
{formatConversationListTime(
|
||||
conversation.updatedAt,
|
||||
locale
|
||||
)}
|
||||
</time>
|
||||
</small>
|
||||
</button>
|
||||
{activeConversationIds.has(conversation.id) && (
|
||||
<span
|
||||
|
||||
@@ -168,6 +168,7 @@ const onAnalysisEvent = vi.fn<
|
||||
})
|
||||
const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -178,6 +179,7 @@ beforeEach(() => {
|
||||
analysisEventListener = undefined
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -677,6 +679,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
it('reuses the AI comments pane for selected todos', async () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-manual',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -707,6 +710,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
it('streams with snapshotted sidebar options while later changes stay local', async () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-manual',
|
||||
magicNoteCommentFormat: 'narrative'
|
||||
@@ -820,6 +824,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
it('automatically comments on a newly saved record in auto mode', async () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-auto',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
|
||||
@@ -400,6 +400,7 @@ const diagnoseEmbedding = vi.fn(
|
||||
)
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -565,6 +566,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
await changeUiLocale('zh-CN')
|
||||
applicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
waitFor
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
import { UpdateSettingsSection } from './UpdateSettingsSection'
|
||||
|
||||
@@ -16,17 +17,22 @@ afterEach(() => {
|
||||
|
||||
describe('UpdateSettingsSection', () => {
|
||||
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<
|
||||
NonNullable<DesktopApi['updates']>['updateSettings']
|
||||
>(async (input) => ({
|
||||
checkUpdatesOnStartup:
|
||||
input.checkUpdatesOnStartup ?? true,
|
||||
magicNotesEnabled: input.magicNotesEnabled ?? true,
|
||||
magicNoteCommentMode:
|
||||
input.magicNoteCommentMode ?? 'immediate',
|
||||
magicNoteCommentFormat:
|
||||
input.magicNoteCommentFormat ?? 'combined'
|
||||
}))
|
||||
>(async (input) => {
|
||||
applicationSettings = {
|
||||
...applicationSettings,
|
||||
...input
|
||||
}
|
||||
return applicationSettings
|
||||
})
|
||||
const check = vi.fn<
|
||||
NonNullable<DesktopApi['updates']>['check']
|
||||
>(async () => ({
|
||||
@@ -62,10 +68,7 @@ describe('UpdateSettingsSection', () => {
|
||||
},
|
||||
updates: {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
...applicationSettings
|
||||
})),
|
||||
updateSettings,
|
||||
check,
|
||||
@@ -80,12 +83,36 @@ describe('UpdateSettingsSection', () => {
|
||||
name: '启动时检查新版本'
|
||||
})
|
||||
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)
|
||||
await waitFor(() =>
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
checkUpdatesOnStartup: false
|
||||
})
|
||||
)
|
||||
expect(source).toBeDisabled()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '立即检查更新' })
|
||||
@@ -113,12 +140,14 @@ describe('UpdateSettingsSection', () => {
|
||||
updates: {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})),
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -141,7 +170,7 @@ describe('UpdateSettingsSection', () => {
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent(
|
||||
'版本检查失败:无法连接 GoodBuddy 官方 GitHub Release,请检查网络或代理后重试'
|
||||
'版本检查失败:无法连接更新源“GitHub”,请检查网络或代理后重试'
|
||||
)
|
||||
expect(alert).not.toHaveTextContent('Error invoking remote method')
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
UpdateSource,
|
||||
VersionCheckResult
|
||||
} from '../../shared/application-settings-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 updates = window.goodbuddy.updates
|
||||
if (!updates) {
|
||||
@@ -125,7 +154,12 @@ export function UpdateSettingsSection(): React.JSX.Element {
|
||||
updateErrorMessage(
|
||||
reason,
|
||||
fallback,
|
||||
t('updates.errors.network', { fallback })
|
||||
t('updates.errors.sourceNetwork', {
|
||||
fallback,
|
||||
source: t(
|
||||
`updates.source.names.${settings?.updateSource ?? 'github'}`
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
@@ -171,6 +205,33 @@ export function UpdateSettingsSection(): React.JSX.Element {
|
||||
<span>{t('updates.checkOnStartup')}</span>
|
||||
</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">
|
||||
<button
|
||||
className="secondary-button"
|
||||
|
||||
@@ -84,7 +84,7 @@ export const settings = {
|
||||
label: 'About and updates',
|
||||
navigationDescription: 'Version checks and downloads',
|
||||
description:
|
||||
'Checks only the official GoodBuddy GitHub Release and never installs automatically'
|
||||
'Checks the selected official update source and never installs automatically'
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
|
||||
@@ -285,16 +285,31 @@ export const settingsSections = {
|
||||
'Version checks are not available in this version',
|
||||
readSettingsFailed: 'Could not load application settings',
|
||||
saveSettingsFailed: 'Could not save update settings',
|
||||
saveSourceFailed: 'Could not save the update source',
|
||||
checkFailed: 'Version check failed',
|
||||
network:
|
||||
'{{fallback}}: Could not connect to the official GoodBuddy GitHub Release. Check your network or proxy and try again.'
|
||||
network: '{{fallback}}. Check the system status and try again.',
|
||||
sourceNetwork:
|
||||
'{{fallback}}: Could not connect to update source "{{source}}". Check your network or proxy and try again.'
|
||||
},
|
||||
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',
|
||||
actions: {
|
||||
checking: 'Checking…',
|
||||
checkNow: 'Check for updates now',
|
||||
openDownloadPage: 'Open official download page'
|
||||
openDownloadPage: 'Open download page'
|
||||
},
|
||||
result: {
|
||||
available: 'New version {{version}} is available',
|
||||
|
||||
@@ -71,7 +71,7 @@ export const settings = {
|
||||
about: {
|
||||
label: '关于与更新',
|
||||
navigationDescription: '版本检查与下载页',
|
||||
description: '只检查 GoodBuddy 官方 GitHub Release,不自动下载安装'
|
||||
description: '检查所选官方更新源,不自动下载安装'
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
|
||||
@@ -266,16 +266,30 @@ export const settingsSections = {
|
||||
serviceUnavailable: '当前版本未提供版本检查服务',
|
||||
readSettingsFailed: '读取应用设置失败',
|
||||
saveSettingsFailed: '保存更新设置失败',
|
||||
saveSourceFailed: '保存检查更新源失败',
|
||||
checkFailed: '版本检查失败',
|
||||
network:
|
||||
'{{fallback}}:无法连接 GoodBuddy 官方 GitHub Release,请检查网络或代理后重试'
|
||||
network: '{{fallback}}:请检查系统状态后重试',
|
||||
sourceNetwork:
|
||||
'{{fallback}}:无法连接更新源“{{source}}”,请检查网络或代理后重试'
|
||||
},
|
||||
loadingAppInfo: '正在读取应用信息…',
|
||||
source: {
|
||||
label: '检查更新源',
|
||||
description: '用于手动检查、启动时检查和打开下载页。',
|
||||
options: {
|
||||
github: 'GitHub(默认)',
|
||||
mirror: '镜像节点'
|
||||
},
|
||||
names: {
|
||||
github: 'GitHub',
|
||||
mirror: '镜像节点'
|
||||
}
|
||||
},
|
||||
checkOnStartup: '启动时检查新版本',
|
||||
actions: {
|
||||
checking: '正在检查…',
|
||||
checkNow: '立即检查更新',
|
||||
openDownloadPage: '打开官方下载页'
|
||||
openDownloadPage: '打开下载页'
|
||||
},
|
||||
result: {
|
||||
available: '发现新版本 {{version}}',
|
||||
|
||||
@@ -6297,6 +6297,39 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -6359,6 +6392,12 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
@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 {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { formatTime } from './time-format'
|
||||
import { formatConversationListTime, formatTime } from './time-format'
|
||||
|
||||
describe('formatTime', () => {
|
||||
afterEach(() => {
|
||||
@@ -27,4 +27,47 @@ describe('formatTime', () => {
|
||||
expect(secondChinese).not.toBe('')
|
||||
expect(formatter).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('shows the time for a conversation updated today', () => {
|
||||
const timestamp = new Date(2026, 7, 17, 9, 5).getTime()
|
||||
const referenceTimestamp = new Date(2026, 7, 17, 23, 30).getTime()
|
||||
|
||||
expect(
|
||||
formatConversationListTime(timestamp, 'zh-CN', referenceTimestamp)
|
||||
).toBe(
|
||||
new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(timestamp)
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the date after the local calendar day changes', () => {
|
||||
const timestamp = new Date(2026, 7, 16, 23, 30).getTime()
|
||||
const referenceTimestamp = new Date(2026, 7, 17, 0, 15).getTime()
|
||||
|
||||
expect(
|
||||
formatConversationListTime(timestamp, 'zh-CN', referenceTimestamp)
|
||||
).toBe(
|
||||
new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(timestamp)
|
||||
)
|
||||
})
|
||||
|
||||
it('includes the year for a conversation from another year', () => {
|
||||
const timestamp = new Date(2025, 11, 31, 23, 30).getTime()
|
||||
const referenceTimestamp = new Date(2026, 0, 1, 0, 15).getTime()
|
||||
|
||||
expect(
|
||||
formatConversationListTime(timestamp, 'en-US', referenceTimestamp)
|
||||
).toBe(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(timestamp)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,14 @@ const timeFormatters: Partial<
|
||||
Record<TimeFormatLocale, Intl.DateTimeFormat>
|
||||
> = {}
|
||||
|
||||
const conversationDateFormatters: Partial<
|
||||
Record<TimeFormatLocale, Intl.DateTimeFormat>
|
||||
> = {}
|
||||
|
||||
const conversationDateWithYearFormatters: Partial<
|
||||
Record<TimeFormatLocale, Intl.DateTimeFormat>
|
||||
> = {}
|
||||
|
||||
export function formatTime(
|
||||
timestamp: number,
|
||||
locale: TimeFormatLocale
|
||||
@@ -16,3 +24,43 @@ export function formatTime(
|
||||
}))
|
||||
return formatter.format(timestamp)
|
||||
}
|
||||
|
||||
export function formatConversationListTime(
|
||||
timestamp: number,
|
||||
locale: TimeFormatLocale,
|
||||
referenceTimestamp = Date.now()
|
||||
): string {
|
||||
const date = new Date(timestamp)
|
||||
const referenceDate = new Date(referenceTimestamp)
|
||||
const isSameYear = date.getFullYear() === referenceDate.getFullYear()
|
||||
const isToday =
|
||||
isSameYear &&
|
||||
date.getMonth() === referenceDate.getMonth() &&
|
||||
date.getDate() === referenceDate.getDate()
|
||||
|
||||
if (isToday) {
|
||||
return formatTime(timestamp, locale)
|
||||
}
|
||||
|
||||
if (isSameYear) {
|
||||
const formatter =
|
||||
conversationDateFormatters[locale] ??
|
||||
(conversationDateFormatters[locale] = new Intl.DateTimeFormat(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}))
|
||||
return formatter.format(timestamp)
|
||||
}
|
||||
|
||||
const formatter =
|
||||
conversationDateWithYearFormatters[locale] ??
|
||||
(conversationDateWithYearFormatters[locale] = new Intl.DateTimeFormat(
|
||||
locale,
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}
|
||||
))
|
||||
return formatter.format(timestamp)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,16 @@ export type MagicNoteCommentMode = z.infer<
|
||||
typeof magicNoteCommentModeSchema
|
||||
>
|
||||
|
||||
export const updateSourceSchema = z.enum([
|
||||
'github',
|
||||
'mirror'
|
||||
])
|
||||
export type UpdateSource = z.infer<typeof updateSourceSchema>
|
||||
|
||||
const applicationPreferencesSchema = z
|
||||
.object({
|
||||
checkUpdatesOnStartup: z.boolean(),
|
||||
updateSource: updateSourceSchema,
|
||||
magicNotesEnabled: z.boolean(),
|
||||
magicNoteCommentMode: magicNoteCommentModeSchema,
|
||||
magicNoteCommentFormat: magicNoteCommentFormatSchema
|
||||
|
||||
@@ -133,6 +133,7 @@ describe('GoodBuddy configuration contracts', () => {
|
||||
const snapshot = {
|
||||
application: {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
interface ReleaseFile {
|
||||
name: string
|
||||
size: number
|
||||
sha256: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface SiteReleaseModule {
|
||||
parseArguments: (argv: string[]) => {
|
||||
manifest: string
|
||||
baseUrl: string
|
||||
output: string
|
||||
}
|
||||
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('parses the CLI options used by the release workflow', () => {
|
||||
expect(
|
||||
siteRelease.parseArguments([
|
||||
'--manifest',
|
||||
'dist/release-upload/release-manifest.json',
|
||||
'--base-url',
|
||||
'https://example.com/releases/v1.2.3/',
|
||||
'--output',
|
||||
'dist/site-release.json'
|
||||
])
|
||||
).toEqual({
|
||||
manifest: resolve('dist/release-upload/release-manifest.json'),
|
||||
baseUrl: 'https://example.com/releases/v1.2.3/',
|
||||
output: resolve('dist/site-release.json')
|
||||
})
|
||||
})
|
||||
|
||||
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