feat(mobile): proxie le telechargement de l'APK via l'API (pas de CORS)
All checks were successful
CI / lint (push) Successful in 2m59s
CI / build (push) Successful in 3m4s
CI / intent-service-test (push) Successful in 4m46s
CI / e2e (push) Successful in 9m33s
CI / test (push) Successful in 17m20s
CI / build-android (push) Has been skipped

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>
This commit is contained in:
Nicolas 2026-09-06 23:40:06 +02:00
parent 7cbd4944bd
commit 3abb7217dd
8 changed files with 162 additions and 43 deletions

View file

@ -10,6 +10,7 @@ import { authRouter } from "./modules/auth/auth.routes.js";
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js"; import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
import { houseRouter } from "./modules/house/house.routes.js"; import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.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 { planningRouter } from "./modules/planning/planning.routes.js";
import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.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 // 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. // recipe/step the way `recipeRouter`'s own correction routes are.
server.mountRouter("/internal/tech-steps", techStepWorkerRouter); 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("/planning", planningRouter);
server.mountRouter("/preferences", preferencesRouter); server.mountRouter("/preferences", preferencesRouter);
server.mountRouter("/profile", profileRouter); server.mountRouter("/profile", profileRouter);

View file

@ -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 `<a>`,
// 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());
}),
);

View file

@ -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<ForgejoRelease> {
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<string> {
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<string> {
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;
}
}

View file

@ -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 // The Android build is distributed as a Forgejo release asset with a stable
// filename (issue #64 builds/attaches it). Points at the release page // filename (issue #64 builds/attaches it). Points at this API's own
// itself, not a direct file URL: Forgejo has no GitHub-style // `/mobile/download` (a plain server-side redirect to the actual latest
// `/releases/latest/download/<file>` shortcut, only `/releases/latest` // `.apk`) rather than a Forgejo URL directly: Forgejo has neither a
// resolves (verified live against the first real release). We only assert // GitHub-style `/releases/latest/download/<file>` shortcut nor CORS headers
// the link is there and points at the right URL — the file itself is // on its Releases API, so resolving the real asset URL client-side isn't
// external to the suite. // 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", () => { Then("I should see the Android download link", () => {
cy.get(".landing__hero .landing__mobile-link") cy.get(".landing__hero .landing__mobile-link")
.should("be.visible") .should("be.visible")
.and("have.attr", "href", "https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest"); .and("have.attr", "href", "/mobile/download");
}); });

View file

@ -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) * in `apps/web/.env`, since the Vite dev server (5173) and the API (3000)
* are on different origins there. * 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 * The token store {@link ApiClient} consults on every request. Defaults to
@ -499,6 +499,17 @@ export class ApiClient {
body: JSON.stringify({ skipped }), 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. */ /** Single shared instance — this client is stateless, no need for one per caller. */

View file

@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { API_BASE_URL } from "../../api/client";
import "./update-banner.scss"; import "./update-banner.scss";
import { useAppUpdateCheck } from "./useAppUpdateCheck"; import { useAppUpdateCheck } from "./useAppUpdateCheck";
@ -10,14 +11,13 @@ import { useAppUpdateCheck } from "./useAppUpdateCheck";
* `<dialog>`: a version check is informative, never something that should * `<dialog>`: a version check is informative, never something that should
* block the rest of the page. * block the rest of the page.
* *
* "Mettre à jour" opens the Forgejo release page externally (not a direct * "Mettre à jour" opens this API's own `/mobile/download` (a plain
* file link — Forgejo has no GitHub-style `/releases/latest/download/<file>` * redirect straight to the latest release's actual `.apk`, resolved
* shortcut, only `/releases/latest` itself resolves; verified live against * server-side — see `mobile.service.ts`) externally, rather than an in-app
* the first real release) rather than an in-app `PackageInstaller` flow — * `PackageInstaller` flow: no `REQUEST_INSTALL_PACKAGES` permission, no
* no `REQUEST_INSTALL_PACKAGES` permission, no extra native code, for a * extra native code, for a marginal UX gain (see issue #66's own
* marginal UX gain (see issue #66's own hors-scope). One extra tap on the * hors-scope). The system browser follows the redirect and handles the
* release page to reach the actual asset, then the system browser handles * download + Android's own install prompt.
* the download + Android's own install prompt as before.
*/ */
export function UpdateBanner() { export function UpdateBanner() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -33,7 +33,7 @@ export function UpdateBanner() {
<div className="update-banner__actions"> <div className="update-banner__actions">
<a <a
className="update-banner__update" className="update-banner__update"
href="https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest" href={`${API_BASE_URL}/mobile/download`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >

View file

@ -1,17 +1,8 @@
import { Capacitor } from "@capacitor/core"; import { Capacitor } from "@capacitor/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { apiClient } from "../../api/client";
import { isNewerVersion } from "./version-compare"; 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. */ /** 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 CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
@ -63,15 +54,11 @@ export function useAppUpdateCheck(): {
const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0; const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0;
if (Date.now() - lastCheckedAt < CHECK_INTERVAL_MS) return; 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(), App.getInfo(),
fetch(LATEST_RELEASE_URL), apiClient.getLatestMobileVersion(),
]); ]);
await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) }); 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; if (!latestTag || !isNewerVersion(installedVersion, latestTag)) return;
const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY }); const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY });

View file

@ -2,6 +2,7 @@ import { CalendarDays, HeartPulse, Smartphone, Sparkles, Users } from "lucide-re
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { API_BASE_URL } from "../../api/client";
import "./landing-page.scss"; import "./landing-page.scss";
/** /**
@ -53,19 +54,24 @@ export function LandingPage() {
{/* {/*
* Secondary action — an account is needed on any platform, so the * Secondary action — an account is needed on any platform, so the
* Android build is a discreet link under the CTA row, not a third * Android build is a discreet link under the CTA row, not a third
* button. Points at the Forgejo "latest release" page (NOT a * button. Points at this API's own `/mobile/download` — a plain
* direct-download URL): Forgejo, unlike GitHub, has no * redirect straight to the latest release's actual `.apk`,
* `/releases/latest/download/<file>` compound route — only * resolved server-side (see `mobile.service.ts`) — rather than a
* `/releases/latest` itself resolves (redirects to the release's * Forgejo URL directly: Forgejo, unlike GitHub, has no
* own page), verified live against the first real v0.2.0 release. * `/releases/latest/download/<file>` shortcut (only
* One extra click via Forgejo's own release page to reach the * `/releases/latest` itself resolves, to the release's HTML page,
* actual `batchcooking.apk` asset, but a link that will always * not a file), and its Releases API sends no CORS headers, so a
* resolve rather than a shortcut that 404s. The hint is honest * client-side fetch to resolve the direct URL ourselves isn't an
* that this is a sideloaded .apk, not a Play Store listing. * 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.
*/} */}
<a <a
className="landing__mobile-link" className="landing__mobile-link"
href="https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest" href={`${API_BASE_URL}/mobile/download`}
target="_blank"
rel="noreferrer"
> >
<Smartphone aria-hidden /> <Smartphone aria-hidden />
{t("landing.hero.mobileDownload")} {t("landing.hero.mobileDownload")}