From 13ac3e38ab18a8da39e09392bc3a70226d0b3bf2 Mon Sep 17 00:00:00 2001 From: Seenu K Date: Wed, 12 Aug 2026 13:22:57 +0530 Subject: [PATCH] Serve a placeholder icon instead of linking one that is not there Every engine-rendered page linked `/favicon.png` and `/icons/Icon-192.png` unconditionally. Those come from the game's own `public/`, which a fresh scaffold ships holding a single `.gitkeep`, so until a Flutter web build landed there the browser tab was blank and all four manifest icons 404ed. An Android-only game, which never runs `build:web`, stayed that way permanently, even though the download page's hero already had a fallback for exactly this case. The shell now links the EigenInteractive mark, served by the worker and drawn in `site.primaryColor`, and the manifest advertises that one scalable icon rather than four missing PNGs. Apple's touch icon is omitted rather than pointed at a missing file, since it has no SVG support. A placeholder, not a default: the moment `favicon.png` exists in `public/`, the game's own icons are linked everywhere and this goes unused. Splits the asset probe in two to make that work. `hasWebBuild` asks for `index.html` and still gates "Play on the web"; `hasAppIcons` asks for `favicon.png` and gates every icon. One probe served both on the grounds that `flutter build web` emits them together, which holds for a game that ships on the web and fails the case worth supporting. `hasAppIcons` also checks the content type, because the scaffold's `single-page-application` fallback answers `200 OK` with `index.html` for any missing asset. Co-Authored-By: Claude Opus 5 --- .changeset/placeholder-app-icon.md | 33 ++++++ packages/server/src/routes/site.tsx | 174 ++++++++++++++++++++-------- packages/server/src/site/icon.ts | 56 +++++++++ packages/server/src/site/page.tsx | 28 ++++- packages/server/test/site.spec.ts | 119 ++++++++++++++++--- 5 files changed, 347 insertions(+), 63 deletions(-) create mode 100644 .changeset/placeholder-app-icon.md create mode 100644 packages/server/src/site/icon.ts diff --git a/.changeset/placeholder-app-icon.md b/.changeset/placeholder-app-icon.md new file mode 100644 index 0000000..aaa73aa --- /dev/null +++ b/.changeset/placeholder-app-icon.md @@ -0,0 +1,33 @@ +--- +"@eigeninteractive/server": patch +--- + +Serve a placeholder app icon instead of linking one that is not there. + +Every page the engine renders linked `/favicon.png` and `/icons/Icon-192.png` +unconditionally. Those are static assets from the game's own `public/`, which a +fresh scaffold ships holding a single `.gitkeep`, so until a Flutter web build +landed there the browser tab was blank and all four manifest icons 404ed. An +Android-only game, which never runs `build:web`, stayed that way permanently, +even though the download page's hero already had a fallback for exactly this +case. + +The shell now links the EigenInteractive mark, served by the worker at +`/_eigen/icon/v1/mark.svg` and drawn in the game's `site.primaryColor`, and the +manifest advertises that single scalable icon rather than four missing PNGs. +Apple's touch icon is omitted rather than pointed at a missing file, since it +has no SVG support. + +It is a placeholder, not a default: as soon as `favicon.png` exists in +`public/`, the shell links the game's own icons everywhere and the placeholder +goes unused. + +The probe behind that decision was split in two. `hasWebBuild` asks for +`index.html` and still gates the "Play on the web" button; the new +`hasAppIcons` asks for `favicon.png` and gates every icon. They used to be one +question on the grounds that `flutter build web` emits both together, which is +true for a game that ships on the web and wrong for the case worth supporting: +an Android-only game that copies its launcher icons into `public/` now gets +them. `hasAppIcons` checks the response's content type as well as its status, +because the scaffold's `single-page-application` fallback answers `200 OK` with +`index.html` for any asset that is missing. diff --git a/packages/server/src/routes/site.tsx b/packages/server/src/routes/site.tsx index aa26520..1a0bde2 100644 --- a/packages/server/src/routes/site.tsx +++ b/packages/server/src/routes/site.tsx @@ -23,6 +23,7 @@ import type { DeepLinkConfig, EngineApp, RouteContext } from "../engine.js"; import type { ResolvedSite } from "../site/config.js"; import { FONTS, fontBytes } from "../site/fonts.js"; +import { DEFAULT_PRIMARY, ENGINE_ICON_URL, engineIconSvg } from "../site/icon.js"; import { ICONS, NEW_TAB, Page, RawHtml, renderDocument } from "../site/page.js"; /** Cached for a day: crawler files change only on redeploy. */ @@ -104,6 +105,59 @@ function jsonLdFor(site: ResolvedSite, deepLink: DeepLinkConfig | null, origin: }); } +/** Fetches an asset through the `ASSETS` binding and discards its body, so a + * caller can ask whether something is there without paying for it. + * + * Null when the deployment has no binding at all, which is a native-only game. + * The body is cancelled rather than left dangling; nothing here reads one. */ +async function probeAsset(assets: Fetcher | null, url: string): Promise { + if (assets === null) return null; + const res = await assets.fetch(new Request(url)); + await res.body?.cancel(); + return res; +} + +/** + * Whether a Flutter web build is actually deployed, which is what the "Play on + * the web" button depends on. + * + * The ASSETS binding alone cannot answer this: the scaffold binds it whether or + * not `public/` has anything in it, so it is bound from the first + * `wrangler dev`. Offering "Play on the web" on that evidence sends the visitor + * to `/`, which has no asset to serve and so redirects back here. Asking the + * binding for the SPA entry point answers it properly: with no `index.html` + * there is nothing for the fallback to serve, so it 404s. + */ +async function hasWebBuild(assets: Fetcher | null, origin: string): Promise { + const res = await probeAsset(assets, `${origin}/index.html`); + return res?.ok === true; +} + +/** + * Whether this game has app icons of its own, which is what the document + * shell's ``, the manifest, and the download page's logo + * depend on. + * + * A separate question from {@link hasWebBuild}, and it used to be folded into + * it on the grounds that `flutter build web` emits `favicon.png` and `icons/` + * beside `index.html`: one bundle, so one probe. That holds for a game that + * ships on the web and fails the case worth supporting, an Android-only game + * that has icons but will never have an `index.html`. Such a game dropping its + * launcher icons into `public/` should get them, and under one probe it never + * would. So: two questions, two probes. + * + * The content type is checked, not just the status. `not_found_handling` is + * `single-page-application` in the scaffold, so once `index.html` exists a + * request for a missing asset is answered *with that document*, `200 OK`. A + * game whose web build has no `favicon.png` would otherwise read as having one + * and link a page where an image belongs. + */ +async function hasAppIcons(assets: Fetcher | null, origin: string): Promise { + const res = await probeAsset(assets, `${origin}${ICONS.favicon}`); + if (res?.ok !== true) return false; + return (res.headers.get("content-type") ?? "").startsWith("image/"); +} + const LEGAL_TITLES = { "/terms": "Terms of Service", "/privacy": "Privacy Policy", "/delete-account": "Delete Account" } as const; export function registerSiteRoutes(app: EngineApp, ctx: RouteContext): void { @@ -119,10 +173,19 @@ export function registerSiteRoutes(app: EngineApp, ctx: RouteContext): void { ["/delete-account", site.legal.deleteAccount], ] as const) { const title = LEGAL_TITLES[path]; - app.get(path, (c) => + app.get(path, async (c) => c.html( renderDocument( - + , ), @@ -162,29 +225,53 @@ export function registerDownloadRoute(app: EngineApp, ctx: RouteContext): void { const name = ctx.site?.name ?? ctx.appName; const tagline = ctx.site?.tagline ?? `Play ${name} in your browser or get the native app.`; - // Icon paths match Flutter's web build. Keep the engine manifest available - // even before legal-site configuration, because Page links it from the - // out-of-box download page. + // Keep the engine manifest available even before legal-site configuration, + // because Page links it from the out-of-box download page. + // // The EigenInteractive primary, matching site.css and the Flutter shell's // default seed. It reaches the manifest and so the browser's install UI, and // a game that has not configured `site` yet should still look like something // rather than like Material's baseline purple. - const color = ctx.site?.primaryColor ?? "#006a60"; - const manifest = JSON.stringify({ - name, - short_name: name, - description: tagline, - start_url: "/", - display: "browser", - theme_color: color, - background_color: color, - icons: [ - { src: ICONS.icon192, sizes: "192x192", type: "image/png" }, - { src: ICONS.icon512, sizes: "512x512", type: "image/png" }, - { src: ICONS.maskable192, sizes: "192x192", type: "image/png", purpose: "maskable" }, - { src: ICONS.maskable512, sizes: "512x512", type: "image/png", purpose: "maskable" }, - ], - }); + const color = ctx.site?.primaryColor ?? DEFAULT_PRIMARY; + + // Two icon sets, chosen per request by what the game actually has. + // + // The PNG set is the one an install prompt wants, and its paths match + // Flutter's web build. The placeholder is a single scalable file, declared + // `sizes: "any"` because that is what the manifest spec says an SVG entry + // means, and a browser will accept it for display. It will not usually earn + // an install prompt, which is the right outcome: a game with no icons of its + // own has no web build to install either. + const appIconSet = [ + { src: ICONS.icon192, sizes: "192x192", type: "image/png" }, + { src: ICONS.icon512, sizes: "512x512", type: "image/png" }, + { src: ICONS.maskable192, sizes: "192x192", type: "image/png", purpose: "maskable" }, + { src: ICONS.maskable512, sizes: "512x512", type: "image/png", purpose: "maskable" }, + ]; + const placeholderIconSet = [{ src: ENGINE_ICON_URL, sizes: "any", type: "image/svg+xml" }]; + const manifestFor = (appIcons: boolean): string => + JSON.stringify({ + name, + short_name: name, + description: tagline, + start_url: "/", + display: "browser", + theme_color: color, + background_color: color, + icons: appIcons ? appIconSet : placeholderIconSet, + }); + + // The placeholder mark, ungated for the same reason the faces below are: + // every page the engine renders links it until the game has icons of its own, + // including a worker running `deepLink` without `site`. + // + // Not `immutable` and not fronted by `caches.default`, unlike the fonts. The + // bytes carry `site.primaryColor`, so they change when an implementor changes + // that and redeploys, which a year-long immutable cache would hide from every + // returning visitor. An hour matches the pages that link it, and the file is + // a few hundred bytes built from a string. + const iconSvg = engineIconSvg(color); + app.get(ENGINE_ICON_URL, (c) => c.body(iconSvg, 200, { "Content-Type": "image/svg+xml; charset=utf-8", "Cache-Control": PAGE_CACHE })); // The brand faces, ungated: every page the engine renders references them, // including the legal documents and the `/j` share page, and a worker running @@ -218,33 +305,14 @@ export function registerDownloadRoute(app: EngineApp, ctx: RouteContext): void { // redirect is the graceful fallback when there is no matching web asset. app.get("/", (c) => (enabled(c.env) ? c.redirect("/download", 302) : c.notFound())); - // Whether a Flutter web build is actually deployed, which two things on the - // page depend on: the "Play on the web" button, and the app icon. - // - // The ASSETS binding alone cannot answer this: the scaffold binds it whether - // or not `public/` has anything in it, so it is bound from the first - // `wrangler dev`. Offering "Play on the web" on that evidence sends the - // visitor to `/`, which has no asset to serve and so redirects back here. - // Asking the binding for the SPA entry point answers it properly: with no - // `index.html` there is nothing for the fallback to serve, so it 404s. - // - // The icons are the same question, because `flutter build web` emits - // `favicon.png` and `icons/` beside `index.html`: one bundle, so one probe. - const hasWebBuild = async (env: unknown, origin: string): Promise => { - const assets = ctx.webAssets(env); - if (assets === null) return false; - const res = await assets.fetch(new Request(`${origin}/index.html`)); - await res.body?.cancel(); - return res.ok; - }; - app.get("/download", async (c) => { if (!enabled(c.env)) return c.notFound(); const origin = new URL(c.req.url).origin; const site = ctx.site; + const assets = ctx.webAssets(c.env); const ogImage = site === null ? undefined : `${origin}${site.ogImage}`; - const web = await hasWebBuild(c.env, origin); + const [web, appIcons] = await Promise.all([hasWebBuild(assets, origin), hasAppIcons(assets, origin)]); const actions = [...(web ? [{ label: "Play on the web", url: "/" }] : []), ...stores]; return c.html( renderDocument( @@ -258,13 +326,16 @@ export function registerDownloadRoute(app: EngineApp, ctx: RouteContext): void { operatorName={site?.operator.name} madeByCredit={site?.madeByCredit} jsonLd={site === null || ogImage === undefined ? undefined : jsonLdFor(site, ctx.deepLink, origin, ogImage)} + appIcons={appIcons} >
{/* The app's own launcher icon, at twice its rendered size, falling - back to the engine's mark before there is a build to take one - from. Decorative either way: the name it stands for is the

- directly below it. */} - {web ? : } + back to the engine's mark until the game ships one. Decorative + either way: the name it stands for is the

directly below + it. Keyed to the icons rather than to the web build, so an + Android-only game that drops its launcher icons into `public/` + gets its own mark here. */} + {appIcons ? : }

{name}

{tagline}

{site !== null && site.description !== site.tagline &&

{site.description}

} @@ -282,5 +353,14 @@ export function registerDownloadRoute(app: EngineApp, ctx: RouteContext): void { ); }); - app.get("/site.webmanifest", (c) => (enabled(c.env) ? c.body(manifest, 200, { "Content-Type": "application/manifest+json", "Cache-Control": CRAWLER_CACHE }) : c.notFound())); + app.get("/site.webmanifest", async (c) => { + if (!enabled(c.env)) return c.notFound(); + const origin = new URL(c.req.url).origin; + const manifest = manifestFor(await hasAppIcons(ctx.webAssets(c.env), origin)); + // `PAGE_CACHE`, not `CRAWLER_CACHE`: this document used to be a constant, + // and now its icon list depends on what is in `public/`. Holding a day-old + // copy would leave the first deploy that adds icons still advertising the + // placeholder, which is exactly when someone is looking. + return c.body(manifest, 200, { "Content-Type": "application/manifest+json", "Cache-Control": PAGE_CACHE }); + }); } diff --git a/packages/server/src/site/icon.ts b/packages/server/src/site/icon.ts new file mode 100644 index 0000000..c169499 --- /dev/null +++ b/packages/server/src/site/icon.ts @@ -0,0 +1,56 @@ +/** + * The engine's placeholder app icon, bundled into the worker and served from it. + * + * Every page the engine renders links an icon, and until a game ships one of its + * own there is nothing in `public/` to link: a fresh scaffold's asset directory + * holds a single `.gitkeep`. Linking `favicon.png` anyway is what left the tab + * blank, so this is what the shell points at instead, on exactly the same + * reasoning as the hero's inline mark, which has always fallen back this way. + * + * A placeholder, not a default. The moment a game has icons of its own the shell + * links those instead and this route goes unused; see `hasAppIcons` in + * `routes/site.tsx` for how that is decided. + * + * SVG rather than PNG because the engine has no image pipeline and should not + * grow one: the mark is two strokes, so it is a few hundred bytes of markup that + * stays sharp at every size a browser asks for, and it can restyle itself for a + * dark tab strip, which no single raster file can. Bundled rather than served + * from `ASSETS` for the reason the fonts are: that binding holds the game's + * Flutter build, and the engine cannot put files in it. + */ + +/** The EigenInteractive primary, matching `site.css` and the Flutter shell's + * default seed. Used when a game has not configured `site.primaryColor`. */ +export const DEFAULT_PRIMARY = "#006a60"; + +/** Path the placeholder is served from, and linked by in the document shell. + * + * Shares the `/_eigen/` prefix with the bundled fonts, which keeps every + * engine-owned asset URL in one namespace a game will not collide with. The + * version segment exists for the same reason it does there: to make replacing + * the artwork a cache miss rather than something to invalidate. + */ +export const ENGINE_ICON_URL = "/_eigen/icon/v1/mark.svg"; + +/** A CSS hex colour, the only shape interpolated into the markup below. */ +const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; + +/** + * The placeholder mark, drawn in a game's own accent colour. + * + * Only the neutral stroke switches between colour schemes. The accent does not, + * because an implementor configures one `site.primaryColor` and there is no + * second value to pair it with, whereas the neutral is the stroke that actually + * vanishes against a dark tab strip. The two paths are the same geometry the + * hero's inline `EigenMark` draws; they differ only in taking literal colours, + * since a standalone document has no page tokens to inherit. + * + * `primaryColor` reaches this from implementor configuration rather than from a + * request, so it is trusted input. It is still shape-checked: the cost is one + * regex at startup, and the failure it rules out is markup injection into a file + * every visitor loads. + */ +export function engineIconSvg(primaryColor: string = DEFAULT_PRIMARY): string { + const accent = HEX.test(primaryColor) ? primaryColor : DEFAULT_PRIMARY; + return ``; +} diff --git a/packages/server/src/site/page.tsx b/packages/server/src/site/page.tsx index a3a2761..67a6f80 100644 --- a/packages/server/src/site/page.tsx +++ b/packages/server/src/site/page.tsx @@ -17,13 +17,18 @@ import type { PropsWithChildren } from "hono/jsx"; import { CREDIT_BRAND, CREDIT_URL, DEFAULT_CREDIT } from "./config.js"; import { fontFaceCss } from "./fonts.js"; +import { ENGINE_ICON_URL } from "./icon.js"; import styles from "./site.css"; /** Icon paths, defaulted to what `flutter_launcher_icons` already emits into a * Flutter app's `web/` directory (see the * {@link https://eigeninteractive.com/docs/ship-it/branding | branding guide}). * An implementor who builds the app has these files already and copies them - * into `public/`; nobody has to author a second icon set for the web. */ + * into `public/`; nobody has to author a second icon set for the web. + * + * These are asset paths, so they are only correct once such a file exists. A + * page renders them when `appIcons` says so and falls back to + * {@link ENGINE_ICON_URL} when it does not. */ export const ICONS = { favicon: "/favicon.png", appleTouch: "/icons/Icon-192.png", @@ -57,6 +62,13 @@ export interface PageProps { operatorName?: string; /** Footer credit line. Omitted → {@link DEFAULT_CREDIT}; `null` removes it. */ madeByCredit?: string | null; + /** Whether this game has icons of its own in `public/`. `true` links + * {@link ICONS}; omitted or `false` links the engine placeholder. + * + * Defaults to the placeholder because that is the answer that is always + * serveable. A page that guesses wrong this way shows the engine's mark; the + * other way round it shows nothing at all. */ + appIcons?: boolean; } /** Every link the engine renders leaves the page it is on rather than replacing @@ -176,8 +188,18 @@ export function Page(props: PropsWithChildren) { - - + {/* Apple's touch icon has no SVG support, so a game without its own + icons gets no such link rather than one pointing at a file that is + not there. iOS then derives its home-screen icon from the page, + which is a better answer than a broken request. */} + {props.appIcons === true ? ( + <> + + + + ) : ( + + )} {/* The faces first, so the rules that use them are already declared by the time the cascade reaches them. Generated from the same table the diff --git a/packages/server/test/site.spec.ts b/packages/server/test/site.spec.ts index 4f360dc..c94feaa 100644 --- a/packages/server/test/site.spec.ts +++ b/packages/server/test/site.spec.ts @@ -24,6 +24,7 @@ import { exports } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; import { createEngine } from "../src/engine.js"; import { CREDIT_URL } from "../src/site/config.js"; +import { ENGINE_ICON_URL } from "../src/site/icon.js"; import { renderLegal } from "../src/site/legal/index.js"; import { ICONS, onPrimary } from "../src/site/page.js"; @@ -84,19 +85,24 @@ describe("landing page", () => { it("offers neither the web button nor the app icon without a deployed web build", async () => { // Both would be broken promises: `/` has no asset to serve and would bounce - // straight back here, and the icons ship inside the same Flutter bundle. - // - // KNOWN GAP: the other side of this branch is untested. Proving it needs an - // `ASSETS` binding with a real `index.html`, which would also make Static - // Assets answer `/` and invalidate the web-root fallback test below. + // straight back here, and there is no icon file to point at. const html = await (await get("/download")).text(); expect(html).not.toContain("Play on the web"); - // Not `ICONS.icon192` on its own: the same path is the apple-touch-icon in - // every page's , which is unconditional and correct. Only the hero - // image is gated. expect(html).not.toContain(` { + // The whole point of the placeholder. `favicon.png` and the apple-touch + // icon live in `public/`, which is empty here, so linking them would leave + // the tab blank; the engine's own mark is always serveable. + const html = await (await get("/download")).text(); + expect(html).toContain(``); + expect(html).not.toContain(`href="${ICONS.favicon}"`); + // Apple's touch icon has no SVG support, so this game gets none at all + // rather than one pointing at a missing file. + expect(html).not.toContain("apple-touch-icon"); + }); + it("stands the engine's mark in for the icon it does not have yet", async () => { const html = await (await get("/download")).text(); expect(html).toContain(' { + const res = await withAssets({ [ICONS.favicon]: png })("/site.webmanifest"); + const manifest = (await res.json()) as { icons: { src: string; purpose?: string }[] }; + // The Flutter app already generates these into web/icons/, so an + // implementor copies that folder rather than authoring a second icon set. + expect(manifest.icons.map((i) => i.src)).toEqual([ICONS.icon192, ICONS.icon512, ICONS.maskable192, ICONS.maskable512]); + expect(manifest.icons.filter((i) => i.purpose === "maskable")).toHaveLength(2); + }); + + it("does not need a web build, only icons", async () => { + // The case that motivated splitting the probe: an Android-only game has no + // `index.html` and never will, and its launcher icons are still its own. + const html = await (await withAssets({ [ICONS.favicon]: png })("/download")).text(); + expect(html).toContain(``); + expect(html).not.toContain("Play on the web"); + }); + + it("is not fooled by the SPA fallback answering for a missing icon", async () => { + // `not_found_handling` is `single-page-application` in the scaffold, so + // once `index.html` exists a request for an absent asset returns that + // document with `200 OK`. Status alone would read as an icon and link a + // web page where an image belongs; the content type is what settles it. + const spa = { body: "", type: "text/html" }; + const html = await (await withAssets({ "/index.html": spa, [ICONS.favicon]: spa })("/download")).text(); + expect(html).toContain(``); + expect(html).not.toContain(``); + // The web build is real, though, so that half is still offered. + expect(html).toContain("Play on the web"); + }); +}); + describe("onPrimary", () => { it("picks the ink that stays readable on the configured colour", () => { // Both ends of the engine's own palette: the light scheme's primary is dark