diff --git a/apps/web/cypress/component/version-compare.cy.tsx b/apps/web/cypress/component/version-compare.cy.tsx new file mode 100644 index 0000000..5d29336 --- /dev/null +++ b/apps/web/cypress/component/version-compare.cy.tsx @@ -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; + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index d84e5a4..3e7c4cf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/features/app-update/UpdateBanner.tsx b/apps/web/src/features/app-update/UpdateBanner.tsx new file mode 100644 index 0000000..7b02f10 --- /dev/null +++ b/apps/web/src/features/app-update/UpdateBanner.tsx @@ -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 + * ``: 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 ( +
+ + {t("appUpdate.banner.text", { version: availableVersion })} + +
+ + {t("appUpdate.banner.update")} + + +
+
+ ); +} diff --git a/apps/web/src/features/app-update/update-banner.scss b/apps/web/src/features/app-update/update-banner.scss new file mode 100644 index 0000000..3264c51 --- /dev/null +++ b/apps/web/src/features/app-update/update-banner.scss @@ -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); + } + } +} diff --git a/apps/web/src/features/app-update/useAppUpdateCheck.ts b/apps/web/src/features/app-update/useAppUpdateCheck.ts new file mode 100644 index 0000000..247cb43 --- /dev/null +++ b/apps/web/src/features/app-update/useAppUpdateCheck.ts @@ -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(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 }; +} diff --git a/apps/web/src/features/app-update/version-compare.ts b/apps/web/src/features/app-update/version-compare.ts new file mode 100644 index 0000000..bb04d73 --- /dev/null +++ b/apps/web/src/features/app-update/version-compare.ts @@ -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; +} diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index 029b612..91c70c7 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -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() {
+ {/* Renders nothing on the web build (native-only check) or once no + newer version is known — see UpdateBanner's own doc comment. */} +
diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 70ee33b..d473d93 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -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" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 734260f..a875268 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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