feat: add trusted mirror update source

Version checks and downloads previously depended on GitHub Release. About & Updates now offers GitHub or a validated mirror source, keeps the selector beneath the startup-check switch, and disables it when startup checks are off.

The website resolves platform downloads from a bounded OSS release index with a GitHub fallback. Tagged releases publish and verify immutable OSS assets through OIDC before switching the latest-version index; deployment requires the configured Alibaba Cloud environment variables and role.

Release note: “关于与更新”新增 GitHub 与镜像节点选择,启动检查、手动检查和下载页使用同一可信来源;官网下载也可按系统、架构和安装包类型直接选择。
This commit is contained in:
mesalogo
2026-08-17 17:20:24 +08:00
parent ce15e7021c
commit dc8f86ff3e
32 changed files with 1680 additions and 66 deletions
+10
View File
@@ -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'
@@ -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'
+2
View File
@@ -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'
+43 -14
View File
@@ -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')
})
+62 -1
View File
@@ -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}}',
+39
View File
@@ -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;