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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user