Some checks failed
Nouvelle route /parametres/application + entrée dans SETTINGS_ITEMS (desktop et sheet mobile, un seul tableau partagé). Thème et "rejouer un tutoriel" restent inchangés dans /parametres/preferences-utilisateur (décision explicite du chantier). Le contenu affiché diffère réellement selon la plateforme, pas par simple choix cosmétique — chaque moitié de la page est inerte sur l'autre plateforme, pas juste hors-sujet : - Version installée + "Vérifier les mises à jour" (natif seulement) : useAppUpdateCheck (déjà utilisé par la bannière silencieuse existante) gagne un checkNow() qui relance la même comparaison sans attendre le throttle de 6h, réutilisant la logique existante plutôt que de la dupliquer (fetchLatestVersionResult extraite en fonction partagée). Nouveau hook useInstalledVersion (App.getInfo() natif, __APP_VERSION__ web). - "Toujours rester sur le site web" (web seulement, tout appareil) : pilote STAY_ON_WEB_PREFERENCE_KEY (déjà exporté par mobile-app-redirect.ts, ticket #84). Volontairement masqué côté natif : le WebView Capacitor tourne sur sa propre origine https://localhost, un localStorage totalement séparé de celui du vrai site web que lit attemptOpenNativeApp — modifier la préférence depuis l'app ne changerait donc jamais rien pour un visiteur mobile réel. Documenté explicitement dans le commentaire de la page pour ne pas réintroduire ce contrôle-fantôme plus tard. Couverture Cypress : nouveau app-settings-page.cy.ts (contenu web, persistance du toggle après reload) ; sidebar.cy.ts étendu pour couvrir le nouveau lien. Le déclenchement natif réel (App.getInfo(), l'appel réseau de checkNow) n'a pas de point d'entrée testable depuis un run Cypress web — même posture déjà acceptée pour useAppUpdateCheck/ UpdateBanner, la logique de comparaison pure reste couverte par version-compare.cy.tsx (inchangé). Suite complète : biome check, tsc -b --force, build, 146/146 e2e, 71/71 composants — tous verts. Closes #86 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
171 lines
7.8 KiB
TypeScript
171 lines
7.8 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";
|
|
|
|
/** Outcome of a single version comparison — shared by the silent background check and {@link useAppUpdateCheck}'s manual `checkNow`, so a caller showing immediate feedback (issue #86's "Vérifier les mises à jour" button) can switch on it directly instead of re-deriving it from `availableVersion`. */
|
|
export type AppUpdateCheckResult =
|
|
| { status: "up-to-date" }
|
|
| { status: "update-available"; version: string }
|
|
| { status: "error" };
|
|
|
|
/**
|
|
* Fetches the installed version (native `App.getInfo()`) and the latest
|
|
* tagged release, and compares them — the one place this comparison
|
|
* happens, shared by the background effect below and `checkNow`. No
|
|
* throttling and no `Preferences` side effects of its own; callers decide
|
|
* what to do with the result (persist a last-checked timestamp, filter
|
|
* against a dismissed version, etc.).
|
|
*/
|
|
async function fetchLatestVersionResult(): Promise<AppUpdateCheckResult> {
|
|
try {
|
|
const { App } = await import("@capacitor/app");
|
|
const [{ version: installedVersion }, { version: latestTag }] = await Promise.all([
|
|
App.getInfo(),
|
|
apiClient.getLatestMobileVersion(),
|
|
]);
|
|
if (!latestTag || !isNewerVersion(installedVersion, latestTag)) {
|
|
return { status: "up-to-date" };
|
|
}
|
|
return { status: "update-available", version: latestTag };
|
|
} catch {
|
|
return { status: "error" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 — plus, since issue #86, a manual `checkNow` for a
|
|
* "Vérifier les mises à jour" button (`AppSettingsPage`) that surfaces this
|
|
* same check's result immediately instead of waiting on the silent
|
|
* background timer.
|
|
*
|
|
* **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.
|
|
* `checkNow` itself still resolves on the web (`{ status: "error" }`,
|
|
* nothing to check) rather than rejecting — callers gate the *button* that
|
|
* would invoke it behind `Capacitor.isNativePlatform()` themselves
|
|
* (`AppSettingsPage` does), so this never actually runs there in practice.
|
|
*
|
|
* 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.
|
|
*
|
|
* The background effect fails silently on any network/parsing error — it
|
|
* must never surface an error banner or block anything, same posture as
|
|
* `ThemeContext`'s own preferences fetch. `checkNow` is the opposite: it's
|
|
* a direct response to an explicit click, so it returns its outcome
|
|
* (including `"error"`) for the caller to actually display.
|
|
*/
|
|
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;
|
|
/**
|
|
* Runs the check immediately, bypassing the background check's own
|
|
* throttle — an explicit "Vérifier les mises à jour" click should always
|
|
* hit the network, not silently no-op because a background check already
|
|
* ran recently. Also bypasses the dismissed-version filter: re-asking is
|
|
* itself a reason to show the result again, even for a version dismissed
|
|
* earlier. Updates `availableVersion` on `"update-available"` so the
|
|
* persistent {@link UpdateBanner} picks it up too, not just this call's
|
|
* own return value.
|
|
*/
|
|
checkNow: () => Promise<AppUpdateCheckResult>;
|
|
} {
|
|
const [availableVersion, setAvailableVersion] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!Capacitor.isNativePlatform()) return;
|
|
|
|
let cancelled = false;
|
|
|
|
async function backgroundCheck() {
|
|
try {
|
|
const { Preferences } = await 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 result = await fetchLatestVersionResult();
|
|
await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) });
|
|
if (result.status !== "update-available") return;
|
|
|
|
const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY });
|
|
if (dismissedVersion === result.version) return;
|
|
|
|
if (!cancelled) setAvailableVersion(result.version);
|
|
} 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 backgroundCheck();
|
|
|
|
// Re-check whenever the app comes back to the foreground — the listener
|
|
// itself is cheap to keep registered, `backgroundCheck`'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 backgroundCheck();
|
|
}).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 }),
|
|
);
|
|
}
|
|
|
|
async function checkNow(): Promise<AppUpdateCheckResult> {
|
|
try {
|
|
const result = await fetchLatestVersionResult();
|
|
if (result.status === "update-available") setAvailableVersion(result.version);
|
|
// Best-effort — a manual check shouldn't make the *next* background
|
|
// check fire again immediately after. Not awaited: nothing the
|
|
// caller needs to wait on, and native-only anyway (a no-op import on
|
|
// the web build would just resolve to nothing useful).
|
|
void import("@capacitor/preferences").then(({ Preferences }) =>
|
|
Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) }),
|
|
);
|
|
return result;
|
|
} catch {
|
|
// `fetchLatestVersionResult` already turns every failure it can hit
|
|
// into `{ status: "error" }` rather than throwing — this only exists
|
|
// to keep `checkNow`'s own contract (a result, never a rejection)
|
|
// true in case that ever changes, per the repo's async/try-catch
|
|
// convention (see specs/dev-conventions.md).
|
|
return { status: "error" };
|
|
}
|
|
}
|
|
|
|
return { availableVersion, dismiss, checkNow };
|
|
}
|