All checks were successful
Suite au fix de /releases/latest (precedent), l'utilisateur voulait un
lien qui declenche le telechargement du .apk directement, sans quitter
l'appli web. Verifie en direct : fetch("https://git.dev.kyuno.fr/api/...")
depuis le navigateur echoue avec "Failed to fetch" (confirme via un essai
en mode no-cors : la requete reseau aboutit bien, seule la reponse est
bloquee) — Forgejo n'envoie aucun header CORS sur son API Releases,
publique ou non. Ca ne concerne pas que la page d'accueil : le hook de
verification de mise a jour (#66, useAppUpdateCheck) faisait exactement le
meme fetch cross-origin depuis la WebView native, et echouait donc tout
aussi silencieusement en production sans que personne ne puisse s'en
rendre compte (le hook avale toute erreur reseau par design).
Ajoute un petit module apps/api/src/modules/mobile/ (2 routes publiques,
aucune ne touche la base de donnees) :
- GET /mobile/latest-release -> { version } (proxy JSON, remplace le fetch
direct de useAppUpdateCheck)
- GET /mobile/download -> redirection 302 vers l'asset .apk reel de la
derniere release (ou vers la page /releases/latest en repli si
Forgejo est injoignable ou qu'aucun .apk n'est encore attache)
Cote frontend : LandingPage.tsx et UpdateBanner.tsx pointent maintenant
vers ce proxy (`${API_BASE_URL}/mobile/download`, target="_blank") au lieu
d'une URL Forgejo directe — un clic, telechargement immediat, jamais de
navigation hors de l'appli. useAppUpdateCheck.ts appelle desormais
apiClient.getLatestMobileVersion() au lieu d'un fetch direct.
Verifie en local (sans Postgres, ces routes n'en ont pas besoin) contre
les vraies donnees Forgejo :
GET /mobile/latest-release -> {"version":"v0.2.0"}
GET /mobile/download -> 302, Location vers le vrai .apk signe
pnpm lint / tsc (api + web) / pnpm --filter web build : tous verts. Le
scenario Cypress "offers the Android app download" echoue encore en local
(meme souci pre-existant deja documente sur la PR precedente, confirme
independant du code par CI reel) ; mis a jour pour asserter le nouveau
href relatif.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
106 lines
4.3 KiB
TypeScript
106 lines
4.3 KiB
TypeScript
import { Capacitor } from "@capacitor/core";
|
|
import { useEffect, useState } from "react";
|
|
import { apiClient } from "../../api/client";
|
|
import { isNewerVersion } from "./version-compare";
|
|
|
|
/** How often a background check is allowed to actually hit the network — cold start and every resume-from-background otherwise re-checks needlessly. */
|
|
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
|
|
|
|
const LAST_CHECKED_KEY = "appUpdate_lastCheckedAt";
|
|
const DISMISSED_VERSION_KEY = "appUpdate_dismissedVersion";
|
|
|
|
/**
|
|
* Detects whether a newer version of the Android app has been tagged than
|
|
* the one currently installed, and offers a way to dismiss the notice for
|
|
* that specific version.
|
|
*
|
|
* **Native only** — on the web, "the app" is just whatever the server is
|
|
* currently serving; there is no "installed version" to compare against,
|
|
* and reloading the page always gets the latest build. `Capacitor`/
|
|
* `@capacitor/app`/`@capacitor/preferences` stay dynamically imported so
|
|
* none of them enter the web bundle (same pattern as `main.tsx`'s own
|
|
* `capacitor-token-store` bootstrap) — this hook is safe to call
|
|
* unconditionally from `AppLayout`, which renders on both platforms.
|
|
*
|
|
* No Play Store integration exists (this app isn't distributed there) —
|
|
* Google's In-App Update API has no purchase here at all, so this is a
|
|
* from-scratch check, not a wrapper around it.
|
|
*
|
|
* Fails silently on any network/parsing error — a background version check
|
|
* must never surface an error banner or block anything, same posture as
|
|
* `ThemeContext`'s own preferences fetch.
|
|
*/
|
|
export function useAppUpdateCheck(): {
|
|
/** The newer version's tag (e.g. `"v1.4.2"`), or `null` when none is available (or none has been checked/parsed yet). */
|
|
availableVersion: string | null;
|
|
/** Hides the banner for `availableVersion` specifically — it reappears only once an even newer version ships, never just on the next app start. */
|
|
dismiss: () => void;
|
|
} {
|
|
const [availableVersion, setAvailableVersion] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!Capacitor.isNativePlatform()) return;
|
|
|
|
let cancelled = false;
|
|
|
|
async function checkForUpdate() {
|
|
try {
|
|
const [{ App }, { Preferences }] = await Promise.all([
|
|
import("@capacitor/app"),
|
|
import("@capacitor/preferences"),
|
|
]);
|
|
|
|
const { value: lastCheckedRaw } = await Preferences.get({ key: LAST_CHECKED_KEY });
|
|
const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0;
|
|
if (Date.now() - lastCheckedAt < CHECK_INTERVAL_MS) return;
|
|
|
|
const [{ version: installedVersion }, { version: latestTag }] = await Promise.all([
|
|
App.getInfo(),
|
|
apiClient.getLatestMobileVersion(),
|
|
]);
|
|
await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) });
|
|
if (!latestTag || !isNewerVersion(installedVersion, latestTag)) return;
|
|
|
|
const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY });
|
|
if (dismissedVersion === latestTag) return;
|
|
|
|
if (!cancelled) setAvailableVersion(latestTag);
|
|
} catch {
|
|
// Silent — see this hook's own doc comment. Nothing to log to
|
|
// (apps/web has no client-side LoggerService equivalent yet), and a
|
|
// failed version check is never actionable for the visitor anyway.
|
|
}
|
|
}
|
|
|
|
void checkForUpdate();
|
|
|
|
// Re-check whenever the app comes back to the foreground — the listener
|
|
// itself is cheap to keep registered, `checkForUpdate`'s own throttle
|
|
// above is what actually keeps this from hitting the network every time.
|
|
let removeListener: (() => void) | undefined;
|
|
void import("@capacitor/app").then(({ App }) => {
|
|
if (cancelled) return;
|
|
App.addListener("appStateChange", ({ isActive }) => {
|
|
if (isActive) void checkForUpdate();
|
|
}).then((handle) => {
|
|
removeListener = () => void handle.remove();
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
removeListener?.();
|
|
};
|
|
}, []);
|
|
|
|
function dismiss() {
|
|
if (!availableVersion) return;
|
|
const dismissed = availableVersion;
|
|
setAvailableVersion(null);
|
|
void import("@capacitor/preferences").then(({ Preferences }) =>
|
|
Preferences.set({ key: DISMISSED_VERSION_KEY, value: dismissed }),
|
|
);
|
|
}
|
|
|
|
return { availableVersion, dismiss };
|
|
}
|