Files
goodbuddy/src/main/version-checker.ts
T
mesalogo 60119a4317
Deploy website to GitHub Pages / Deploy static website (push) Waiting to run
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / linux arm64 (push) Blocked by required conditions
Cross-platform packages / macos arm64 (push) Blocked by required conditions
Cross-platform packages / windows arm64 (push) Blocked by required conditions
Cross-platform packages / linux x64 (push) Blocked by required conditions
Cross-platform packages / macos x64 (push) Blocked by required conditions
Cross-platform packages / windows x64 (push) Blocked by required conditions
Cross-platform packages / Publish GitHub and OSS release (push) Blocked by required conditions
fix: align mirror publishing region
The v0.10.3 release index was generated, but the pinned upload client rejected object uploads because V4 signing did not receive a region. The application and website also addressed the Beijing bucket through the previous Hangzhou endpoint.

Derive and pass the endpoint region to every upload, pin workflow configuration to the trusted production bucket, and align application and website URL validation with its actual endpoint. Advance recovery metadata to 0.10.4 and remove the unpublished 0.10.3 note.

Release note: 0.10.4 carries forward the approved mirror-node and conversation timestamp improvements from the unpublished candidates.
2026-08-17 22:17:43 +08:00

727 lines
20 KiB
TypeScript

