diff --git a/src/coreUpdate.tsx b/src/coreUpdate.tsx index ae85b78b..a2043dff 100644 --- a/src/coreUpdate.tsx +++ b/src/coreUpdate.tsx @@ -1,5 +1,6 @@ import { checkLatestCore } from './services/coreVersions'; import type { CoreLatest } from './services/coreVersions'; +import { compareSemanticVersions } from './services/semanticVersion'; import { createContext, useCallback, @@ -29,70 +30,11 @@ let cachedLatestError = ''; let latestCheckPromise: Promise | null = null; let latestRequestEpoch = 0; -function normalizeVersion(version: string | null | undefined) { - return (version ?? '').trim().replace(/^v/i, ''); -} - -type SemanticVersion = { - core: [string, string, string]; - prerelease: string[] | null; -}; - -const semanticVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; - -function parseSemanticVersion(version: string | null | undefined): SemanticVersion | null { - const match = semanticVersionPattern.exec(normalizeVersion(version)); - if (!match) return null; - - return { - core: [match[1], match[2], match[3]], - prerelease: match[4] ? match[4].split('.') : null, - }; -} - -function compareNumericIdentifier(left: string, right: string) { - if (left.length !== right.length) return left.length > right.length ? 1 : -1; - if (left === right) return 0; - return left > right ? 1 : -1; -} - -function compareSemanticVersions(left: SemanticVersion, right: SemanticVersion) { - for (let index = 0; index < left.core.length; index += 1) { - const difference = compareNumericIdentifier(left.core[index], right.core[index]); - if (difference !== 0) return difference; - } - - if (left.prerelease === null) return right.prerelease === null ? 0 : 1; - if (right.prerelease === null) return -1; - - const count = Math.max(left.prerelease.length, right.prerelease.length); - for (let index = 0; index < count; index += 1) { - const leftIdentifier = left.prerelease[index]; - const rightIdentifier = right.prerelease[index]; - if (leftIdentifier === undefined) return -1; - if (rightIdentifier === undefined) return 1; - if (leftIdentifier === rightIdentifier) continue; - - const leftIsNumeric = /^\d+$/.test(leftIdentifier); - const rightIsNumeric = /^\d+$/.test(rightIdentifier); - if (leftIsNumeric && rightIsNumeric) { - return compareNumericIdentifier(leftIdentifier, rightIdentifier); - } - if (leftIsNumeric) return -1; - if (rightIsNumeric) return 1; - return leftIdentifier > rightIdentifier ? 1 : -1; - } - - return 0; -} - export function coreUpdateAvailable( currentVersion: string | null | undefined, latestVersion: string | null | undefined, ) { - const current = parseSemanticVersion(currentVersion); - const latest = parseSemanticVersion(latestVersion); - return Boolean(current && latest && compareSemanticVersions(latest, current) > 0); + return (compareSemanticVersions(latestVersion, currentVersion) ?? 0) > 0; } export function requestLatestCore(force = false) { diff --git a/src/services/pluginReleaseVersions.ts b/src/services/pluginReleaseVersions.ts index d4bfca56..7e95c538 100644 --- a/src/services/pluginReleaseVersions.ts +++ b/src/services/pluginReleaseVersions.ts @@ -1,6 +1,7 @@ import { buildRepositoryURL, getPluginRepositorySlug } from './pluginResources'; import { invoke } from '@tauri-apps/api/core'; import type { PluginStoreResponse } from './plugins'; +import { compareSemanticVersions } from './semanticVersion'; export const supportsPluginVersionSelection = (installType: string): boolean => installType.trim().toLowerCase() === 'github-release'; @@ -26,6 +27,8 @@ export function isNewerPluginRelease(installed: string, latest: string): boolean const current = installed.trim().replace(/^[vV]/, ''); const next = latest.trim().replace(/^[vV]/, ''); if (!current || !next || current === next) return false; + const semantic = compareSemanticVersions(next, current); + if (semantic !== null) return semantic > 0; if (!/^\d+(\.\d+)*$/.test(current) || !/^\d+(\.\d+)*$/.test(next)) return true; const a = current.split('.').map(Number); const b = next.split('.').map(Number); diff --git a/src/services/semanticVersion.ts b/src/services/semanticVersion.ts new file mode 100644 index 00000000..ab17af2d --- /dev/null +++ b/src/services/semanticVersion.ts @@ -0,0 +1,57 @@ +type SemanticVersion = { + core: [string, string, string]; + prerelease: string[] | null; +}; + +const semanticVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +function parseSemanticVersion(version: string | null | undefined): SemanticVersion | null { + const match = semanticVersionPattern.exec((version ?? '').trim().replace(/^v/i, '')); + if (!match) return null; + + return { + core: [match[1], match[2], match[3]], + prerelease: match[4] ? match[4].split('.') : null, + }; +} + +function compareNumericIdentifier(left: string, right: string) { + if (left.length !== right.length) return left.length > right.length ? 1 : -1; + if (left === right) return 0; + return left > right ? 1 : -1; +} + +/** Returns null when either tag is not SemVer, otherwise compares release precedence. */ +export function compareSemanticVersions(leftVersion: string | null | undefined, rightVersion: string | null | undefined): number | null { + const left = parseSemanticVersion(leftVersion); + const right = parseSemanticVersion(rightVersion); + if (!left || !right) return null; + + for (let index = 0; index < left.core.length; index += 1) { + const difference = compareNumericIdentifier(left.core[index], right.core[index]); + if (difference !== 0) return difference; + } + + if (left.prerelease === null) return right.prerelease === null ? 0 : 1; + if (right.prerelease === null) return -1; + + const count = Math.max(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < count; index += 1) { + const leftIdentifier = left.prerelease[index]; + const rightIdentifier = right.prerelease[index]; + if (leftIdentifier === undefined) return -1; + if (rightIdentifier === undefined) return 1; + if (leftIdentifier === rightIdentifier) continue; + + const leftIsNumeric = /^\d+$/.test(leftIdentifier); + const rightIsNumeric = /^\d+$/.test(rightIdentifier); + if (leftIsNumeric && rightIsNumeric) { + return compareNumericIdentifier(leftIdentifier, rightIdentifier); + } + if (leftIsNumeric) return -1; + if (rightIsNumeric) return 1; + return leftIdentifier > rightIdentifier ? 1 : -1; + } + + return 0; +} diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts index a8912fc1..5c29e6c1 100644 --- a/tests/plugins.test.ts +++ b/tests/plugins.test.ts @@ -120,6 +120,19 @@ describe('plugin Management API', () => { expect(calls).toHaveLength(2); }); + it('does not offer an older stable release over an installed prerelease', async () => { + mockIPC(() => [{ repository: 'example/sample', version: '1.9.0', error: null }]); + const store = normalizePluginStore({ plugins: [{ + id: 'sample', source_id: 'official', repository: 'example/sample', + install_type: 'github-release', installed: true, installed_version: '2.0.0-rc.1', + install_source_status: 'matched', + }] }); + const result = await refreshPluginReleaseVersions(store); + expect(result.plugins[0]).toMatchObject({ + version: '1.9.0', installedVersion: '2.0.0-rc.1', updateAvailable: false, + }); + }); + it('defaults only a missing configuration node and propagates transport, auth and endpoint errors', async () => { mockIPC(() => { throw 'Management API error (404): not_found'; }); expect(await pluginsApi.getConfig('sample')).toEqual({}); @@ -253,7 +266,24 @@ describe('plugin installation trust and versions', () => { expect(isNewerPluginRelease('2.0.0', '1.9.0')).toBe(false); expect(isNewerPluginRelease('V1.0', '1.0.0')).toBe(false); expect(isNewerPluginRelease('', '1.0.0')).toBe(false); - expect(isNewerPluginRelease('1.0.0', '1.0.0-rc.1')).toBe(true); + expect(isNewerPluginRelease('1.0.0', '1.0.0-rc.1')).toBe(false); + }); + it.each([ + ['2.0.0-rc.1', '1.9.0', false], + ['1.0.0-rc.10', '1.0.0-rc.2', false], + ['1.0.0-rc.2', '1.0.0-rc.10', true], + ['1.0.0-rc.1', '1.0.0', true], + ['1.0.0+build.1', '1.0.0+build.2', false], + ['1.0.0-alpha.2', '1.0.0-alpha.1', false], + ['1.0.0-beta', '1.0.0-alpha.9', false], + ])('compares semantic release precedence: %s -> %s', (current, latest, expected) => { + expect(isNewerPluginRelease(current, latest)).toBe(expected); + }); + it('preserves arbitrary-tag and short dotted-version comparisons', () => { + expect(isNewerPluginRelease('nightly-old', 'nightly-new')).toBe(true); + expect(isNewerPluginRelease('nightly', 'nightly')).toBe(false); + expect(isNewerPluginRelease('1.2', '1.3')).toBe(true); + expect(isNewerPluginRelease('1.3', '1.2')).toBe(false); }); it('requires the official source and a canonical official GitHub repository', () => { for (const repository of ['router-for-me/sample', 'https://github.com/router-for-me/sample.git']) {