Files
EasyCLIProxyAPI/src/coreUpdate.tsx
T
lzt404 45ecd37ce5 fix(update): compare core versions using SemVer
Only show update indicators when the remote core version is newer.
Add coverage for prereleases, build metadata, invalid versions, and version boundaries.
2026-09-06 01:28:58 +08:00

201 lines
5.7 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useCoreRuntime } from './coreRuntime';
export type CoreLatest = {
version: string;
assetName: string;
};
type CoreUpdateContextValue = {
latest: CoreLatest | null;
error: string;
checking: boolean;
hasUpdate: boolean;
check: (force?: boolean) => Promise<void>;
reset: () => void;
};
let latestAutoCheckStarted = false;
let cachedLatest: CoreLatest | null = null;
let cachedLatestError = '';
let latestCheckPromise: Promise<CoreLatest> | 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);
}
export function requestLatestCore(force = false) {
if (!force && latestCheckPromise) {
return latestCheckPromise;
}
const requestEpoch = ++latestRequestEpoch;
const request = invoke<CoreLatest>('check_latest_core')
.then((result) => {
if (requestEpoch === latestRequestEpoch) {
cachedLatest = result;
cachedLatestError = '';
}
return result;
})
.catch((error) => {
if (requestEpoch === latestRequestEpoch) {
cachedLatest = null;
cachedLatestError = String(error);
}
throw error;
})
.finally(() => {
if (latestCheckPromise === request) {
latestCheckPromise = null;
}
});
latestCheckPromise = request;
return request;
}
const CoreUpdateContext = createContext<CoreUpdateContextValue | null>(null);
export function CoreUpdateProvider({ children }: { children: ReactNode }) {
const { status } = useCoreRuntime();
const [latest, setLatest] = useState<CoreLatest | null>(cachedLatest);
const [error, setError] = useState(cachedLatestError);
const [checking, setChecking] = useState(Boolean(latestCheckPromise));
const checkEpochRef = useRef(0);
const check = useCallback(async (force = false) => {
const checkEpoch = ++checkEpochRef.current;
setChecking(true);
setError('');
try {
const result = await requestLatestCore(force);
if (checkEpoch === checkEpochRef.current) {
setLatest(result);
}
} catch (nextError) {
if (checkEpoch === checkEpochRef.current) {
setLatest(null);
setError(String(nextError));
}
} finally {
if (checkEpoch === checkEpochRef.current) {
setChecking(false);
}
}
}, []);
const reset = useCallback(() => {
latestRequestEpoch += 1;
latestCheckPromise = null;
cachedLatest = null;
cachedLatestError = '';
checkEpochRef.current += 1;
setLatest(null);
setError('');
setChecking(false);
}, []);
useEffect(() => {
if (!latestAutoCheckStarted) {
latestAutoCheckStarted = true;
void check();
} else if (latestCheckPromise) {
void check();
}
}, [check]);
const value = useMemo<CoreUpdateContextValue>(() => ({
latest,
error,
checking,
hasUpdate: coreUpdateAvailable(status?.currentVersion, latest?.version),
check,
reset,
}), [check, checking, error, latest, reset, status?.currentVersion]);
return <CoreUpdateContext.Provider value={value}>{children}</CoreUpdateContext.Provider>;
}
export function useCoreUpdate() {
const context = useContext(CoreUpdateContext);
if (!context) {
throw new Error('useCoreUpdate 必须在 CoreUpdateProvider 内使用');
}
return context;
}