feat(mobile): proxie le téléchargement de l'APK via l'API (pas de CORS) #77
8 changed files with 162 additions and 43 deletions
|
|
@ -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);
|
||||
|
|
|
|||
31
apps/api/src/modules/mobile/mobile.routes.ts
Normal file
31
apps/api/src/modules/mobile/mobile.routes.ts
Normal 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());
|
||||
}),
|
||||
);
|
||||
75
apps/api/src/modules/mobile/mobile.service.ts
Normal file
75
apps/api/src/modules/mobile/mobile.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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/<file>` 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/<file>` 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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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";
|
|||
* `<dialog>`: 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/<file>`
|
||||
* 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() {
|
|||
<div className="update-banner__actions">
|
||||
<a
|
||||
className="update-banner__update"
|
||||
href="https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest"
|
||||
href={`${API_BASE_URL}/mobile/download`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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/<file>` 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/<file>` 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.
|
||||
*/}
|
||||
<a
|
||||
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 />
|
||||
{t("landing.hero.mobileDownload")}
|
||||
|
|
|
|||
Loading…
Reference in a new issue