import { z } from 'zod'
import type {
UpdateSource,
VersionCheckFile,
VersionCheckResult,
VersionCheckTarget
} from '../shared/application-settings-contracts'
export type {
VersionCheckResult,
VersionCheckTarget
} from '../shared/application-settings-contracts'
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-beijing.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
const MAX_JSON_BYTES = 2 * 1024 * 1024
const MAX_REDIRECTS = 3
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
const ALLOWED_RELEASE_HOSTS = new Set([
'api.github.com',
'github.com',
'release-assets.githubusercontent.com'
])
const semVerPattern =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+((?:[0-9a-zA-Z-]+)(?:\.[0-9a-zA-Z-]+)*))?$/u
const sha256Pattern = /^[a-f0-9]{64}$/u
const safeFileNamePattern = /^(?!\.{1,2}$)[^/\\\0]+$/u
const releaseAssetSchema = z.object({
name: z.string().min(1).max(255),
url: z.url().max(2_048),
browser_download_url: z.url().max(2_048)
})
const githubReleaseSchema = z.object({
tag_name: z.string().min(2).max(256),
draft: z.boolean(),
prerelease: z.boolean(),
assets: z.array(releaseAssetSchema).max(256)
})
const releaseFileSchema = z
.object({
name: z.string().min(1).max(255).regex(safeFileNamePattern),
size: z.number().int().positive().safe(),
sha256: z.string().regex(sha256Pattern)
})
.strict()
const platformSchema = z.enum(['windows', 'macos', 'linux'])
const architectureSchema = z.enum(['x64', 'arm64'])
const formatSchema = z.enum([
'nsis',
'portable',
'dmg',
'zip',
'AppImage',
'deb'
])
const releaseTargetSchema = z
.object({
platform: platformSchema,
arch: architectureSchema,
formats: z.array(formatSchema).min(1).max(8),
manifest: z
.string()
.min(1)
.max(255)
.regex(safeFileNamePattern),
files: z.array(releaseFileSchema).min(1).max(16)
})
.strict()
const aggregateFileSchema = releaseFileSchema.extend({
platform: platformSchema,
arch: architectureSchema
})
const aggregateReleaseManifestSchema = z
.object({
formatVersion: z.literal(1),
productName: z.literal(PRODUCT_NAME),
version: z.string().min(1).max(256),
targets: z.array(releaseTargetSchema).min(1).max(6),
files: z.array(aggregateFileSchema).min(1).max(96)
})
.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
patch: bigint
prerelease: string[]
}
export type ReleaseFile = VersionCheckFile
export type ReleasePlatform = z.infer<typeof platformSchema>
export type ReleaseArchitecture = z.infer<typeof architectureSchema>
export type VersionCheckerDependencies = {
fetch: typeof fetch
currentVersion: string
platform: NodeJS.Platform
arch: string
timeoutMs?: number
maxJsonBytes?: number
}
function boundedInteger(
value: number,
field: string,
minimum: number,
maximum: number
): number {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new RangeError(
`${field} must be an integer between ${minimum} and ${maximum}`
)
}
return value
}
function parseSemVer(version: string): ParsedSemVer {
const match = semVerPattern.exec(version)
if (!match) {
throw new Error(`Invalid semantic version: ${version}`)
}
return {
major: BigInt(match[1] ?? ''),
minor: BigInt(match[2] ?? ''),
patch: BigInt(match[3] ?? ''),
prerelease: match[4]?.split('.') ?? []
}
}
function compareIdentifiers(left: string, right: string): number {
const leftNumeric = /^\d+$/u.test(left)
const rightNumeric = /^\d+$/u.test(right)
if (leftNumeric && rightNumeric) {
const leftNumber = BigInt(left)
const rightNumber = BigInt(right)
return leftNumber < rightNumber ? -1 : leftNumber > rightNumber ? 1 : 0
}
if (leftNumeric !== rightNumeric) {
return leftNumeric ? -1 : 1
}
return left < right ? -1 : left > right ? 1 : 0
}
export function compareStrictSemVer(left: string, right: string): number {
const parsedLeft = parseSemVer(left)
const parsedRight = parseSemVer(right)
for (const field of ['major', 'minor', 'patch'] as const) {
if (parsedLeft[field] < parsedRight[field]) {
return -1
}
if (parsedLeft[field] > parsedRight[field]) {
return 1
}
}
if (
parsedLeft.prerelease.length === 0 ||
parsedRight.prerelease.length === 0
) {
return parsedLeft.prerelease.length === parsedRight.prerelease.length
? 0
: parsedLeft.prerelease.length === 0
? 1
: -1
}
const identifierCount = Math.max(
parsedLeft.prerelease.length,
parsedRight.prerelease.length
)
for (let index = 0; index < identifierCount; index += 1) {
const leftIdentifier = parsedLeft.prerelease[index]
const rightIdentifier = parsedRight.prerelease[index]
if (leftIdentifier === undefined || rightIdentifier === undefined) {
return leftIdentifier === undefined ? -1 : 1
}
const comparison = compareIdentifiers(leftIdentifier, rightIdentifier)
if (comparison !== 0) {
return comparison
}
}
return 0
}
async function readBoundedJson(
response: Response,
maximumBytes: number,
signal: AbortSignal
): Promise<unknown> {
const declaredLength = response.headers.get('content-length')
if (declaredLength !== null) {
const parsedLength = Number(declaredLength)
if (
!Number.isSafeInteger(parsedLength) ||
parsedLength < 0 ||
parsedLength > maximumBytes
) {
throw new RangeError('Version check response is too large')
}
}
if (!response.body) {
throw new Error('Version check response has no body')
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let length = 0
let rejectForAbort: ((reason: DOMException) => void) | undefined
const abortPromise = new Promise<never>((_resolve, reject) => {
rejectForAbort = reject
})
const abort = (): void => {
rejectForAbort?.(
new DOMException('The operation was aborted', 'AbortError')
)
}
if (signal.aborted) {
abort()
} else {
signal.addEventListener('abort', abort, { once: true })
}
try {
while (true) {
const result = await Promise.race([reader.read(), abortPromise])
if (result.done) {
break
}
length += result.value.byteLength
if (length > maximumBytes) {
await reader.cancel()
throw new RangeError('Version check response is too large')
}
chunks.push(result.value)
}
} catch (error) {
void reader.cancel().catch(() => undefined)
throw error
} finally {
signal.removeEventListener('abort', abort)
}
const bytes = new Uint8Array(length)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}
try {
return JSON.parse(new TextDecoder().decode(bytes)) as unknown
} catch {
throw new Error('Version check response is not valid JSON')
}
}
function normalizePlatform(platform: NodeJS.Platform): ReleasePlatform {
if (platform === 'win32') {
return 'windows'
}
if (platform === 'darwin') {
return 'macos'
}
if (platform === 'linux') {
return 'linux'
}
throw new Error(`Unsupported update platform: ${platform}`)
}
function normalizeArchitecture(arch: string): ReleaseArchitecture {
const parsed = architectureSchema.safeParse(arch)
if (!parsed.success) {
throw new Error(`Unsupported update architecture: ${arch}`)
}
return parsed.data
}
function isCanonicalReleaseAssetApiUrl(value: string): boolean {
const url = new URL(value)
return (
url.protocol === 'https:' &&
url.hostname === 'api.github.com' &&
!url.username &&
!url.password &&
!url.search &&
!url.hash &&
/^\/repos\/mesalogo\/goodbuddy\/releases\/assets\/[1-9]\d*$/u.test(
url.pathname
)
)
}
const expectedFormats: Record<ReleasePlatform, string[]> = {
windows: ['nsis', 'portable'],
macos: ['dmg', 'zip'],
linux: ['AppImage', 'deb']
}
function hasExpectedFileFormats(
platform: ReleasePlatform,
files: ReleaseFile[]
): boolean {
if (platform === 'windows') {
return (
files.filter((file) => /-setup\.exe$/u.test(file.name)).length === 1 &&
files.filter((file) => /-portable\.zip$/u.test(file.name)).length === 1
)
}
const extensions =
platform === 'macos' ? ['.dmg', '.zip'] : ['.AppImage', '.deb']
return extensions.every(
(extension) =>
files.filter((file) => file.name.endsWith(extension)).length === 1
)
}
function sameFile(left: ReleaseFile, right: ReleaseFile): boolean {
return (
left.name === right.name &&
left.size === right.size &&
left.sha256 === right.sha256
)
}
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,
arch: ReleaseArchitecture
): VersionCheckTarget {
const targets = manifest.targets.filter(
(target) => target.platform === platform && target.arch === arch
)
if (targets.length !== 1) {
throw new Error(
`Release manifest must contain exactly one ${platform}/${arch} target`
)
}
const target = targets[0]
if (!target) {
throw new Error('Release manifest target is missing')
}
const formats = expectedFormats[platform]
if (
target.formats.length !== formats.length ||
!formats.every((format, index) => target.formats[index] === format) ||
target.manifest !== `release-manifest-${platform}-${arch}.json` ||
target.files.length !== formats.length ||
!hasExpectedFileFormats(platform, target.files) ||
new Set(target.files.map((file) => file.name)).size !== target.files.length
) {
throw new Error(`Release manifest target is invalid: ${platform}/${arch}`)
}
const aggregateFiles = manifest.files.filter(
(file) => file.platform === platform && file.arch === arch
)
if (
aggregateFiles.length !== target.files.length ||
!target.files.every((file) =>
aggregateFiles.some((candidate) => sameFile(file, candidate))
)
) {
throw new Error(
`Release manifest file index does not match target: ${platform}/${arch}`
)
}
return {
platform,
arch,
formats: [...target.formats],
files: target.files.map((file) => ({ ...file }))
}
}
async function fetchJson(
transport: typeof fetch,
url: string,
signal: AbortSignal,
maximumBytes: number,
accept: string
): Promise<unknown> {
let currentUrl = new URL(url)
for (let redirectCount = 0; ; redirectCount += 1) {
const response = await transport(currentUrl, {
method: 'GET',
headers: {
Accept: accept,
'User-Agent': 'GoodBuddy-Version-Checker'
},
cache: 'no-store',
credentials: 'omit',
redirect: 'manual',
referrerPolicy: 'no-referrer',
signal
})
if (REDIRECT_STATUSES.has(response.status)) {
const location = response.headers.get('location')
if (!location || redirectCount >= MAX_REDIRECTS) {
throw new Error('GitHub release redirect is invalid or excessive')
}
const target = new URL(location, currentUrl)
if (
target.protocol !== 'https:' ||
target.username ||
target.password ||
target.hash ||
target.href.length > 8_192 ||
!ALLOWED_RELEASE_HOSTS.has(target.hostname.toLowerCase())
) {
throw new Error('GitHub release redirect target is not trusted')
}
currentUrl = target
continue
}
if (!response.ok) {
throw new Error(
`Version check request failed with HTTP ${response.status}`
)
}
return readBoundedJson(response, maximumBytes, signal)
}
}
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> {
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 releasePayload = await fetchJson(
dependencies.fetch,
GOODBUDDY_LATEST_RELEASE_API_URL,
controller.signal,
maximumBytes,
'application/vnd.github+json'
)
const release = githubReleaseSchema.parse(releasePayload)
if (release.draft || release.prerelease) {
throw new Error('GitHub latest release is not a stable published release')
}
if (!release.tag_name.startsWith('v')) {
throw new Error('GitHub release tag must start with v')
}
const latestVersion = release.tag_name.slice(1)
parseSemVer(latestVersion)
const manifestUrl =
`${RELEASE_WEB_ROOT}/download/v${latestVersion}/release-manifest.json`
const manifests = release.assets.filter(
(asset) => asset.name === 'release-manifest.json'
)
if (
manifests.length !== 1 ||
manifests[0]?.browser_download_url !== manifestUrl ||
!isCanonicalReleaseAssetApiUrl(manifests[0].url)
) {
throw new Error(
'GitHub release does not contain the canonical aggregate manifest'
)
}
const manifestPayload = await fetchJson(
dependencies.fetch,
manifests[0].url,
controller.signal,
maximumBytes,
'application/octet-stream'
)
const manifest = aggregateReleaseManifestSchema.parse(manifestPayload)
if (manifest.version !== latestVersion) {
throw new Error('Release manifest version does not match the release tag')
}
const target = validateCurrentTarget(manifest, platform, arch)
return {
updateAvailable:
compareStrictSemVer(latestVersion, dependencies.currentVersion) > 0,
currentVersion: dependencies.currentVersion,
latestVersion,
releaseUrl: `${RELEASE_WEB_ROOT}/tag/v${latestVersion}`,
target
}
} finally {
clearTimeout(timeout)
}
}
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(source: UpdateSource = 'github'): Promise<VersionCheckResult> {
return source === 'mirror'
? checkMirrorForUpdates(this.dependencies)
: checkForUpdates(this.dependencies)
}
}