137 lines
4.3 KiB
TypeScript
137 lines
4.3 KiB
TypeScript
|
|
import { useEffect, useState } from 'react';
|
|
import {
|
|
GITEA_URL,
|
|
GITEA_OWNER,
|
|
GITEA_REPO,
|
|
RELEASE_TAG_PREFIX,
|
|
UPDATE_CHECK_TIMEOUT_MS,
|
|
} from '../config/gitea';
|
|
import { version as currentVersion } from '../version.json';
|
|
|
|
export interface UpdateInfo {
|
|
/** Build number of the latest release (same unit as currentVersion). */
|
|
latestVersion: number;
|
|
/** Direct URL the user can open to read the release notes / download the APK. */
|
|
releaseUrl: string;
|
|
/** Tag name as stored on Gitea, e.g. "mobile-v42". */
|
|
tagName: string;
|
|
}
|
|
|
|
export interface UseUpdateCheckResult {
|
|
/** True while the API call is in flight. */
|
|
checking: boolean;
|
|
/** Populated once the check completes and a newer build exists. */
|
|
updateInfo: UpdateInfo | null;
|
|
/** Non-null when the check failed (network error, timeout, bad response). */
|
|
error: Error | null;
|
|
/** Re-run the check manually (e.g. from a "check again" button). */
|
|
recheck: () => void;
|
|
}
|
|
|
|
// ─── Gitea releases API ──────────────────────────────────────────────────────
|
|
|
|
interface GiteaRelease {
|
|
id: number;
|
|
tag_name: string;
|
|
name: string;
|
|
html_url: string;
|
|
draft: boolean;
|
|
prerelease: boolean;
|
|
}
|
|
|
|
async function fetchLatestRelease(): Promise<GiteaRelease> {
|
|
const url = `${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest`;
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
|
|
|
try {
|
|
const res = await fetch(url, { signal: controller.signal });
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Gitea API returned HTTP ${res.status}`);
|
|
}
|
|
|
|
return (await res.json()) as GiteaRelease;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/** Parse the integer build number out of a tag like "mobile-v42". Returns NaN if it can't. */
|
|
function parseBuildNumber(tagName: string): number {
|
|
if (!tagName.startsWith(RELEASE_TAG_PREFIX)) return NaN;
|
|
return parseInt(tagName.slice(RELEASE_TAG_PREFIX.length), 10);
|
|
}
|
|
|
|
|
|
export function useUpdatecheck(): UseUpdateCheckResult {
|
|
const [checking, setChecking] = useState(false);
|
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
// A counter we bump to re-trigger the effect without changing the dep array shape.
|
|
const [trigger, setTrigger] = useState(0);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function check() {
|
|
setChecking(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const release = await fetchLatestRelease();
|
|
|
|
if (cancelled) return;
|
|
|
|
// Skip drafts and pre-releases.
|
|
if (release.draft || release.prerelease) {
|
|
setUpdateInfo(null);
|
|
return;
|
|
}
|
|
|
|
const latestVersion = parseBuildNumber(release.tag_name);
|
|
|
|
if (isNaN(latestVersion)) {
|
|
// The tag doesn't follow our scheme — ignore it silently.
|
|
setUpdateInfo(null);
|
|
return;
|
|
}
|
|
|
|
if (latestVersion > currentVersion) {
|
|
setUpdateInfo({
|
|
latestVersion,
|
|
releaseUrl: release.html_url,
|
|
tagName: release.tag_name,
|
|
});
|
|
} else {
|
|
setUpdateInfo(null);
|
|
}
|
|
} catch (err) {
|
|
if (cancelled) return;
|
|
// Don't surface AbortError as a real error — it's just a timeout.
|
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
setError(new Error('Update check timed out. Check your connection.'));
|
|
} else {
|
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
}
|
|
} finally {
|
|
if (!cancelled) setChecking(false);
|
|
}
|
|
}
|
|
|
|
check();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [trigger]);
|
|
|
|
return {
|
|
checking,
|
|
updateInfo,
|
|
error,
|
|
recheck: () => setTrigger(t => t + 1),
|
|
};
|
|
} |