diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9154300..95726a9 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ import { authRouter } from "./modules/auth/auth.routes.js"; import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js"; import { houseRouter } from "./modules/house/house.routes.js"; import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js"; +import { mobileRouter } from "./modules/mobile/mobile.routes.js"; import { planningRouter } from "./modules/planning/planning.routes.js"; import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { profileRouter } from "./modules/profile/profile.routes.js"; @@ -64,6 +65,10 @@ export function createServer(): ExpressServer { // rather than nested under one of them since it isn't scoped to a single // recipe/step the way `recipeRouter`'s own correction routes are. server.mountRouter("/internal/tech-steps", techStepWorkerRouter); + // Public proxy for Forgejo's Releases API (issues #65/#66) — see + // mobile.service.ts's doc comment for why this can't just be called + // directly from the browser. + server.mountRouter("/mobile", mobileRouter); server.mountRouter("/planning", planningRouter); server.mountRouter("/preferences", preferencesRouter); server.mountRouter("/profile", profileRouter); diff --git a/apps/api/src/modules/mobile/mobile.routes.ts b/apps/api/src/modules/mobile/mobile.routes.ts new file mode 100644 index 0000000..f34fac7 --- /dev/null +++ b/apps/api/src/modules/mobile/mobile.routes.ts @@ -0,0 +1,31 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { Router } from "express"; +import { getLatestApkUrl, getLatestVersion } from "./mobile.service.js"; + +/** + * Router mounted at `/mobile` in app.ts. Both routes are deliberately + * public (no `requireAuth`): the landing page's download link is shown to + * logged-out visitors, and the native update check (issue #66) runs on + * every app start, session or not. Both exist purely to work around + * Forgejo's Releases API sending no CORS headers — see `mobile.service.ts`'s + * own doc comment for the full story. + */ +export const mobileRouter = Router(); + +mobileRouter.get( + "/latest-release", + wrapAsyncHandler(async (_req, res) => { + res.status(200).json({ version: await getLatestVersion() }); + }), +); + +// A plain redirect, not JSON — lets any caller (the landing page's ``, +// the update banner's "Mettre à jour" link) use a static href with zero +// client-side fetch, and always resolves to something useful even if +// Forgejo is briefly unreachable (see getLatestApkUrl's fallback). +mobileRouter.get( + "/download", + wrapAsyncHandler(async (_req, res) => { + res.redirect(302, await getLatestApkUrl()); + }), +); diff --git a/apps/api/src/modules/mobile/mobile.service.ts b/apps/api/src/modules/mobile/mobile.service.ts new file mode 100644 index 0000000..6dce252 --- /dev/null +++ b/apps/api/src/modules/mobile/mobile.service.ts @@ -0,0 +1,75 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { ErrorCode } from "@batch-cooking/shared"; + +/** + * 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). **Must be called server-side, not + * from the browser**: verified live that Forgejo sends no + * `Access-Control-Allow-Origin` header on it, so a direct `fetch()` from + * `apps/web` fails with an opaque CORS error even though the endpoint + * itself is perfectly reachable — this whole module exists to proxy it + * from a context CORS doesn't apply to. + */ +const LATEST_RELEASE_API_URL = + "https://git.dev.kyuno.fr/api/v1/repos/kyuno/batchCooking/releases/latest"; + +/** + * Human-facing Forgejo release page — safe fallback for {@link getLatestApkUrl} + * when no matching asset is found (a release with nothing attached yet, + * Forgejo briefly unreachable, ...). Never a dead link, just a slightly + * less direct one: the visitor lands on the release itself and can find + * the asset from there. + */ +const LATEST_RELEASE_PAGE_URL = "https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest"; + +/** Stable filename `release.yml` always attaches the signed APK under — see that workflow's own comment. */ +const APK_ASSET_NAME = "batchcooking.apk"; + +interface ForgejoReleaseAsset { + name: string; + browser_download_url: string; +} + +interface ForgejoRelease { + tag_name: string; + assets: ForgejoReleaseAsset[]; +} + +async function fetchLatestRelease(): Promise { + const response = await fetch(LATEST_RELEASE_API_URL); + if (!response.ok) { + throw new HttpError( + 502, + ErrorCode.INTERNAL_ERROR, + `Forgejo releases API responded ${response.status}`, + ); + } + return (await response.json()) as ForgejoRelease; +} + +/** + * The latest tagged Android build's version (e.g. `"v1.4.2"`) — used by the + * native app's update check (issue #66, `useAppUpdateCheck.ts`). + */ +export async function getLatestVersion(): Promise { + const release = await fetchLatestRelease(); + return release.tag_name; +} + +/** + * Where a "download the app" click should end up right now: the actual + * signed-APK asset of the latest release if one is attached, else + * {@link LATEST_RELEASE_PAGE_URL} as a safe fallback. Never throws — a + * download link must always go *somewhere* useful, even when Forgejo is + * briefly unreachable. + */ +export async function getLatestApkUrl(): Promise { + try { + const release = await fetchLatestRelease(); + const apk = release.assets.find((asset) => asset.name === APK_ASSET_NAME); + return apk?.browser_download_url ?? LATEST_RELEASE_PAGE_URL; + } catch { + return LATEST_RELEASE_PAGE_URL; + } +} diff --git a/apps/web/cypress/e2e/landing.ts b/apps/web/cypress/e2e/landing.ts index 4067516..067aecc 100644 --- a/apps/web/cypress/e2e/landing.ts +++ b/apps/web/cypress/e2e/landing.ts @@ -16,14 +16,18 @@ Then("I should see the landing call to action", () => { }); // The Android build is distributed as a Forgejo release asset with a stable -// filename (issue #64 builds/attaches it). Points at the release page -// itself, not a direct file URL: Forgejo has no GitHub-style -// `/releases/latest/download/` shortcut, only `/releases/latest` -// resolves (verified live against the first real release). We only assert -// the link is there and points at the right URL — the file itself is -// external to the suite. +// filename (issue #64 builds/attaches it). Points at this API's own +// `/mobile/download` (a plain server-side redirect to the actual latest +// `.apk`) rather than a Forgejo URL directly: Forgejo has neither a +// GitHub-style `/releases/latest/download/` shortcut nor CORS headers +// on its Releases API, so resolving the real asset URL client-side isn't +// possible either way (both verified live). `VITE_API_URL` is unset in this +// environment, so `API_BASE_URL` (see `api/client.ts`) is `""` — same +// origin, hence the bare `/mobile/download` path here rather than an +// absolute URL. We only assert the link is there and points at the right +// path — the redirect target itself is external to the suite. Then("I should see the Android download link", () => { cy.get(".landing__hero .landing__mobile-link") .should("be.visible") - .and("have.attr", "href", "https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest"); + .and("have.attr", "href", "/mobile/download"); }); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 6126374..e14bf43 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -44,7 +44,7 @@ import { noopTokenStore, type TokenStore } from "./token-store"; * in `apps/web/.env`, since the Vite dev server (5173) and the API (3000) * are on different origins there. */ -const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? ""; +export const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? ""; /** * The token store {@link ApiClient} consults on every request. Defaults to @@ -499,6 +499,17 @@ export class ApiClient { body: JSON.stringify({ skipped }), }); } + + /** + * The latest tagged Android release's version (e.g. `"v1.4.2"`) — used by + * {@link useAppUpdateCheck}. Proxied through this API rather than fetched + * directly from Forgejo: Forgejo's Releases API sends no CORS headers, so + * a browser-side `fetch()` to it fails outright. Public — no session + * required. + */ + public getLatestMobileVersion(): Promise<{ version: string }> { + return this._request("/mobile/latest-release"); + } } /** Single shared instance — this client is stateless, no need for one per caller. */ diff --git a/apps/web/src/features/app-update/UpdateBanner.tsx b/apps/web/src/features/app-update/UpdateBanner.tsx index 966de16..38f2d88 100644 --- a/apps/web/src/features/app-update/UpdateBanner.tsx +++ b/apps/web/src/features/app-update/UpdateBanner.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { API_BASE_URL } from "../../api/client"; import "./update-banner.scss"; import { useAppUpdateCheck } from "./useAppUpdateCheck"; @@ -10,14 +11,13 @@ import { useAppUpdateCheck } from "./useAppUpdateCheck"; * ``: a version check is informative, never something that should * block the rest of the page. * - * "Mettre à jour" opens the Forgejo release page externally (not a direct - * file link — Forgejo has no GitHub-style `/releases/latest/download/` - * shortcut, only `/releases/latest` itself resolves; verified live against - * the first real release) 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). One extra tap on the - * release page to reach the actual asset, then the system browser handles - * the download + Android's own install prompt as before. + * "Mettre à jour" opens this API's own `/mobile/download` (a plain + * redirect straight to the latest release's actual `.apk`, resolved + * server-side — see `mobile.service.ts`) externally, 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). The system browser follows the redirect and handles the + * download + Android's own install prompt. */ export function UpdateBanner() { const { t } = useTranslation(); @@ -33,7 +33,7 @@ export function UpdateBanner() {
diff --git a/apps/web/src/features/app-update/useAppUpdateCheck.ts b/apps/web/src/features/app-update/useAppUpdateCheck.ts index 247cb43..ddccf7b 100644 --- a/apps/web/src/features/app-update/useAppUpdateCheck.ts +++ b/apps/web/src/features/app-update/useAppUpdateCheck.ts @@ -1,17 +1,8 @@ import { Capacitor } from "@capacitor/core"; import { useEffect, useState } from "react"; +import { apiClient } from "../../api/client"; 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 @@ -63,15 +54,11 @@ export function useAppUpdateCheck(): { const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0; if (Date.now() - lastCheckedAt < CHECK_INTERVAL_MS) return; - const [{ version: installedVersion }, response] = await Promise.all([ + const [{ version: installedVersion }, { version: latestTag }] = await Promise.all([ App.getInfo(), - fetch(LATEST_RELEASE_URL), + apiClient.getLatestMobileVersion(), ]); 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 }); diff --git a/apps/web/src/pages/landing/LandingPage.tsx b/apps/web/src/pages/landing/LandingPage.tsx index 689dcd0..ba9be8a 100644 --- a/apps/web/src/pages/landing/LandingPage.tsx +++ b/apps/web/src/pages/landing/LandingPage.tsx @@ -2,6 +2,7 @@ import { CalendarDays, HeartPulse, Smartphone, Sparkles, Users } from "lucide-re import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; +import { API_BASE_URL } from "../../api/client"; import "./landing-page.scss"; /** @@ -53,19 +54,24 @@ export function LandingPage() { {/* * Secondary action — an account is needed on any platform, so the * Android build is a discreet link under the CTA row, not a third - * button. Points at the Forgejo "latest release" page (NOT a - * direct-download URL): Forgejo, unlike GitHub, has no - * `/releases/latest/download/` compound route — only - * `/releases/latest` itself resolves (redirects to the release's - * own page), verified live against the first real v0.2.0 release. - * One extra click via Forgejo's own release page to reach the - * actual `batchcooking.apk` asset, but a link that will always - * resolve rather than a shortcut that 404s. The hint is honest - * that this is a sideloaded .apk, not a Play Store listing. + * button. Points at this API's own `/mobile/download` — a plain + * redirect straight to the latest release's actual `.apk`, + * resolved server-side (see `mobile.service.ts`) — rather than a + * Forgejo URL directly: Forgejo, unlike GitHub, has no + * `/releases/latest/download/` shortcut (only + * `/releases/latest` itself resolves, to the release's HTML page, + * not a file), and its Releases API sends no CORS headers, so a + * client-side fetch to resolve the direct URL ourselves isn't an + * option either — both verified live. `target="_blank"` so the + * redirect-then-download happens in a new tab, never navigating + * this one away. The hint is honest that this is a sideloaded + * .apk, not a Play Store listing. */} {t("landing.hero.mobileDownload")}