Files
etf-oglasi/frontend/hooks/useUpdatecheck.tsx
T
ksan 14725e180a
CI/CD / Backend Unit Tests (push) Successful in 1m48s
CI/CD / Deploy (push) Successful in 1m38s
push notifications not working for some reason
2026-06-11 01:29:48 +02:00

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),
};
}