mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: honor configured PWA theme color in desktop title bar #671
This commit is contained in:
+27
-15
@@ -10,6 +10,7 @@ import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
type BrandingOverrideKey,
|
||||
} from "@/lib/admin/domain-branding";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { locales } from "@/i18n/routing";
|
||||
@@ -37,11 +38,31 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
// Resolve a branding value for the requesting host: per-domain override first,
|
||||
// then the global admin/env/default value (same precedence as app/manifest.ts).
|
||||
async function brandedValue(key: BrandingOverrideKey, fallback: string): Promise<string> {
|
||||
const host = pickRequestHost(await headers());
|
||||
const override = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
||||
)[key];
|
||||
if (typeof override === "string" && override.length > 0) return override;
|
||||
return configManager.get<string>(key, fallback);
|
||||
}
|
||||
|
||||
export async function generateViewport(): Promise<Viewport> {
|
||||
await configManager.ensureLoaded();
|
||||
// Chromium uses <meta name="theme-color"> in preference to the manifest's
|
||||
// theme_color when coloring an installed desktop PWA's title bar, so a
|
||||
// hardcoded value here silently overrode the admin setting (#671). Emit the
|
||||
// configured colour instead, honoring per-domain branding like the manifest.
|
||||
return {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: (await brandedValue("pwaThemeColor", "")) || "#ffffff",
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
await configManager.ensureLoaded();
|
||||
@@ -49,15 +70,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
// /api/config, app/manifest.ts, and /api/pwa-icon already do. Resolve the
|
||||
// request host and prefer its override; fall back to the global
|
||||
// admin/env/default value when the host has no favicon override (#585).
|
||||
const host = pickRequestHost(await headers());
|
||||
const domainOverride = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
||||
).faviconUrl;
|
||||
const faviconUrl =
|
||||
domainOverride && domainOverride.length > 0
|
||||
? domainOverride
|
||||
: configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||
const faviconUrl = await brandedValue("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||
// Localize the <head> description to match the UI language; a hardcoded
|
||||
// English description is another signal that makes Chrome offer to
|
||||
// "translate this page". Resolve the locale from the request path, since this
|
||||
@@ -97,7 +110,6 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
|
||||
// The root layout pulls in globals.css, next/font and client components purely
|
||||
// for rendering; stub them so the test can import generateViewport in isolation.
|
||||
// globals.css would otherwise drag Tailwind's PostCSS pipeline into vitest.
|
||||
vi.mock('@/app/globals.css', () => ({}));
|
||||
vi.mock('next/font/google', () => ({
|
||||
Geist: () => ({ variable: '--font-geist-sans' }),
|
||||
Geist_Mono: () => ({ variable: '--font-geist-mono' }),
|
||||
}));
|
||||
vi.mock('@/components/service-worker-registration', () => ({ ServiceWorkerRegistration: () => null }));
|
||||
vi.mock('@/components/favicon-badge', () => ({ FaviconBadge: () => null }));
|
||||
vi.mock('next-intl/server', () => ({
|
||||
getLocale: async () => 'en',
|
||||
getTranslations: async () => (k: string) => k,
|
||||
}));
|
||||
vi.mock('next/headers', () => ({ headers: vi.fn() }));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: vi.fn(async () => {}), get: vi.fn() },
|
||||
}));
|
||||
|
||||
import { generateViewport } from '@/app/(main)/layout';
|
||||
import { headers } from 'next/headers';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
const mockHeaders = headers as unknown as Mock;
|
||||
const mockGet = configManager.get as unknown as Mock;
|
||||
|
||||
/** Config values keyed by admin key; anything unset falls back to the caller's default. */
|
||||
function withConfig(values: Record<string, unknown>) {
|
||||
mockGet.mockImplementation((key: string, fallback: unknown) =>
|
||||
key in values ? values[key] : fallback,
|
||||
);
|
||||
}
|
||||
function onHost(host: string) {
|
||||
mockHeaders.mockResolvedValue(new Headers({ host }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
onHost('mail.example.com');
|
||||
withConfig({});
|
||||
});
|
||||
|
||||
describe('root layout generateViewport - theme color (#671)', () => {
|
||||
it('emits the admin-configured theme color, not a hardcoded white', async () => {
|
||||
withConfig({ pwaThemeColor: '#1a1b26' });
|
||||
expect((await generateViewport()).themeColor).toBe('#1a1b26');
|
||||
});
|
||||
|
||||
it('prefers a per-domain branding override for the requesting host', async () => {
|
||||
withConfig({
|
||||
pwaThemeColor: '#1a1b26',
|
||||
domainBranding: [{ host: 'other.example.com', pwaThemeColor: '#7c3aed' }],
|
||||
});
|
||||
onHost('other.example.com');
|
||||
expect((await generateViewport()).themeColor).toBe('#7c3aed');
|
||||
|
||||
onHost('mail.example.com');
|
||||
expect((await generateViewport()).themeColor).toBe('#1a1b26');
|
||||
});
|
||||
|
||||
it('falls back to white when nothing is configured', async () => {
|
||||
withConfig({ pwaThemeColor: '' });
|
||||
expect((await generateViewport()).themeColor).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('keeps the viewport sizing fields intact', async () => {
|
||||
const viewport = await generateViewport();
|
||||
expect(viewport.width).toBe('device-width');
|
||||
expect(viewport.initialScale).toBe(1);
|
||||
expect(viewport.viewportFit).toBe('cover');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user