Product decision 2026-08-05: exactly SRC (default) and VNClagoon ship as selectable themes. Everything else (Qui, Nord, Catppuccin, Solarized, Roundcube Elastic, Aurora Glass) is hidden via ThemePolicy.disabledBuiltinThemes rather than deleted - cheap to re-enable later, zero code lost. Also removed the hardcoded "Default/Bulwark" theme card from the Settings > Themes grid (product wants exactly 2 theme choices, not 3). Separately, found and fixed real "still says Bulwark" branding gaps: - All 24 locale files: "Bulwark"/"Bulwark Mail"/"Bulwark Webmail" -> "VNCmail+" in every user-facing string (verified via the translations test, which only checks structural key parity across locales, not content - a straight string swap is safe against it. 48/48 still pass). - PWA manifest fallback app name, package.json description/author. - Demo mode fixtures: the "Welcome to Bulwark Mail!" email and identity signature a first-time demo user actually sees. - The demo empty-state's logo was hardcoded to a literal Bulwark SVG file regardless of active theme - a real bug, not just stale text, since lib/theme-logo.ts's resolveThemeLogo() already exists and is already used correctly by the login page and nav rail for exactly this (theme-aware SRC mark / VNClagoon wordmark). Wired the same helper in here instead of a hardcoded path. NOT touched: internal code comments referencing "Bulwark" as historical/ attribution context (e.g. design-rationale comments explaining why a value differs from Bulwark's original) - those are harmless and a full-repo sweep of every comment wasn't the ask. Verified: typecheck clean, lint clean, translations test 48/48 passing.
126 lines
5.3 KiB
TypeScript
126 lines
5.3 KiB
TypeScript
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<ExtendedManifest> {
|
|
await configManager.ensureLoaded();
|
|
|
|
const host = pickRequestHost(await headers());
|
|
const domainOverrides = matchDomainBranding(
|
|
host,
|
|
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
|
);
|
|
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
|
|
const override = domainOverrides[key];
|
|
if (typeof override === "string" && override.length > 0) return override as T;
|
|
return configManager.get<T>(key, fallback);
|
|
};
|
|
|
|
const appName =
|
|
branded<string>("appName", "") ||
|
|
process.env.NEXT_PUBLIC_APP_NAME ||
|
|
"VNCmail+";
|
|
|
|
const shortName = branded<string>("appShortName", "") || appName;
|
|
const description =
|
|
branded<string>("appDescription", "") ||
|
|
"A modern webmail client built for Stalwart Mail Server";
|
|
const themeColor = branded<string>("pwaThemeColor", "") || "#ffffff";
|
|
const backgroundColor = branded<string>("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"],
|
|
},
|
|
};
|
|
}
|