Feature: configurable PWA install screenshots (per-domain)
Admins can upload custom mobile/desktop screenshots shown in the browser's PWA install dialog, replacing the hardcoded Bulwark ones. Two new config keys (pwaScreenshotMobileUrl/DesktopUrl), upload widgets in the admin Branding tab, a sharp-based /api/pwa-screenshot/[variant] resize route, and manifest.ts picks the custom screenshots when configured. Like the other branding fields, screenshots are per-domain: they are BRANDING_OVERRIDE_KEYS, the manifest and the /api/pwa-screenshot route resolve them from the request host (domain override -> global -> Bulwark default), and the admin Branding tab + upload/delete route handle them in a per-domain scope, mirroring pwaIconUrl/faviconUrl.
This commit is contained in:
@@ -33,6 +33,8 @@ const TEXT_FIELDS = [
|
|||||||
|
|
||||||
const PWA_IMAGE_FIELDS = [
|
const PWA_IMAGE_FIELDS = [
|
||||||
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'pwaScreenshotMobileUrl', label: 'PWA Screenshot (Mobile)', accept: '.png,.jpg,.webp' },
|
||||||
|
{ key: 'pwaScreenshotDesktopUrl', label: 'PWA Screenshot (Desktop)', accept: '.png,.jpg,.webp' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const PWA_TEXT_FIELDS = [
|
const PWA_TEXT_FIELDS = [
|
||||||
|
|||||||
@@ -26,14 +26,18 @@ const ALLOWED_MIME_TYPES = new Set([
|
|||||||
'image/vnd.microsoft.icon',
|
'image/vnd.microsoft.icon',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
type UploadSlot = BrandingOverrideKey;
|
||||||
|
|
||||||
/** Slots that correspond to branding config keys */
|
/** Slots that correspond to branding config keys */
|
||||||
const VALID_SLOTS = new Set<BrandingOverrideKey>([
|
const VALID_SLOTS = new Set<UploadSlot>([
|
||||||
'faviconUrl',
|
'faviconUrl',
|
||||||
'pwaIconUrl',
|
'pwaIconUrl',
|
||||||
'appLogoLightUrl',
|
'appLogoLightUrl',
|
||||||
'appLogoDarkUrl',
|
'appLogoDarkUrl',
|
||||||
'loginLogoLightUrl',
|
'loginLogoLightUrl',
|
||||||
'loginLogoDarkUrl',
|
'loginLogoDarkUrl',
|
||||||
|
'pwaScreenshotMobileUrl',
|
||||||
|
'pwaScreenshotDesktopUrl',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const EXT_BY_MIME: Record<string, string> = {
|
const EXT_BY_MIME: Record<string, string> = {
|
||||||
@@ -131,7 +135,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_SLOTS.has(slot as BrandingOverrideKey)) {
|
if (!VALID_SLOTS.has(slot as UploadSlot)) {
|
||||||
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +230,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const slot = body.slot;
|
const slot = body.slot;
|
||||||
const rawHost = body.host ?? '';
|
const rawHost = body.host ?? '';
|
||||||
|
|
||||||
if (!slot || !VALID_SLOTS.has(slot as BrandingOverrideKey)) {
|
if (!slot || !VALID_SLOTS.has(slot as UploadSlot)) {
|
||||||
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { getConfigDir } from '@/lib/admin/paths';
|
||||||
|
import {
|
||||||
|
matchDomainBranding,
|
||||||
|
parseDomainBranding,
|
||||||
|
pickRequestHost,
|
||||||
|
} from '@/lib/admin/domain-branding';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant → target output size + admin config key.
|
||||||
|
* Matches the sizes declared in app/manifest.ts so the rendered PNG fits
|
||||||
|
* the slot the manifest tells the browser about.
|
||||||
|
*/
|
||||||
|
const VARIANTS = {
|
||||||
|
mobile: { width: 540, height: 720, configKey: 'pwaScreenshotMobileUrl' as const },
|
||||||
|
desktop: { width: 1280, height: 720, configKey: 'pwaScreenshotDesktopUrl' as const },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type Variant = keyof typeof VARIANTS;
|
||||||
|
|
||||||
|
// Cache resized images keyed by (variant, source URL).
|
||||||
|
const cache = new Map<string, Blob>();
|
||||||
|
|
||||||
|
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||||
|
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||||
|
const res = await fetch(iconUrl);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch PWA screenshot: ${res.status}`);
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
|
||||||
|
// but stored on disk under getConfigDir()/branding/.
|
||||||
|
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
|
||||||
|
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
|
||||||
|
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
|
||||||
|
return readFile(path.join(getConfigDir(), 'branding', filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<{ variant: string }> },
|
||||||
|
) {
|
||||||
|
const { variant: variantParam } = await params;
|
||||||
|
if (!(variantParam in VARIANTS)) {
|
||||||
|
return new NextResponse('Invalid variant. Allowed: mobile, desktop', { status: 400 });
|
||||||
|
}
|
||||||
|
const { width, height, configKey } = VARIANTS[variantParam as Variant];
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const host = pickRequestHost(req);
|
||||||
|
const domainOverrides = matchDomainBranding(
|
||||||
|
host,
|
||||||
|
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||||
|
);
|
||||||
|
const sources = configManager.getAllWithSources();
|
||||||
|
const sourceEntry = sources[configKey];
|
||||||
|
const screenshotUrl =
|
||||||
|
domainOverrides[configKey] ||
|
||||||
|
(sourceEntry?.source !== 'default' ? (sourceEntry?.value as string | undefined) : undefined);
|
||||||
|
if (!screenshotUrl) {
|
||||||
|
return new NextResponse('No PWA screenshot configured', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngHeaders = {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
Vary: 'Host, X-Forwarded-Host',
|
||||||
|
};
|
||||||
|
const cacheKey = `${variantParam}|${screenshotUrl}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cache.has(cacheKey)) {
|
||||||
|
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceBuffer = await fetchSourceImage(screenshotUrl);
|
||||||
|
// 'cover' fills the target box without letterboxing - screenshots benefit
|
||||||
|
// more from cropping than from a transparent frame around them. Users get
|
||||||
|
// a hint about the recommended aspect ratio in the admin UI.
|
||||||
|
const resized = await sharp(sourceBuffer)
|
||||||
|
.resize(width, height, { fit: 'cover', position: 'center' })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const ab = new ArrayBuffer(resized.byteLength);
|
||||||
|
new Uint8Array(ab).set(resized);
|
||||||
|
const blob = new Blob([ab], { type: 'image/png' });
|
||||||
|
cache.set(cacheKey, blob);
|
||||||
|
|
||||||
|
return new NextResponse(blob, { headers: pngHeaders });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate PWA screenshot:', err);
|
||||||
|
return new NextResponse('Failed to generate screenshot', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,10 +23,13 @@ const ALLOWED_MIME_TYPES = new Set([
|
|||||||
|
|
||||||
const VALID_SLOTS = new Set([
|
const VALID_SLOTS = new Set([
|
||||||
'faviconUrl',
|
'faviconUrl',
|
||||||
|
'pwaIconUrl',
|
||||||
'appLogoLightUrl',
|
'appLogoLightUrl',
|
||||||
'appLogoDarkUrl',
|
'appLogoDarkUrl',
|
||||||
'loginLogoLightUrl',
|
'loginLogoLightUrl',
|
||||||
'loginLogoDarkUrl',
|
'loginLogoDarkUrl',
|
||||||
|
'pwaScreenshotMobileUrl',
|
||||||
|
'pwaScreenshotDesktopUrl',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const EXT_BY_MIME: Record<string, string> = {
|
const EXT_BY_MIME: Record<string, string> = {
|
||||||
|
|||||||
+19
-4
@@ -95,10 +95,25 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
|||||||
background_color: backgroundColor,
|
background_color: backgroundColor,
|
||||||
icons,
|
icons,
|
||||||
categories: ["productivity"],
|
categories: ["productivity"],
|
||||||
screenshots: [
|
// Use admin-uploaded screenshots when configured (per-domain override,
|
||||||
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
// admin/env global; resized on the fly via /api/pwa-screenshot/[variant]);
|
||||||
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
// 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_handlers: [
|
||||||
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
||||||
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const BRANDING_OVERRIDE_KEYS = [
|
|||||||
'appDescription',
|
'appDescription',
|
||||||
'faviconUrl',
|
'faviconUrl',
|
||||||
'pwaIconUrl',
|
'pwaIconUrl',
|
||||||
|
'pwaScreenshotMobileUrl',
|
||||||
|
'pwaScreenshotDesktopUrl',
|
||||||
'pwaThemeColor',
|
'pwaThemeColor',
|
||||||
'pwaBackgroundColor',
|
'pwaBackgroundColor',
|
||||||
'appLogoLightUrl',
|
'appLogoLightUrl',
|
||||||
@@ -42,6 +44,8 @@ export interface DomainBrandingEntry {
|
|||||||
appDescription?: string;
|
appDescription?: string;
|
||||||
faviconUrl?: string;
|
faviconUrl?: string;
|
||||||
pwaIconUrl?: string;
|
pwaIconUrl?: string;
|
||||||
|
pwaScreenshotMobileUrl?: string;
|
||||||
|
pwaScreenshotDesktopUrl?: string;
|
||||||
pwaThemeColor?: string;
|
pwaThemeColor?: string;
|
||||||
pwaBackgroundColor?: string;
|
pwaBackgroundColor?: string;
|
||||||
appLogoLightUrl?: string;
|
appLogoLightUrl?: string;
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
|||||||
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
|
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
|
||||||
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
|
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
|
||||||
pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' },
|
pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' },
|
||||||
|
pwaScreenshotMobileUrl: { envVar: 'PWA_SCREENSHOT_MOBILE_URL', type: 'url', defaultValue: '' },
|
||||||
|
pwaScreenshotDesktopUrl: { envVar: 'PWA_SCREENSHOT_DESKTOP_URL', type: 'url', defaultValue: '' },
|
||||||
pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' },
|
pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||||||
pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' },
|
pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||||||
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
|
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
|
||||||
|
|||||||
Reference in New Issue
Block a user