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
This commit is contained in:
nesgarbo
2026-04-15 11:25:16 +02:00
committed by Linus Rath
parent c690e8eb76
commit 8a9dce1a99
4 changed files with 120 additions and 1 deletions
+66
View File
@@ -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<number, Blob>();
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
// 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 });
}
}
-1
View File
@@ -22,7 +22,6 @@ export async function generateMetadata(): Promise<Metadata> {
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",
+53
View File
@@ -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" },
],
};
}
+1
View File
@@ -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<Response> {
if (input.startsWith('/') && !input.startsWith('//')) {
return fetch(getPathPrefix() + input, init);