Merge remote-tracking branch 'origin/main' into pr/quote-header-i18n
This commit is contained in:
@@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
|
||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { locales } from "@/i18n/routing";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -41,6 +42,7 @@ export default async function LocaleLayout({
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<PluginConsentDialog />
|
||||
<PWAInstallPrompt />
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
|
||||
@@ -55,7 +55,7 @@ import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailb
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
@@ -72,6 +72,7 @@ import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { useLocaleStore } from "@/stores/locale-store";
|
||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||
|
||||
@@ -752,9 +753,9 @@ export default function Home() {
|
||||
let title = t('email_composer.new_message');
|
||||
if (baseSubject) {
|
||||
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
|
||||
title = baseSubject.startsWith('Re:') ? baseSubject : `Re: ${baseSubject}`;
|
||||
title = buildReplySubject(baseSubject, t('email_composer.prefix.reply'));
|
||||
} else if (effectiveMode === 'forward') {
|
||||
title = baseSubject.startsWith('Fwd:') ? baseSubject : `Fwd: ${baseSubject}`;
|
||||
title = buildForwardSubject(baseSubject, t('email_composer.prefix.forward'));
|
||||
} else {
|
||||
title = baseSubject;
|
||||
}
|
||||
@@ -1130,6 +1131,7 @@ export default function Home() {
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
delayedUntil?: string;
|
||||
requestReadReceipt?: boolean;
|
||||
}) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -1137,7 +1139,7 @@ export default function Home() {
|
||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||
const originalEmailId = selectedEmail?.id;
|
||||
|
||||
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom);
|
||||
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt });
|
||||
setShowComposer(false);
|
||||
if (result.scheduled) {
|
||||
await refreshScheduledMetadata(client);
|
||||
@@ -2098,9 +2100,21 @@ export default function Home() {
|
||||
|
||||
// Append signature from the sending identity (fall back to primary
|
||||
// when the reply-from lives on the same identity but a different alias).
|
||||
const finalBody = appendPlainTextSignature(body, sendingIdentity, {
|
||||
separator: useSettingsStore.getState().signatureSeparatorEnabled,
|
||||
});
|
||||
const separator = useSettingsStore.getState().signatureSeparatorEnabled;
|
||||
const finalBody = appendPlainTextSignature(body, sendingIdentity, { separator });
|
||||
|
||||
// When the identity has an HTML signature, send a matching HTML body so the
|
||||
// signature keeps its formatting; appendPlainTextSignature would otherwise
|
||||
// flatten it to plain text. Text-only identities keep the plain-text-only
|
||||
// behavior (htmlBody stays undefined).
|
||||
const escapedBody = body
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
const finalHtmlBody = sendingIdentity?.htmlSignature?.trim()
|
||||
? appendHtmlSignature(`<div>${escapedBody}</div>`, sendingIdentity, { separator })
|
||||
: undefined;
|
||||
|
||||
const originalEmailId = selectedEmail.id;
|
||||
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
|
||||
@@ -2124,7 +2138,7 @@ export default function Home() {
|
||||
const result = await sendEmail(
|
||||
client,
|
||||
[sender.email],
|
||||
`Re: ${selectedEmail.subject || "(no subject)"}`,
|
||||
buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')),
|
||||
finalBody,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -2132,7 +2146,7 @@ export default function Home() {
|
||||
headerFromEmail,
|
||||
undefined,
|
||||
headerFromName,
|
||||
undefined,
|
||||
finalHtmlBody,
|
||||
undefined,
|
||||
threading?.inReplyTo,
|
||||
threading?.references,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
import {
|
||||
BRANDING_OVERRIDE_KEYS,
|
||||
parseDomainBranding,
|
||||
@@ -33,6 +33,8 @@ const TEXT_FIELDS = [
|
||||
|
||||
const PWA_IMAGE_FIELDS = [
|
||||
{ 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;
|
||||
|
||||
const PWA_TEXT_FIELDS = [
|
||||
@@ -528,7 +530,7 @@ export function BrandingTab() {
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={currentValue(field.key)}
|
||||
src={withBasePath(currentValue(field.key))}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
@@ -606,7 +608,7 @@ export function BrandingTab() {
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={currentValue(field.key)}
|
||||
src={withBasePath(currentValue(field.key))}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
@@ -92,7 +91,6 @@ export default async function RootLayout({
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
{children}
|
||||
<PWAInstallPrompt />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import '../globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
// The plugin sandbox iframe runs with an opaque origin (the `sandbox`
|
||||
// attribute in production excludes `allow-same-origin` for isolation). Any
|
||||
// asset request from this layout - bundled fonts, globals.css, etc. - is then
|
||||
// cross-origin from the "null" origin to the host origin and gets blocked
|
||||
// (fonts in particular require CORS). So this layout is intentionally minimal:
|
||||
// no font imports, no CSS imports. Plugins ship their own styles, and both the
|
||||
// plugin bundle and all host API calls travel over the postMessage RPC bridge,
|
||||
// so the sandbox never fetches same-origin assets itself.
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Plugin sandbox',
|
||||
@@ -21,10 +18,7 @@ export const metadata: Metadata = {
|
||||
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
style={{ margin: 0, padding: 0, background: 'transparent' }}
|
||||
>
|
||||
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -26,14 +26,18 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/vnd.microsoft.icon',
|
||||
]);
|
||||
|
||||
type UploadSlot = BrandingOverrideKey;
|
||||
|
||||
/** Slots that correspond to branding config keys */
|
||||
const VALID_SLOTS = new Set<BrandingOverrideKey>([
|
||||
const VALID_SLOTS = new Set<UploadSlot>([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'pwaScreenshotMobileUrl',
|
||||
'pwaScreenshotDesktopUrl',
|
||||
]);
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
if (!VALID_SLOTS.has(slot as BrandingOverrideKey)) {
|
||||
if (!VALID_SLOTS.has(slot as UploadSlot)) {
|
||||
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -226,7 +230,7 @@ export async function DELETE(request: NextRequest) {
|
||||
const slot = body.slot;
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,9 @@ export async function POST(request: NextRequest) {
|
||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||
: {}),
|
||||
...(manifest.locales && typeof manifest.locales === 'object'
|
||||
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||
: {}),
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
|
||||
@@ -723,7 +723,18 @@ const emails: MockEmail[] = [
|
||||
// Identities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IDENTITIES = [
|
||||
type MockIdentity = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo: Array<{ name?: string; email: string }> | null;
|
||||
bcc: Array<{ name?: string; email: string }> | null;
|
||||
textSignature: string | null;
|
||||
htmlSignature: string | null;
|
||||
mayDelete: boolean;
|
||||
};
|
||||
|
||||
const IDENTITIES: MockIdentity[] = [
|
||||
{
|
||||
id: 'identity-001',
|
||||
name: 'Dev User',
|
||||
@@ -1562,13 +1573,55 @@ function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult {
|
||||
|
||||
function handleIdentitySet(args: MethodArgs, callId: string): MethodResult {
|
||||
const created: Record<string, { id: string }> = {};
|
||||
const create = args.create as Record<string, unknown> | undefined;
|
||||
const updated: Record<string, null> = {};
|
||||
const destroyed: string[] = [];
|
||||
|
||||
const create = args.create as Record<string, Record<string, unknown>> | undefined;
|
||||
if (create) {
|
||||
for (const key of Object.keys(create)) {
|
||||
created[key] = { id: `identity-new-${Date.now()}-${key}` };
|
||||
for (const [key, data] of Object.entries(create)) {
|
||||
const newId = `identity-${Date.now()}-${key}`;
|
||||
IDENTITIES.push({
|
||||
id: newId,
|
||||
name: (data.name as string) || '',
|
||||
email: (data.email as string) || '',
|
||||
replyTo: (data.replyTo as MockIdentity['replyTo']) ?? null,
|
||||
bcc: (data.bcc as MockIdentity['bcc']) ?? null,
|
||||
textSignature: (data.textSignature as string | null) ?? null,
|
||||
htmlSignature: (data.htmlSignature as string | null) ?? null,
|
||||
mayDelete: true,
|
||||
});
|
||||
created[key] = { id: newId };
|
||||
}
|
||||
}
|
||||
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated: null, destroyed: null }, callId];
|
||||
|
||||
const update = args.update as Record<string, Record<string, unknown>> | undefined;
|
||||
if (update) {
|
||||
for (const [id, changes] of Object.entries(update)) {
|
||||
const identity = IDENTITIES.find((i) => i.id === id);
|
||||
if (identity) {
|
||||
// Email is immutable per the identity form, so it's never in `changes`.
|
||||
if (changes.name !== undefined) identity.name = changes.name as string;
|
||||
if (changes.replyTo !== undefined) identity.replyTo = changes.replyTo as MockIdentity['replyTo'];
|
||||
if (changes.bcc !== undefined) identity.bcc = changes.bcc as MockIdentity['bcc'];
|
||||
if (changes.textSignature !== undefined) identity.textSignature = changes.textSignature as string | null;
|
||||
if (changes.htmlSignature !== undefined) identity.htmlSignature = changes.htmlSignature as string | null;
|
||||
updated[id] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const destroy = args.destroy as string[] | undefined;
|
||||
if (destroy) {
|
||||
for (const id of destroy) {
|
||||
const idx = IDENTITIES.findIndex((i) => i.id === id);
|
||||
if (idx !== -1) {
|
||||
IDENTITIES.splice(idx, 1);
|
||||
destroyed.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId];
|
||||
}
|
||||
|
||||
function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
|
||||
|
||||
@@ -57,6 +57,9 @@ export async function GET() {
|
||||
// Per-user settings schema, captured from the manifest at upload/load
|
||||
// time so the client can render the settings UI without re-parsing.
|
||||
settingsSchema: p.settingsSchema,
|
||||
// Plugin-declared i18n tables, so the sandbox can localize plugin
|
||||
// strings via api.i18n.t().
|
||||
locales: p.locales,
|
||||
}));
|
||||
|
||||
// Only serve enabled themes
|
||||
|
||||
@@ -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([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'pwaScreenshotMobileUrl',
|
||||
'pwaScreenshotDesktopUrl',
|
||||
]);
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
|
||||
+19
-4
@@ -95,10 +95,25 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
||||
background_color: backgroundColor,
|
||||
icons,
|
||||
categories: ["productivity"],
|
||||
screenshots: [
|
||||
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||
],
|
||||
// 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") },
|
||||
|
||||
Reference in New Issue
Block a user