feat(mobile): vérification automatique de mise à jour de version #69

Merged
kyuno merged 7 commits from feat/mobile-update-check into main 2026-09-06 14:11:55 +02:00
9 changed files with 340 additions and 0 deletions

View file

@ -0,0 +1,51 @@
import { isNewerVersion, parseVersion } from "../../src/features/app-update/version-compare";
// Pure logic, no DOM/mount needed — same posture as highlight-tech-steps.cy.tsx:
// reuses the component-test runner (Cypress's Mocha/Chai) purely for `expect`.
// `.cy.tsx` (not `.cy.ts`) only because that's what cypress.config.ts's
// component `specPattern` looks for.
describe("parseVersion", () => {
it("parses a v-prefixed tag into its three integer components", () => {
expect(parseVersion("v1.4.2")).to.deep.equal({ major: 1, minor: 4, patch: 2 });
});
it("also accepts a bare X.Y.Z string (no leading v)", () => {
expect(parseVersion("1.4.2")).to.deep.equal({ major: 1, minor: 4, patch: 2 });
});
it("returns null for anything that doesn't match exactly", () => {
expect(parseVersion("")).to.be.null;
expect(parseVersion("not-a-version")).to.be.null;
expect(parseVersion("v1.4")).to.be.null;
expect(parseVersion("v1.4.2-beta")).to.be.null;
});
});
describe("isNewerVersion", () => {
it("is true when the major version is higher", () => {
expect(isNewerVersion("1.4.2", "v2.0.0")).to.be.true;
});
it("is true when the minor version is higher, major equal", () => {
expect(isNewerVersion("1.4.2", "v1.5.0")).to.be.true;
});
it("is true when only the patch version is higher", () => {
expect(isNewerVersion("1.4.2", "v1.4.3")).to.be.true;
});
it("is false for the exact same version", () => {
expect(isNewerVersion("1.4.2", "v1.4.2")).to.be.false;
});
it("is false when the candidate is actually older", () => {
expect(isNewerVersion("1.4.2", "v1.4.1")).to.be.false;
expect(isNewerVersion("2.0.0", "v1.9.9")).to.be.false;
});
it("fails closed (false) when either side doesn't parse", () => {
expect(isNewerVersion("1.0", "v2.0.0")).to.be.false;
expect(isNewerVersion("1.0.0", "not-a-tag")).to.be.false;
});
});

View file

@ -18,6 +18,7 @@
"@batch-cooking/date-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*",
"@capacitor/android": "^8.5.1",
"@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.1",
"@capacitor/preferences": "^8.0.1",
"@dnd-kit/core": "^6.3.1",

View file

