From c7ca0de0c3664f5ae0a775bd0221c8a28173b130 Mon Sep 17 00:00:00 2001 From: PiEgg Date: Tue, 3 Mar 2026 16:03:29 +0800 Subject: [PATCH] :bug: Fix(update): correct latest version lookup with beta channel (#1396) (#1397) --- src/__tests__/main/getLatestVersion.spec.ts | 108 +++++++++++++++++ src/main/utils/getLatestVersion.ts | 121 ++++++++++++++++---- 2 files changed, 206 insertions(+), 23 deletions(-) create mode 100644 src/__tests__/main/getLatestVersion.spec.ts diff --git a/src/__tests__/main/getLatestVersion.spec.ts b/src/__tests__/main/getLatestVersion.spec.ts new file mode 100644 index 0000000..0566465 --- /dev/null +++ b/src/__tests__/main/getLatestVersion.spec.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RELEASE_URL, RELEASE_URL_BACKUP } from '#/utils/static' +import { getLatestVersion } from '~/main/utils/getLatestVersion' + +const { axiosGetMock } = vi.hoisted(() => { + return { + axiosGetMock: vi.fn() + } +}) + +vi.mock('axios', () => { + return { + default: { + get: axiosGetMock + } + } +}) + +describe('main/utils/getLatestVersion', () => { + beforeEach(() => { + axiosGetMock.mockReset() + }) + + it('returns stable release when beta channel is older', async () => { + axiosGetMock.mockResolvedValueOnce({ + data: [ + { tag_name: 'v2.5.2', prerelease: false, draft: false }, + { tag_name: 'v2.4.2-beta.0', prerelease: true, draft: false } + ] + }) + + const version = await getLatestVersion(true) + + expect(version).toBe('2.5.2') + expect(axiosGetMock).toHaveBeenCalledWith(RELEASE_URL, { + headers: { + Referer: 'https://github.com' + } + }) + }) + + it('returns prerelease when beta channel has a newer version', async () => { + axiosGetMock.mockResolvedValueOnce({ + data: [ + { tag_name: 'v2.5.2', prerelease: false, draft: false }, + { tag_name: 'v2.6.0-beta.1', prerelease: true, draft: false } + ] + }) + + const version = await getLatestVersion(true) + + expect(version).toBe('2.6.0-beta.1') + }) + + it('ignores prerelease when beta updates are disabled', async () => { + axiosGetMock.mockResolvedValueOnce({ + data: [ + { tag_name: 'v2.6.0-beta.1', prerelease: true, draft: false }, + { tag_name: 'v2.5.2', prerelease: false, draft: false } + ] + }) + + const version = await getLatestVersion(false) + + expect(version).toBe('2.5.2') + }) + + it('fallback compares stable and beta backup metadata when beta updates are enabled', async () => { + axiosGetMock.mockRejectedValueOnce(new Error('network down')) + axiosGetMock.mockResolvedValueOnce({ + data: 'version: 2.5.2' + }) + axiosGetMock.mockResolvedValueOnce({ + data: 'version: 2.4.2-beta.0' + }) + + const version = await getLatestVersion(true) + + expect(version).toBe('2.5.2') + expect(axiosGetMock).toHaveBeenNthCalledWith(2, `${RELEASE_URL_BACKUP}/latest.yml`, { + headers: { + Referer: 'https://github.com' + } + }) + expect(axiosGetMock).toHaveBeenNthCalledWith(3, `${RELEASE_URL_BACKUP}/latest.beta.yml`, { + headers: { + Referer: 'https://github.com' + } + }) + }) + + it('fallback uses only stable backup metadata when beta updates are disabled', async () => { + axiosGetMock.mockRejectedValueOnce(new Error('network down')) + axiosGetMock.mockResolvedValueOnce({ + data: 'version: 2.5.2' + }) + + const version = await getLatestVersion(false) + + expect(version).toBe('2.5.2') + expect(axiosGetMock).toHaveBeenCalledTimes(2) + expect(axiosGetMock).toHaveBeenNthCalledWith(2, `${RELEASE_URL_BACKUP}/latest.yml`, { + headers: { + Referer: 'https://github.com' + } + }) + }) +}) diff --git a/src/main/utils/getLatestVersion.ts b/src/main/utils/getLatestVersion.ts index 8991896..1e76760 100644 --- a/src/main/utils/getLatestVersion.ts +++ b/src/main/utils/getLatestVersion.ts @@ -1,34 +1,109 @@ // for referer policy, we can't use it in renderer import axios from 'axios' import { RELEASE_URL, RELEASE_URL_BACKUP } from '../../universal/utils/static' +import semver from 'semver' import yaml from 'js-yaml' +interface IGithubRelease { + tag_name?: string + name?: string + prerelease?: boolean + draft?: boolean +} + +interface IReleaseYAML { + version?: unknown +} + +const REQUEST_HEADERS = { + Referer: 'https://github.com' +} + +function normalizeVersion (version: unknown): string { + if (typeof version !== 'string') { + return '' + } + const normalized = version.trim().replace(/^v/i, '') + return semver.valid(normalized) ?? '' +} + +function pickLatestVersion (versions: string[]): string { + return versions.reduce((latest, current) => { + if (latest === '' || semver.gt(current, latest)) { + return current + } + return latest + }, '') +} + +async function fetchLatestVersionFromGitHub (isCheckBetaUpdate: boolean): Promise { + const response = await axios.get(RELEASE_URL, { + headers: REQUEST_HEADERS + }) + const releaseList: IGithubRelease[] = Array.isArray(response.data) ? response.data : [] + + const versions = releaseList.flatMap((release) => { + if (release.draft) { + return [] + } + if (!isCheckBetaUpdate && release.prerelease) { + return [] + } + + const version = normalizeVersion(release.tag_name ?? release.name) + return version ? [version] : [] + }) + + return pickLatestVersion(versions) +} + +async function fetchVersionFromBackupYAML (fileName: 'latest.yml' | 'latest.beta.yml'): Promise { + const response = await axios.get(`${RELEASE_URL_BACKUP}/${fileName}`, { + headers: REQUEST_HEADERS + }) + const releaseInfo = yaml.load(response.data) + + if (typeof releaseInfo !== 'object' || releaseInfo === null) { + return '' + } + + return normalizeVersion((releaseInfo as IReleaseYAML).version) +} + +async function fetchLatestVersionFromBackup (isCheckBetaUpdate: boolean): Promise { + if (!isCheckBetaUpdate) { + return fetchVersionFromBackupYAML('latest.yml') + } + + const settled = await Promise.allSettled([ + fetchVersionFromBackupYAML('latest.yml'), + fetchVersionFromBackupYAML('latest.beta.yml') + ]) + + const versions: string[] = [] + settled.forEach((item) => { + if (item.status === 'fulfilled' && item.value) { + versions.push(item.value) + } + }) + + return pickLatestVersion(versions) +} + export const getLatestVersion = async (isCheckBetaUpdate: boolean = false) => { - let res: string = '' try { - res = await axios.get(RELEASE_URL, { - headers: { - Referer: 'https://github.com' - } - }).then(r => { - const list = r.data as IStringKeyMap[] - if (isCheckBetaUpdate) { - const betaList = list.filter(item => item.name.includes('beta')) - return betaList[0].name - } - const normalList = list.filter(item => !item.name.includes('beta')) - return normalList[0].name - }).catch(async () => { - const result = await axios.get(isCheckBetaUpdate ? `${RELEASE_URL_BACKUP}/latest.beta.yml` : `${RELEASE_URL_BACKUP}/latest.yml`, { - headers: { - Referer: 'https://github.com' - } - }) - const r = yaml.load(result.data) as IStringKeyMap - return r.version - }) + const version = await fetchLatestVersionFromGitHub(isCheckBetaUpdate) + if (version) { + return version + } } catch (err) { console.log(err) } - return res + + try { + return await fetchLatestVersionFromBackup(isCheckBetaUpdate) + } catch (err) { + console.log(err) + return '' + } }