import type { MetadataRoute } from "next"; import { headers } from "next/headers"; import { configManager } from "@/lib/admin/config-manager"; import { matchDomainBranding, parseDomainBranding, pickRequestHost, type BrandingOverrideKey, } from "@/lib/admin/domain-branding"; export const dynamic = "force-dynamic"; type WebAppProtocolHandler = { protocol: string; url: string; }; type ExtendedManifest = MetadataRoute.Manifest & { protocol_handlers?: WebAppProtocolHandler[]; launch_handler?: { client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new" | Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">; }; }; // Manifest paths must include the deployment subpath - browsers resolve them // against the document origin, not the manifest's location, and Next.js does // not auto-prefix string literals inside MetadataRoute payloads. const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, ""); const withBase = (p: string) => `${BASE_PATH}${p}`; export default async function manifest(): Promise { await configManager.ensureLoaded(); const host = pickRequestHost(await headers()); const domainOverrides = matchDomainBranding( host, parseDomainBranding(configManager.get("domainBranding", [])), ); const branded = (key: BrandingOverrideKey, fallback: T): T => { const override = domainOverrides[key]; if (typeof override === "string" && override.length > 0) return override as T; return configManager.get(key, fallback); }; const appName = branded("appName", "") || process.env.NEXT_PUBLIC_APP_NAME || "VNCmail+"; const shortName = branded("appShortName", "") || appName; const description = branded("appDescription", "") || "A modern webmail client built for Stalwart Mail Server"; const themeColor = branded("pwaThemeColor", "") || "#ffffff"; const backgroundColor = branded("pwaBackgroundColor", "") || "#ffffff"; // If pwaIconUrl or faviconUrl was explicitly configured (admin override, // env var, or per-domain override), serve dynamically resized PNGs via // /api/pwa-icon/[size]. Otherwise fall back to the static Bulwark PNGs - // sources marked "default" are the built-in placeholder paths and not // real custom icons. const sources = configManager.getAllWithSources(); const hasCustomIcon = !!domainOverrides.pwaIconUrl || !!domainOverrides.faviconUrl || sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default"; const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon ? [ { src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "any" }, { src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "any" }, { src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "maskable" }, { src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "maskable" }, ] : [ { src: withBase("/icon-192x192.png"), sizes: "192x192", type: "image/png", purpose: "any" }, { src: withBase("/icon-512x512.png"), sizes: "512x512", type: "image/png", purpose: "any" }, { src: withBase("/icon-maskable-light-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" }, { src: withBase("/icon-maskable-light-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" }, { src: withBase("/icon-maskable-dark-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" }, { src: withBase("/icon-maskable-dark-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" }, ]; return { name: appName, short_name: shortName, description, start_url: withBase("/"), scope: withBase("/"), display: "standalone", orientation: "portrait-primary", theme_color: themeColor, background_color: backgroundColor, icons, categories: ["productivity"], // Use admin-uploaded screenshots when configured (per-domain override, // admin/env global; resized on the fly via /api/pwa-screenshot/[variant]); // otherwise fall back to the built-in Bulwark screenshots from public/. screenshots: (() => { const hasMobile = !!domainOverrides.pwaScreenshotMobileUrl || sources.pwaScreenshotMobileUrl?.source !== "default"; const hasDesktop = !!domainOverrides.pwaScreenshotDesktopUrl || sources.pwaScreenshotDesktopUrl?.source !== "default"; return [ hasMobile ? { src: withBase("/api/pwa-screenshot/mobile"), sizes: "540x720", type: "image/png" } : { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" }, hasDesktop ? { src: withBase("/api/pwa-screenshot/desktop"), sizes: "1280x720", type: "image/png" } : { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" }, ]; })(), protocol_handlers: [ { protocol: "mailto", url: withBase("/protocol/mailto?url=%s") }, { protocol: "webcal", url: withBase("/protocol/webcal?url=%s") }, ], launch_handler: { client_mode: ["focus-existing", "navigate-new"], }, }; }