@ -0,0 +1,45 @@
import { useTranslation } from "react-i18next";
import "./update-banner.scss";
import { useAppUpdateCheck } from "./useAppUpdateCheck";
/**
* Discreet, dismissible notice shown when a newer Android build has been
* tagged than the one installed (see `useAppUpdateCheck`'s own doc comment
* for the full mechanism — native only, `null` on the web, so this renders
* nothing there). A plain non-modal banner, not `Dialog.tsx`'s native
* `<dialog>`: a version check is informative, never something that should
* block the rest of the page.
*
* "Mettre à jour" opens the download link externally (the system browser
* handles the download + Android's own install prompt) rather than an
* in-app `PackageInstaller` flow — no `REQUEST_INSTALL_PACKAGES` permission,
* no extra native code, for a marginal UX gain (see issue #66's own
* hors-scope).
*/
export function UpdateBanner() {
const { t } = useTranslation();
const { availableVersion, dismiss } = useAppUpdateCheck();
if (!availableVersion) return null;
return (
<div className="update-banner" role="status">
<span className="update-banner__text">
{t("appUpdate.banner.text", { version: availableVersion })}
</span>
<div className="update-banner__actions">
<a
className="update-banner__update"
href="https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest/download/batchcooking.apk"
target="_blank"
rel="noreferrer"
>
{t("appUpdate.banner.update")}
</a>
<button type="button" className="update-banner__dismiss" onClick={dismiss}>
{t("appUpdate.banner.dismiss")}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,50 @@
// Styles for UpdateBanner — a discreet, dismissible strip above the routed
// page content (see AppLayout.tsx). Uses --color-warning the same way
// source-item-preview__hint does elsewhere in the app: "worth noticing",
// not an error.
.update-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
border: 1px solid var(--color-warning);
border-radius: var(--radius-base);
font-size: var(--font-size-sm);
&__text {
color: var(--color-text);
}
&__actions {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-shrink: 0;
}
&__update {
padding: var(--space-xs) var(--space-sm);
background: var(--color-primary);
color: var(--color-surface);
border-radius: var(--radius-base);
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
&__dismiss {
padding: var(--space-xs) var(--space-sm);
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
&:hover {
color: var(--color-text);
}
}
}

View file

@ -0,0 +1,119 @@
import { Capacitor } from "@capacitor/core";
import { useEffect, useState } from "react";
import { isNewerVersion } from "./version-compare";
/**
* Forgejo's own Releases API for this repo — public, unauthenticated (the
* same fact `local-verification-while-ci-disabled` already leans on for
* CI-status checks without a token). Zero new backend endpoint needed: the
* tag of the latest *tagged* release is exactly "the latest version" this
* check needs, and it's already publicly readable.
*/
const LATEST_RELEASE_URL =
"https://git.dev.kyuno.fr/api/v1/repos/kyuno/batchCooking/releases/latest";
/** 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 }, response] = await Promise.all([
App.getInfo(),
fetch(LATEST_RELEASE_URL),
]);
await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) });
if (!response.ok) return;
const release = (await response.json()) as { tag_name?: string };
const latestTag = release.tag_name;
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 };
}

View file

@ -0,0 +1,51 @@
/**
* Pure version-comparison logic for the mobile update check (issue #66) —
* no Capacitor/network/DOM dependency, kept separate from
* `useAppUpdateCheck.ts` so it's testable in isolation (same
* pure-logic/effectful-hook split this repo uses everywhere else, e.g.
* `ingredient-matcher.ts`'s `matchIngredientName` vs `loadIngredientCatalog`).
*
* Release tags in this repo are always `vX.Y.Z` (see `.github/workflows/release.yml`'s
* `on: push: tags: ["v*.*.*"]`) — a hand-rolled comparison for exactly that
* shape is simpler than pulling in a semver library for three integers.
*/
/** One `vX.Y.Z` tag parsed into its three integer components. */
export interface ParsedVersion {
major: number;
minor: number;
patch: number;
}
/**
* Parses a `vX.Y.Z` (or bare `X.Y.Z`) string into its components — `null`
* if it doesn't match that exact shape (a malformed tag, an empty string, a
* pre-release suffix this app doesn't publish). Callers treat `null` as
* "can't compare, assume no update" rather than throwing — a version check
* is never allowed to be the reason the app breaks.
*/
export function parseVersion(raw: string): ParsedVersion | null {
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(raw.trim());
if (!match) return null;
const [, major, minor, patch] = match;
return { major: Number(major), minor: Number(minor), patch: Number(patch) };
}
/**
* `true` when `latest` is a genuinely newer version than `current` —
* `false` for equal, older, or unparseable input on either side (fails
* closed: "don't nag" is always the safe default for a background check).
*/
export function isNewerVersion(current: string, latest: string): boolean {
const currentParsed = parseVersion(current);
const latestParsed = parseVersion(latest);
if (!currentParsed || !latestParsed) return false;
if (latestParsed.major !== currentParsed.major) {
return latestParsed.major > currentParsed.major;
}
if (latestParsed.minor !== currentParsed.minor) {
return latestParsed.minor > currentParsed.minor;
}
return latestParsed.patch > currentParsed.patch;
}

View file

@ -1,6 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { UpdateBanner } from "../features/app-update/UpdateBanner";
import { useAuth } from "../features/auth/AuthContext";
import "./AppLayout.scss";
import {
@ -124,6 +125,9 @@ export function AppLayout() {
</aside>
<main className="app-content">
{/* Renders nothing on the web build (native-only check) or once no
newer version is known — see UpdateBanner's own doc comment. */}
<UpdateBanner />
<Outlet />
</main>
</div>

View file

@ -1372,5 +1372,12 @@
"title": "Revoir un tutoriel",
"button": "Revoir"
}
},
"appUpdate": {
"banner": {
"text": "Une nouvelle version ({{version}}) est disponible.",
"update": "Mettre à jour",
"dismiss": "Plus tard"
}
}
}

View file

@ -105,6 +105,9 @@ importers:
'@capacitor/android':
specifier: ^8.5.1
version: 8.5.1(@capacitor/core@8.5.1)
'@capacitor/app':
specifier: ^8.1.1
version: 8.1.1(@capacitor/core@8.5.1)
'@capacitor/core':
specifier: ^8.5.1
version: 8.5.1
@ -441,6 +444,11 @@ packages:
peerDependencies:
'@capacitor/core': ^8.5.0
'@capacitor/app@8.1.1':
resolution: {integrity: sha512-xM2ZTX5jK60tFtmjsmJv+Cdj1Qsb6NcavkqmdEnCPQBeya9oKhamZJxYOnQNj1ZvcJM7sWfG6BEtSLx42pisbA==, tarball: https://registry.npmjs.org/@capacitor/app/-/app-8.1.1.tgz}
peerDependencies:
'@capacitor/core': '>=8.0.0'
'@capacitor/cli@8.5.1':
resolution: {integrity: sha512-LKQFSyLg1QbTN6CGDoFqeRtAlj8C3c9OymP8OuhwlmkbSQwh9i1HQjwUCaNyVem2XkY9rv0v2UOfVlgoxH5pAw==, tarball: https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.1.tgz}
engines: {node: '>=22.0.0'}
@ -4446,6 +4454,10 @@ snapshots:
dependencies:
'@capacitor/core': 8.5.1
'@capacitor/app@8.1.1(@capacitor/core@8.5.1)':
dependencies:
'@capacitor/core': 8.5.1
'@capacitor/cli@8.5.1':
dependencies:
'@ionic/cli-framework-output': 2.2.8