|
1 | 1 | // Same-origin PWA manifest for white-label instances. |
2 | 2 | // |
3 | | -// The web app manifest must be served from the SAME origin as the page, |
4 | | -// otherwise its start_url/scope resolve to the API origin and the manifest |
5 | | -// no longer applies to the document — Chrome never fires beforeinstallprompt |
6 | | -// (no install button) and iOS won't reliably enter standalone mode. |
| 3 | +// The web app manifest MUST be served from the same origin as the page, |
| 4 | +// otherwise its start_url/scope resolve to a different origin and the manifest |
| 5 | +// stops applying to the document — Chrome never fires beforeinstallprompt (no |
| 6 | +// install button) and iOS won't reliably enter standalone mode. |
7 | 7 | // |
8 | | -// So we proxy the API's dynamic manifest (brand name + colors) through the |
9 | | -// web origin. start_url ("/") resolves to the web origin here; the icon URLs |
10 | | -// in the API manifest are absolute (API origin), and cross-origin icons are |
11 | | -// allowed by the spec. |
| 8 | +// So instead of pointing <link rel="manifest"> at the API, we build the |
| 9 | +// manifest here (same Hasura-admin pattern as server/routes/clips/[id].get.ts) |
| 10 | +// and serve it from the web origin. start_url ("/") resolves to the web origin; |
| 11 | +// the favicon icon is an absolute API URL, and cross-origin icons are allowed. |
| 12 | + |
| 13 | +const SETTINGS_QUERY = `query BrandingManifest { |
| 14 | + settings(where: { name: { _in: [ |
| 15 | + "public.brand_name", |
| 16 | + "public.favicon_url", |
| 17 | + "public.color_dark_background", |
| 18 | + "public.color_dark_primary" |
| 19 | + ] } }) { |
| 20 | + name |
| 21 | + value |
| 22 | + } |
| 23 | +}`; |
| 24 | + |
| 25 | +// Stored color settings are shadcn space-separated HSL (e.g. "240 10% 3.9%"). |
| 26 | +function toCssColor(value: string | null | undefined): string { |
| 27 | + if (!value) return "#000000"; |
| 28 | + const trimmed = value.trim(); |
| 29 | + if (trimmed.startsWith("#") || trimmed.startsWith("hsl")) return trimmed; |
| 30 | + return `hsl(${trimmed})`; |
| 31 | +} |
| 32 | + |
| 33 | +function guessContentType(path: string): string { |
| 34 | + if (path.endsWith(".svg")) return "image/svg+xml"; |
| 35 | + if (path.endsWith(".webp")) return "image/webp"; |
| 36 | + if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg"; |
| 37 | + if (path.endsWith(".ico")) return "image/x-icon"; |
| 38 | + return "image/png"; |
| 39 | +} |
12 | 40 |
|
13 | 41 | export default defineEventHandler(async (event) => { |
14 | 42 | setResponseHeader(event, "Content-Type", "application/manifest+json"); |
15 | 43 | setResponseHeader(event, "Cache-Control", "public, max-age=60"); |
16 | 44 |
|
17 | 45 | const apiDomain = process.env.NUXT_PUBLIC_API_DOMAIN; |
18 | | - if (!apiDomain) { |
| 46 | + const adminSecret = process.env.HASURA_GRAPHQL_ADMIN_SECRET; |
| 47 | + |
| 48 | + // No backend wired up — fall back to the static build manifest. |
| 49 | + if (!apiDomain || !adminSecret) { |
19 | 50 | return sendRedirect(event, "/manifest.webmanifest", 302); |
20 | 51 | } |
21 | 52 |
|
| 53 | + let settings: Array<{ name: string; value: string }> = []; |
22 | 54 | try { |
23 | | - return await $fetch(`https://${apiDomain}/branding/manifest.webmanifest`); |
| 55 | + const res = await $fetch<{ data?: { settings?: typeof settings } }>( |
| 56 | + `https://${apiDomain}/v1/graphql`, |
| 57 | + { |
| 58 | + method: "POST", |
| 59 | + headers: { |
| 60 | + "Content-Type": "application/json", |
| 61 | + "x-hasura-admin-secret": adminSecret, |
| 62 | + }, |
| 63 | + body: { query: SETTINGS_QUERY }, |
| 64 | + }, |
| 65 | + ); |
| 66 | + settings = res?.data?.settings ?? []; |
24 | 67 | } catch (err) { |
25 | | - console.error("[branding-manifest] fetch failed:", err); |
26 | | - // Fall back to the static build manifest rather than serving nothing. |
| 68 | + console.error("[branding-manifest] settings fetch failed:", err); |
27 | 69 | return sendRedirect(event, "/manifest.webmanifest", 302); |
28 | 70 | } |
| 71 | + |
| 72 | + const get = (name: string) => settings.find((s) => s.name === name)?.value; |
| 73 | + |
| 74 | + const brandName = get("public.brand_name") || "5Stack"; |
| 75 | + const faviconUrl = get("public.favicon_url"); |
| 76 | + |
| 77 | + // Cross-origin favicon (API origin) is fine; the manifest itself is what must |
| 78 | + // be same-origin. Static fallbacks are web-origin relative paths. |
| 79 | + const icons = faviconUrl |
| 80 | + ? [ |
| 81 | + { |
| 82 | + src: `https://${apiDomain}/branding/favicon?v=${encodeURIComponent(faviconUrl)}`, |
| 83 | + sizes: "192x192 512x512", |
| 84 | + type: guessContentType(faviconUrl), |
| 85 | + }, |
| 86 | + { |
| 87 | + src: `https://${apiDomain}/branding/favicon?v=${encodeURIComponent(faviconUrl)}`, |
| 88 | + sizes: "any", |
| 89 | + type: guessContentType(faviconUrl), |
| 90 | + purpose: "any", |
| 91 | + }, |
| 92 | + ] |
| 93 | + : [ |
| 94 | + { src: "/favicon/192.png", sizes: "192x192", type: "image/png" }, |
| 95 | + { |
| 96 | + src: "/favicon/512.png", |
| 97 | + sizes: "512x512", |
| 98 | + type: "image/png", |
| 99 | + purpose: "any", |
| 100 | + }, |
| 101 | + ]; |
| 102 | + |
| 103 | + return { |
| 104 | + name: brandName, |
| 105 | + short_name: brandName, |
| 106 | + icons, |
| 107 | + theme_color: toCssColor(get("public.color_dark_primary")), |
| 108 | + background_color: toCssColor(get("public.color_dark_background")), |
| 109 | + display: "standalone", |
| 110 | + start_url: "/", |
| 111 | + }; |
29 | 112 | }); |
0 commit comments