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 { 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(null); const [error, setError] = useState(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), }; }