feat(pwa): refresh cross-platform app icons

This commit is contained in:
jxxghp
2026-08-28 14:56:34 +08:00
parent 9a885396fb
commit 995460634f
30 changed files with 392 additions and 8 deletions
+13 -5
View File
@@ -38,13 +38,21 @@
<meta name="referrer" content="no-referrer" />
<!-- PWA - 基础图标 -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="64x64" />
<link rel="icon" type="image/png" href="/logo.png" sizes="192x192" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="16x16 32x32 48x48 64x64 128x128 256x256" />
<link rel="icon" type="image/png" href="/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-16x16.png" sizes="16x16" />
<link rel="icon" type="image/png" href="/logo.png" sizes="512x512" />
<link id="theme-favicon" rel="icon" type="image/svg+xml" href="/logo.svg" sizes="any" />
<link rel="mask-icon" href="/logo.svg" color="#8D51F9" />
<!-- iOS Safari PWA 优化 -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- Apple Touch 与 Windows 磁贴图标 -->
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" sizes="180x180" />
<link rel="apple-touch-icon" href="/apple-touch-icon-167x167.png" sizes="167x167" />
<link rel="apple-touch-icon" href="/apple-touch-icon-152x152.png" sizes="152x152" />
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
<meta name="msapplication-TileColor" content="#07091F" />
<meta name="msapplication-TileImage" content="/mstile-150x150.png" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<!--
iOS still matches launch images by the physical device resolution. Keep
these links static in <head>; inserting them after JavaScript starts is too
@@ -622,7 +630,7 @@
let logoSvgSourcePromise
let pendingFaviconColor = '#8D51F9'
const themeLogoCacheKey = 'moviepilot-themed-logo-cache'
const themeLogoCacheKey = 'moviepilot-themed-logo-cache-v2'
const sourceLogoPalette = {
'rgb(141,81,249)': 'primary',
+2 -1
View File
@@ -7,8 +7,9 @@
"scripts": {
"dev": "vite --host",
"dev:pwa": "vite --host --port 5174",
"prebuild": "npm run build:icons && npm run generate:pwa-splash",
"prebuild": "npm run build:icons && npm run generate:pwa-icons && npm run generate:pwa-splash",
"build": "vite build",
"generate:pwa-icons": "node scripts/generate-pwa-icons.mjs",
"generate:pwa-splash": "node scripts/generate-pwa-splash.mjs",
"preview": "vite preview --port 5050",
"test": "vitest",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png" />
<TileColor>#07091F</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 102 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 335 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 112 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<svg width="3em" height="3em" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<svg width="3em" height="3em" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:2;shape-rendering:geometricPrecision;">
<g transform="matrix(1,0,0,1,-2606,-236)">
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
<rect x="0" y="0" width="192" height="192" style="fill:none;"/>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

+251
View File
@@ -0,0 +1,251 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const sourceLogoPath = path.join(projectRoot, 'scripts', 'assets', 'moviepilot-logo-image2.png')
const cleanedLogoPath = path.join(projectRoot, 'scripts', 'assets', 'moviepilot-logo-master.png')
const DARK_BACKGROUND = '#07091F'
const LIGHT_BACKGROUND = '#F4F1FF'
/**
* 清理 Image 2 输出中的棋盘格和离散像素,只保留构成 Logo 的大面积连通区域。
*/
async function cleanLogoMaster(inputPath) {
const sourceMetadata = await sharp(inputPath).metadata()
const { data, info } = await sharp(inputPath).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
const pixelCount = info.width * info.height
const visited = new Uint8Array(pixelCount)
const retained = new Uint8Array(pixelCount)
const queue = new Int32Array(pixelCount)
const minimumComponentArea = Math.round(pixelCount * 0.0005)
for (let pixel = 0; pixel < pixelCount; pixel += 1) {
const channelOffset = pixel * 4
const darkestChannel = Math.min(data[channelOffset], data[channelOffset + 1], data[channelOffset + 2])
const brightestChannel = Math.max(data[channelOffset], data[channelOffset + 1], data[channelOffset + 2])
const chroma = brightestChannel - darkestChannel
// Image 2 偶尔会把透明棋盘格写进 RGB;高饱和紫色阈值可剔除背景并内收浅色边缘。
if (!sourceMetadata.hasAlpha) data[channelOffset + 3] = chroma >= 96 ? 255 : 0
if (brightestChannel < 48) data[channelOffset + 3] = 0
}
for (let start = 0; start < pixelCount; start += 1) {
if (visited[start]) continue
visited[start] = 1
if (data[start * 4 + 3] === 0) continue
let queueLength = 1
queue[0] = start
for (let cursor = 0; cursor < queueLength; cursor += 1) {
const current = queue[cursor]
const x = current % info.width
const y = Math.floor(current / info.width)
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
const nextY = y + offsetY
if (nextY < 0 || nextY >= info.height) continue
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
if (offsetX === 0 && offsetY === 0) continue
const nextX = x + offsetX
if (nextX < 0 || nextX >= info.width) continue
const next = nextY * info.width + nextX
if (visited[next]) continue
visited[next] = 1
if (data[next * 4 + 3] === 0) continue
queue[queueLength] = next
queueLength += 1
}
}
}
if (queueLength < minimumComponentArea) continue
for (let cursor = 0; cursor < queueLength; cursor += 1) retained[queue[cursor]] = 1
}
for (let pixel = 0; pixel < pixelCount; pixel += 1) {
if (!retained[pixel]) data[pixel * 4 + 3] = 0
}
return sharp(data, { raw: info })
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png({ adaptiveFiltering: true, compressionLevel: 9 })
.toBuffer()
}
/**
* 创建与确认稿一致的全幅背景;圆角由各操作系统自行裁切,不写入源图。
*/
function createBackgroundSvg(size, appearance) {
const isLight = appearance === 'light'
const base = isLight ? LIGHT_BACKGROUND : DARK_BACKGROUND
const glow = isLight ? '#FFFFFF' : '#3B126F'
const middle = isLight ? '#EEE8FF' : '#17113C'
const edge = isLight ? '#E6DEFA' : '#07091F'
return Buffer.from(`
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<defs>
<radialGradient id="brand-glow" cx="45%" cy="35%" r="78%">
<stop offset="0%" stop-color="${glow}" />
<stop offset="52%" stop-color="${middle}" />
<stop offset="100%" stop-color="${edge}" />
</radialGradient>
<linearGradient id="brand-depth" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${base}" stop-opacity="0.08" />
<stop offset="100%" stop-color="${base}" stop-opacity="0.56" />
</linearGradient>
</defs>
<rect width="${size}" height="${size}" fill="url(#brand-glow)" />
<rect width="${size}" height="${size}" fill="url(#brand-depth)" />
</svg>
`)
}
/**
* 将透明 Logo 置于完整方形画布内,并按用途控制安全区比例。
*/
async function createSquareIcon(mark, size, markScale, appearance = 'dark') {
const markSize = Math.round(size * markScale)
const resizedMark = await sharp(mark)
.resize(markSize, markSize, {
background: { r: 0, g: 0, b: 0, alpha: 0 },
fit: 'contain',
kernel: sharp.kernel.lanczos3,
})
.png()
.toBuffer()
const offset = Math.round((size - markSize) / 2)
const background = appearance === 'light' ? LIGHT_BACKGROUND : DARK_BACKGROUND
return sharp(createBackgroundSvg(size, appearance))
.composite([{ input: resizedMark, left: offset, top: offset }])
.flatten({ background })
.removeAlpha()
.png({ adaptiveFiltering: true, compressionLevel: 9 })
.toBuffer()
}
/**
* 将透明母版缩放到指定画布,供页面、通知和主题标识作为无底图资源使用。
*/
async function createTransparentLogo(mark, size) {
const markSize = Math.round(size * 0.88)
const resizedMark = await sharp(mark)
.resize(markSize, markSize, {
background: { r: 0, g: 0, b: 0, alpha: 0 },
fit: 'contain',
kernel: sharp.kernel.lanczos3,
})
.png()
.toBuffer()
const offset = Math.round((size - markSize) / 2)
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([{ input: resizedMark, left: offset, top: offset }])
.png({ adaptiveFiltering: true, compressionLevel: 9 })
.toBuffer()
}
/**
* 将多份 PNG 尺寸封装为兼容 Windows 与浏览器的多分辨率 ICO 文件。
*/
function createIco(entries) {
const headerSize = 6
const directoryEntrySize = 16
const dataOffset = headerSize + entries.length * directoryEntrySize
const header = Buffer.alloc(dataOffset)
header.writeUInt16LE(0, 0)
header.writeUInt16LE(1, 2)
header.writeUInt16LE(entries.length, 4)
let currentOffset = dataOffset
entries.forEach(({ buffer, size }, index) => {
const entryOffset = headerSize + index * directoryEntrySize
header.writeUInt8(size === 256 ? 0 : size, entryOffset)
header.writeUInt8(size === 256 ? 0 : size, entryOffset + 1)
header.writeUInt8(0, entryOffset + 2)
header.writeUInt8(0, entryOffset + 3)
header.writeUInt16LE(1, entryOffset + 4)
header.writeUInt16LE(32, entryOffset + 6)
header.writeUInt32LE(buffer.length, entryOffset + 8)
header.writeUInt32LE(currentOffset, entryOffset + 12)
currentOffset += buffer.length
})
return Buffer.concat([header, ...entries.map(entry => entry.buffer)])
}
/**
* 写入生成资产并自动创建目标目录。
*/
async function writeAsset(relativePath, buffer) {
const outputPath = path.join(projectRoot, relativePath)
await mkdir(path.dirname(outputPath), { recursive: true })
await writeFile(outputPath, buffer)
}
const sourceLogo = await readFile(sourceLogoPath)
const cleanedLogo = await cleanLogoMaster(sourceLogo)
const transparentLogo = await createTransparentLogo(cleanedLogo, 512)
const standardMaster = await createSquareIcon(cleanedLogo, 1024, 0.76)
await Promise.all([
writeAsset(path.relative(projectRoot, cleanedLogoPath), cleanedLogo),
writeAsset(path.join('public', 'icon.png'), standardMaster),
writeAsset(path.join('public', 'logo.png'), transparentLogo),
writeAsset(path.join('src', 'assets', 'images', 'logo.png'), transparentLogo),
writeAsset(path.join('public', 'icon-black.png'), await createSquareIcon(cleanedLogo, 512, 0.76)),
writeAsset(path.join('public', 'icon-white.png'), await createSquareIcon(cleanedLogo, 512, 0.76, 'light')),
writeAsset(path.join('public', 'android-chrome-192x192.png'), await createSquareIcon(cleanedLogo, 192, 0.76)),
writeAsset(
path.join('public', 'android-chrome-192x192_maskable.png'),
await createSquareIcon(cleanedLogo, 192, 0.64),
),
writeAsset(path.join('public', 'android-chrome-512x512.png'), await createSquareIcon(cleanedLogo, 512, 0.76)),
writeAsset(
path.join('public', 'android-chrome-512x512_maskable.png'),
await createSquareIcon(cleanedLogo, 512, 0.64),
),
writeAsset(path.join('public', 'pwa-1024x1024.png'), standardMaster),
writeAsset(path.join('public', 'pwa-1024x1024_maskable.png'), await createSquareIcon(cleanedLogo, 1024, 0.64)),
writeAsset(path.join('public', 'apple-touch-icon.png'), await createSquareIcon(cleanedLogo, 180, 0.72)),
writeAsset(path.join('public', 'apple-touch-icon-precomposed.png'), await createSquareIcon(cleanedLogo, 180, 0.72)),
writeAsset(path.join('public', 'apple-touch-icon-180x180.png'), await createSquareIcon(cleanedLogo, 180, 0.72)),
writeAsset(path.join('public', 'apple-touch-icon-167x167.png'), await createSquareIcon(cleanedLogo, 167, 0.72)),
writeAsset(path.join('public', 'apple-touch-icon-152x152.png'), await createSquareIcon(cleanedLogo, 152, 0.72)),
writeAsset(path.join('public', 'mstile-150x150.png'), await createSquareIcon(cleanedLogo, 150, 0.66)),
])
const icoEntries = await Promise.all(
[16, 32, 48, 64, 128, 256].map(async size => ({
buffer: await createSquareIcon(cleanedLogo, size, size <= 32 ? 0.84 : 0.78),
size,
})),
)
await Promise.all([
writeAsset(path.join('public', 'favicon-16x16.png'), icoEntries[0].buffer),
writeAsset(path.join('public', 'favicon-32x32.png'), icoEntries[1].buffer),
writeAsset(path.join('public', 'favicon.ico'), createIco(icoEntries)),
])
console.log('Generated MoviePilot icon assets for browsers, iOS, macOS, Android, Windows, and PWA installs')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 112 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<svg width="3em" height="3em" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<svg width="3em" height="3em" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:2;shape-rendering:geometricPrecision;">
<g transform="matrix(1,0,0,1,-2606,-236)">
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
<rect x="0" y="0" width="192" height="192" style="fill:none;"/>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+103
View File
@@ -0,0 +1,103 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import sharp from 'sharp'
import { describe, expect, it } from 'vitest'
const projectRoot = process.cwd()
const indexHtml = readFileSync(resolve(projectRoot, 'index.html'), 'utf8')
const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8'))
const viteConfig = readFileSync(resolve(projectRoot, 'vite.config.ts'), 'utf8')
const iconSpecs = [
{ alpha: true, path: 'public/logo.png', size: 512 },
{ alpha: true, path: 'src/assets/images/logo.png', size: 512 },
{ alpha: false, path: 'public/icon.png', size: 1024 },
{ alpha: false, path: 'public/android-chrome-192x192.png', size: 192 },
{ alpha: false, path: 'public/android-chrome-192x192_maskable.png', size: 192 },
{ alpha: false, path: 'public/android-chrome-512x512.png', size: 512 },
{ alpha: false, path: 'public/android-chrome-512x512_maskable.png', size: 512 },
{ alpha: false, path: 'public/pwa-1024x1024.png', size: 1024 },
{ alpha: false, path: 'public/pwa-1024x1024_maskable.png', size: 1024 },
{ alpha: false, path: 'public/apple-touch-icon.png', size: 180 },
{ alpha: false, path: 'public/apple-touch-icon-precomposed.png', size: 180 },
{ alpha: false, path: 'public/apple-touch-icon-180x180.png', size: 180 },
{ alpha: false, path: 'public/apple-touch-icon-167x167.png', size: 167 },
{ alpha: false, path: 'public/apple-touch-icon-152x152.png', size: 152 },
{ alpha: false, path: 'public/mstile-150x150.png', size: 150 },
{ alpha: false, path: 'public/favicon-32x32.png', size: 32 },
{ alpha: false, path: 'public/favicon-16x16.png', size: 16 },
] as const
/**
* 读取生成 PNG 的关键元数据,验证声明尺寸与透明通道契约。
*/
async function readIconMetadata(relativePath: string) {
return sharp(resolve(projectRoot, relativePath)).metadata()
}
describe('PWA 跨平台图标资源', () => {
it('生成与文件名和用途一致的 PNG 尺寸及透明通道', async () => {
for (const icon of iconSpecs) {
const metadata = await readIconMetadata(icon.path)
expect(metadata.width, icon.path).toBe(icon.size)
expect(metadata.height, icon.path).toBe(icon.size)
expect(metadata.hasAlpha, icon.path).toBe(icon.alpha)
}
})
it('在 manifest 中同时声明常规、maskable 与高分辨率图标', () => {
expect(viteConfig).toContain("'src': './android-chrome-192x192.png'")
expect(viteConfig).toContain("'src': './android-chrome-192x192_maskable.png'")
expect(viteConfig).toContain("'src': './android-chrome-512x512.png'")
expect(viteConfig).toContain("'src': './android-chrome-512x512_maskable.png'")
expect(viteConfig).toContain("'src': './pwa-1024x1024.png'")
expect(viteConfig).toContain("'src': './pwa-1024x1024_maskable.png'")
expect(viteConfig.match(/'purpose': 'any'/g)).toHaveLength(3)
expect(viteConfig.match(/'purpose': 'maskable'/g)).toHaveLength(3)
})
it('声明 Apple、macOS Safari 与 Windows 的平台入口', () => {
const parsedDocument = new DOMParser().parseFromString(indexHtml, 'text/html')
const appleSizes = [...parsedDocument.querySelectorAll<HTMLLinkElement>('link[rel="apple-touch-icon"]')]
.map(link => link.getAttribute('sizes'))
.sort()
expect(appleSizes).toEqual(['152x152', '167x167', '180x180'])
expect(parsedDocument.querySelector('link[rel="mask-icon"]')?.getAttribute('href')).toBe('/logo.svg')
expect(parsedDocument.querySelector('meta[name="msapplication-TileImage"]')?.getAttribute('content')).toBe(
'/mstile-150x150.png',
)
expect(parsedDocument.querySelector('meta[name="msapplication-config"]')?.getAttribute('content')).toBe(
'/browserconfig.xml',
)
})
it('生成包含常见 Windows 尺寸的多分辨率 favicon', () => {
const favicon = readFileSync(resolve(projectRoot, 'public/favicon.ico'))
const imageCount = favicon.readUInt16LE(4)
const declaredSizes = Array.from({ length: imageCount }, (_, index) => {
const sizeByte = favicon.readUInt8(6 + index * 16)
return sizeByte === 0 ? 256 : sizeByte
})
expect(favicon.readUInt16LE(2)).toBe(1)
expect(declaredSizes).toEqual([16, 32, 48, 64, 128, 256])
})
it('在构建前可重复生成图标,并让启动图消费最新矢量 Logo', () => {
expect(packageJson.scripts['generate:pwa-icons']).toBe('node scripts/generate-pwa-icons.mjs')
expect(packageJson.scripts.prebuild).toContain(
'npm run build:icons && npm run generate:pwa-icons && npm run generate:pwa-splash',
)
const publicLogo = readFileSync(resolve(projectRoot, 'public/logo.svg'), 'utf8')
const sourceLogo = readFileSync(resolve(projectRoot, 'src/assets/images/logo.svg'), 'utf8')
expect(publicLogo).toBe(sourceLogo)
expect(publicLogo).toContain('shape-rendering:geometricPrecision')
expect(publicLogo).not.toContain('stroke-width')
expect(publicLogo).not.toContain('<feDropShadow')
expect(indexHtml).toContain("const themeLogoCacheKey = 'moviepilot-themed-logo-cache-v2'")
})
})
+12
View File
@@ -141,6 +141,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'type': 'image/png',
'purpose': 'maskable',
},
{
'src': './pwa-1024x1024.png',
'sizes': '1024x1024',
'type': 'image/png',
'purpose': 'any',
},
{
'src': './pwa-1024x1024_maskable.png',
'sizes': '1024x1024',
'type': 'image/png',
'purpose': 'maskable',
},
],
'theme_color': '#0E1116',
'background_color': '#0E1116',