Skip to content

Commit 7bd4c81

Browse files
authored
chore: update pwa manifest endpoint (#457)
1 parent 68e3921 commit 7bd4c81

1 file changed

Lines changed: 95 additions & 12 deletions

File tree

Lines changed: 95 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,112 @@
11
// Same-origin PWA manifest for white-label instances.
22
//
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.
77
//
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+
}
1240

1341
export default defineEventHandler(async (event) => {
1442
setResponseHeader(event, "Content-Type", "application/manifest+json");
1543
setResponseHeader(event, "Cache-Control", "public, max-age=60");
1644

1745
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) {
1950
return sendRedirect(event, "/manifest.webmanifest", 302);
2051
}
2152

53+
let settings: Array<{ name: string; value: string }> = [];
2254
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 ?? [];
2467
} 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);
2769
return sendRedirect(event, "/manifest.webmanifest", 302);
2870
}
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+
};
29112
});

0 commit comments

Comments
 (0)