From 8a9dce1a991e840b2668db42458c714ea0475103 Mon Sep 17 00:00:00 2001 From: nesgarbo Date: Wed, 15 Apr 2026 10:15:42 +0200 Subject: [PATCH] feat: dynamic PWA manifest with configurable name, description and icons - Add app/manifest.ts to serve /manifest.webmanifest dynamically at runtime - Name, short_name, description, theme_color and background_color are read from env vars (APP_NAME, APP_SHORT_NAME, APP_DESCRIPTION, PWA_THEME_COLOR, PWA_BACKGROUND_COLOR) with Bulwark defaults as fallback - Add /api/pwa-icon/[size] route that auto-generates 192x192 and 512x512 PNG icons from PWA_ICON_URL (or FAVICON_URL as fallback) using Sharp; results are cached in memory - Remove static manifest: '/manifest.json' from layout metadata; Next.js injects the link automatically from app/manifest.ts - Fix pre-existing ESLint no-undef on RequestInit in browser-navigation.ts --- app/api/pwa-icon/[size]/route.ts | 66 ++++++++++++++++++++++++++++++++ app/layout.tsx | 1 - app/manifest.ts | 53 +++++++++++++++++++++++++ lib/browser-navigation.ts | 1 + 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 app/api/pwa-icon/[size]/route.ts create mode 100644 app/manifest.ts diff --git a/app/api/pwa-icon/[size]/route.ts b/app/api/pwa-icon/[size]/route.ts new file mode 100644 index 00000000..8b42dbb7 --- /dev/null +++ b/app/api/pwa-icon/[size]/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import sharp from 'sharp'; +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; + +const VALID_SIZES = new Set([192, 512]); + +// Cache resized images in memory to avoid reprocessing on every request +const cache = new Map(); + +async function fetchSourceImage(iconUrl: string): Promise { + // Absolute URL (http/https) + if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) { + const res = await fetch(iconUrl); + if (!res.ok) throw new Error(`Failed to fetch PWA icon: ${res.status}`); + return Buffer.from(await res.arrayBuffer()); + } + + // Path relative to public/ directory + const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, '')); + return readFile(publicPath); +} + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ size: string }> } +) { + const { size: sizeParam } = await params; + const size = parseInt(sizeParam, 10); + + if (!VALID_SIZES.has(size)) { + return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 }); + } + + const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL; + if (!iconUrl) { + return new NextResponse('No PWA icon configured', { status: 404 }); + } + + const pngHeaders = { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=86400', + }; + + try { + if (cache.has(size)) { + return new NextResponse(cache.get(size)!, { headers: pngHeaders }); + } + + const sourceBuffer = await fetchSourceImage(iconUrl); + const resized = await sharp(sourceBuffer) + .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .png() + .toBuffer(); + + const ab = new ArrayBuffer(resized.byteLength); + new Uint8Array(ab).set(resized); + const blob = new Blob([ab], { type: 'image/png' }); + cache.set(size, blob); + + return new NextResponse(blob, { headers: pngHeaders }); + } catch (err) { + console.error('Failed to generate PWA icon:', err); + return new NextResponse('Failed to generate icon', { status: 500 }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index f23d8a25..c2a54606 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -22,7 +22,6 @@ export async function generateMetadata(): Promise { return { title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail", description: "Minimalist webmail client using JMAP protocol", - manifest: "/manifest.json", appleWebApp: { capable: true, statusBarStyle: "black-translucent", diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 00000000..b3684866 --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,53 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + const appName = + process.env.APP_NAME || + process.env.NEXT_PUBLIC_APP_NAME || + "Bulwark Webmail"; + + const shortName = process.env.APP_SHORT_NAME || appName; + const description = + process.env.APP_DESCRIPTION || + "A modern webmail client built for Stalwart Mail Server"; + const themeColor = process.env.PWA_THEME_COLOR || "#ffffff"; + const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff"; + + // If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs + // via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs. + const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL); + + const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon + ? [ + { src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" }, + { src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" }, + { src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" }, + { src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" }, + ] + : [ + { src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" }, + { src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" }, + { src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" }, + { src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, + { src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" }, + { src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, + ]; + + return { + name: appName, + short_name: shortName, + description, + start_url: "/", + scope: "/", + display: "standalone", + orientation: "portrait-primary", + theme_color: themeColor, + background_color: backgroundColor, + icons, + categories: ["productivity"], + screenshots: [ + { src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" }, + { src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" }, + ], + }; +} diff --git a/lib/browser-navigation.ts b/lib/browser-navigation.ts index 7f5a2c11..93a9c487 100644 --- a/lib/browser-navigation.ts +++ b/lib/browser-navigation.ts @@ -57,6 +57,7 @@ export function getPathPrefix(locale?: string): string { * // Browser at /webmail/en/inbox → /webmail/api/jmap * // Browser at /en/inbox → /api/jmap */ +// eslint-disable-next-line no-undef export function apiFetch(input: string, init?: RequestInit): Promise { if (input.startsWith('/') && !input.startsWith('//')) { return fetch(getPathPrefix() + input, init);