From 1b04cdd92863778d4f0127b84131cf67c87ec473 Mon Sep 17 00:00:00 2001 From: HuangLeijiana <2644901977@qq.com> Date: Sun, 6 Sep 2026 00:13:38 +0800 Subject: [PATCH] fix(update): compare prerelease identifiers numerically, not lexicographically compareVersions ('rc.10' < 'rc.9' under string comparison) mis-ordered any prerelease that reached a two-digit counter in three places that must stay in lockstep: the update feed filter, the preload's picker newer/older + downgrade split (pendingDowngrade skips the downgrade confirm dialog on a mislabeled upgrade), and the release-script index generator that feeds dshdesktop.com/updates/versions.json. Implement semver-style precedence (numeric identifiers compared numerically and below alphanumeric ones, fewer identifiers sort below more) in all three copies and pin rc.10 > rc.9 plus identifier rules in both test suites. --- scripts/build-version-index.mjs | 35 ++++++++++++++++++++--- src/main/update/version-catalog.ts | 46 +++++++++++++++++++++++++++--- src/preload/index.ts | 39 +++++++++++++++++++++---- test/build-version-index.test.ts | 15 ++++++++++ test/version-catalog.test.ts | 17 +++++++++++ 5 files changed, 139 insertions(+), 13 deletions(-) diff --git a/scripts/build-version-index.mjs b/scripts/build-version-index.mjs index 690681c5..5e9b05c0 100644 --- a/scripts/build-version-index.mjs +++ b/scripts/build-version-index.mjs @@ -3,6 +3,36 @@ import { fileURLToPath } from 'node:url' const SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/ +function comparePrerelease(a, b) { + if (a === b) return 0 + if (!a) return 1 + if (!b) return -1 + const la = a.split('.') + const lb = b.split('.') + const length = Math.max(la.length, lb.length) + for (let i = 0; i < length; i += 1) { + const x = la[i] + const y = lb[i] + if (x === undefined) return -1 + if (y === undefined) return 1 + if (x === y) continue + const xn = /^\d+$/.test(x) + const yn = /^\d+$/.test(y) + if (xn && yn) { + const nx = x.replace(/^0+/, '') || '0' + const ny = y.replace(/^0+/, '') || '0' + if (nx.length !== ny.length) return nx.length < ny.length ? -1 : 1 + if (nx !== ny) return nx < ny ? -1 : 1 + continue + } + if (xn) return -1 + if (yn) return 1 + if (x < y) return -1 + if (x > y) return 1 + } + return 0 +} + function compare(a, b) { const pa = a.split('-')[0].split('.').map(Number) const pb = b.split('-')[0].split('.').map(Number) @@ -11,10 +41,7 @@ function compare(a, b) { } const preA = a.includes('-') ? a.slice(a.indexOf('-') + 1) : '' const preB = b.includes('-') ? b.slice(b.indexOf('-') + 1) : '' - if (preA === preB) return 0 - if (!preA) return 1 - if (!preB) return -1 - return preA < preB ? -1 : 1 + return comparePrerelease(preA, preB) } /** diff --git a/src/main/update/version-catalog.ts b/src/main/update/version-catalog.ts index ef3255a6..c95ef34a 100644 --- a/src/main/update/version-catalog.ts +++ b/src/main/update/version-catalog.ts @@ -29,10 +29,48 @@ export function compareVersions(a: string, b: string): -1 | 0 | 1 { const diff = (left.nums[i] ?? 0) - (right.nums[i] ?? 0) if (diff !== 0) return diff < 0 ? -1 : 1 } - if (left.pre === right.pre) return 0 - if (!left.pre) return 1 // release > prerelease - if (!right.pre) return -1 - return left.pre < right.pre ? -1 : 1 + return comparePrerelease(left.pre, right.pre) +} + +/** + * Semver-style prerelease precedence once the numeric core is equal. A release + * (no prerelease) sorts above any prerelease; dot-separated identifiers + * compare with numeric identifiers numerically and below alphanumeric ones, + * and fewer identifiers sort below more ("alpha" < "alpha.1"). Plain string + * comparison would order "rc.10" below "rc.9", mis-sorting the archive index + * (and the picker/downgrade split in the preload) once a prerelease counter + * reaches two digits. + */ +function comparePrerelease(left: string, right: string): -1 | 0 | 1 { + if (left === right) return 0 + if (!left) return 1 // release > prerelease + if (!right) return -1 + const l = left.split('.') + const r = right.split('.') + const length = Math.max(l.length, r.length) + for (let i = 0; i < length; i += 1) { + const x = l[i] + const y = r[i] + if (x === undefined) return -1 // fewer identifiers sorts below + if (y === undefined) return 1 + if (x === y) continue + const xn = /^\d+$/.test(x) + const yn = /^\d+$/.test(y) + if (xn && yn) { + // Compare without Number() so leading-zero forms and large counters do + // not lose precision. + const nx = x.replace(/^0+/, '') || '0' + const ny = y.replace(/^0+/, '') || '0' + if (nx.length !== ny.length) return nx.length < ny.length ? -1 : 1 + if (nx !== ny) return nx < ny ? -1 : 1 + continue + } + if (xn) return -1 // numeric identifiers sort below alphanumeric ones + if (yn) return 1 + if (x < y) return -1 + if (x > y) return 1 + } + return 0 } function isRelease(value: unknown): value is AvailableRelease { diff --git a/src/preload/index.ts b/src/preload/index.ts index 804b9dc9..8373b6ea 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -544,7 +544,9 @@ function skipButton(status: UpdateStatus): HTMLButtonElement { /** Compare two dotted versions; prerelease sorts below its release. Mirrors * `version-catalog.compareVersions` — a small duplication across the - * main/preload boundary, kept local so the preload bundle stays standalone. */ + * main/preload boundary, kept local so the preload bundle stays standalone. + * Keep the two implementations in lockstep, including the semver-style + * numeric prerelease comparison below ("rc.10" > "rc.9"). */ function comparePreloadVersions(a: string, b: string): number { const parse = (value: string): [number[], string] => { const [core = '', ...pre] = value.trim().split('-') @@ -557,10 +559,37 @@ function comparePreloadVersions(a: string, b: string): number { for (let i = 0; i < 3; i += 1) { if ((an[i] ?? 0) !== (bn[i] ?? 0)) return (an[i] ?? 0) - (bn[i] ?? 0) } - if (ap === bp) return 0 - if (!ap) return 1 - if (!bp) return -1 - return ap < bp ? -1 : 1 + return comparePrerelease(ap, bp) +} + +function comparePrerelease(left: string, right: string): number { + if (left === right) return 0 + if (!left) return 1 + if (!right) return -1 + const l = left.split('.') + const r = right.split('.') + const length = Math.max(l.length, r.length) + for (let i = 0; i < length; i += 1) { + const x = l[i] + const y = r[i] + if (x === undefined) return -1 + if (y === undefined) return 1 + if (x === y) continue + const xn = /^\d+$/.test(x) + const yn = /^\d+$/.test(y) + if (xn && yn) { + const nx = x.replace(/^0+/, '') || '0' + const ny = y.replace(/^0+/, '') || '0' + if (nx.length !== ny.length) return nx.length < ny.length ? -1 : 1 + if (nx !== ny) return nx < ny ? -1 : 1 + continue + } + if (xn) return -1 + if (yn) return 1 + if (x < y) return -1 + if (x > y) return 1 + } + return 0 } function loadVersionList(onDone?: () => void): void { diff --git a/test/build-version-index.test.ts b/test/build-version-index.test.ts index 83d8f344..6efc7a9e 100644 --- a/test/build-version-index.test.ts +++ b/test/build-version-index.test.ts @@ -24,6 +24,21 @@ describe('buildVersionIndex', () => { ]) }) + it('orders prerelease counters numerically so rc.10 is newer than rc.9', () => { + const index = buildVersionIndex([ + '1.2.3-rc.1', + '1.2.3-rc.10', + '1.2.3-rc.9', + '1.2.3-rc.2' + ]) + expect(index.versions.map((v: { version: string }) => v.version)).toEqual([ + '1.2.3-rc.10', + '1.2.3-rc.9', + '1.2.3-rc.2', + '1.2.3-rc.1' + ]) + }) + it('derives tag and archiveUrl for each entry', () => { const [entry] = buildVersionIndex(['3.4.5']).versions expect(entry).toEqual({ diff --git a/test/version-catalog.test.ts b/test/version-catalog.test.ts index 76723d80..6ce727c9 100644 --- a/test/version-catalog.test.ts +++ b/test/version-catalog.test.ts @@ -31,6 +31,23 @@ describe('compareVersions', () => { expect(compareVersions('1.2.3', '1.2.3-rc.1')).toBe(1) expect(compareVersions('1.2.3-rc.1', '1.2.3-rc.2')).toBe(-1) }) + + it('compares prerelease counters numerically, not lexicographically', () => { + // "rc.10" < "rc.9" under string comparison; semver says the reverse. + expect(compareVersions('1.2.3-rc.10', '1.2.3-rc.9')).toBe(1) + expect(compareVersions('1.2.3-rc.9', '1.2.3-rc.10')).toBe(-1) + expect(compareVersions('1.2.3-alpha.10', '1.2.3-alpha.9')).toBe(1) + expect(compareVersions('1.2.3-rc.10', '1.2.3-rc.1')).toBe(1) + }) + + it('follows semver identifier precedence', () => { + // fewer identifiers < more ("alpha" < "alpha.1") + expect(compareVersions('1.2.3-alpha', '1.2.3-alpha.1')).toBe(-1) + // numeric identifiers < alphanumeric ones ("1" < "alpha") + expect(compareVersions('1.2.3-1', '1.2.3-alpha')).toBe(-1) + expect(compareVersions('1.2.3-alpha', '1.2.3-beta')).toBe(-1) + expect(compareVersions('1.2.3-rc.10', '1.2.3-rc.10')).toBe(0) + }) }) describe('parseVersionIndex', () => {