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
+57 -3
View File
@@ -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'
+27 -1
View File
@@ -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
+96
View File
@@ -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
View File
@@ -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) => {
+200 -1
View File
@@ -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
View File
@@ -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)
}
}
+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;
@@ -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'