feat(pwa): refresh cross-platform app icons
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 22 KiB |
@@ -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>
|
||||
|
After Width: | Height: | Size: 708 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 112 KiB |
@@ -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 |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 836 KiB |
@@ -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')
|
||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 112 KiB |
@@ -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 |
@@ -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'")
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||