feat(pwa): align app branding and add startup screen

This commit is contained in:
hectorqin committed 2026-10-01 08:26:08 +08:00
1 parent 79eeb5ded6
commit 352dccaabe
44 files changed
+97 -1

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 634 B

After

Width:  |  Height:  |  Size: 5.3 KiB

+40
View File
@@ -28,6 +28,43 @@
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="reader" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<!-- startup-images:start -->
<link rel="apple-touch-startup-image" href="./startup/startup-640x1136.png" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1136x640.png" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-750x1334.png" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1334x750.png" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1242x2208.png" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2208x1242.png" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1125x2436.png" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2436x1125.png" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1170x2532.png" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2532x1170.png" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1179x2556.png" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2556x1179.png" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1206x2622.png" media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2622x1206.png" media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-828x1792.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1792x828.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1242x2688.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2688x1242.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1284x2778.png" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2778x1284.png" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1290x2796.png" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2796x1290.png" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1320x2868.png" media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2868x1320.png" media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1536x2048.png" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2048x1536.png" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1620x2160.png" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2160x1620.png" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1640x2360.png" media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2360x1640.png" media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-1668x2388.png" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2388x1668.png" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2048x2732.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
<link rel="apple-touch-startup-image" href="./startup/startup-2732x2048.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" />
<!-- startup-images:end -->
<title>reader</title>
<style>
/* First paint only: prevents a white flash before the bundle applies the
@@ -42,10 +79,13 @@
html[data-theme='dark'] body {
background: #16171a;
}
.app-startup { position: fixed; inset: 0; display: grid; place-items: center; background: #fbfaf7; z-index: 9999; }
.app-startup img { width: 96px; height: 96px; }
</style>
</head>
<body>
<div id="app"></div>
<div id="app-startup" class="app-startup" role="status" aria-label="正在启动 reader"><img src="./icons/icon-192.png" width="96" height="96" alt="" /></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3
View File
@@ -6,7 +6,10 @@
"lang": "zh-CN",
"start_url": "./",
"scope": "./",
"display_override": ["window-controls-overlay", "standalone"],
"display": "standalone",
"orientation": "any",
"launch_handler": { "client_mode": "navigate-existing" },
"background_color": "#fbfaf7",
"theme_color": "#fbfaf7",
"icons": [
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+1 -1
View File
@@ -42,4 +42,4 @@ window.__readerFlush = () => {
void app.start().catch((err: unknown) => {
root.textContent = `客户端启动失败:${err instanceof Error ? err.message : String(err)}`;
});
}).finally(() => document.getElementById('app-startup')?.remove());
+12
View File
@@ -1,6 +1,8 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { registerPwa, watchPwaUpdates } from '../src/core/pwa.ts';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
let registration: ServiceWorkerRegistration;
let serviceWorker: ServiceWorkerContainer;
@@ -126,6 +128,16 @@ it('keeps ordinary HTTP and unsupported browsers usable', () => {
expect(registerPwa).not.toThrow();
});
it('ships standalone launch metadata and a first-paint startup shell', () => {
const manifest = JSON.parse(readFileSync(resolve(process.cwd(), 'public/manifest.webmanifest'), 'utf8'));
expect(manifest.display).toBe('standalone');
expect(manifest.display_override).toContain('standalone');
expect(manifest.launch_handler.client_mode).toBe('navigate-existing');
const html = readFileSync(resolve(process.cwd(), 'index.html'), 'utf8');
expect(html).toContain('id="app-startup"');
expect(html).toContain('apple-touch-startup-image');
});
it('reports failed registration without an unhandled rejection', async () => {
const error = new Error('storage unavailable');
register.mockRejectedValueOnce(error);
+41
View File
@@ -0,0 +1,41 @@
// One PWA icon supplies APK launcher sizes and iOS startup images.
// Run: node web/tools/pwa/branding.mjs
import { chromium } from 'playwright';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
const root = new URL('../../', import.meta.url);
const icon = await readFile(new URL('public/icons/icon-512.png', root));
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setContent('<canvas></canvas>');
async function render(relative, width, height, size) {
const bytes = await page.evaluate(async ({ source, width, height, size }) => {
const img = new Image(); img.src = source; await img.decode();
const canvas = document.querySelector('canvas'); canvas.width = width; canvas.height = height;
const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fbfaf7'; ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, (width - size) / 2, (height - size) / 2, size, size);
return canvas.toDataURL('image/png').split(',')[1];
}, { source: `data:image/png;base64,${icon.toString('base64')}`, width, height, size });
const dest = new URL(relative, root); await mkdir(new URL('./', dest), { recursive: true });
await writeFile(dest, Buffer.from(bytes, 'base64'));
}
for (const [density, size] of Object.entries({ mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 })) {
await render(`../android/app/src/main/res/mipmap-${density}/ic_launcher.png`, size, size, size);
}
const devices = [[320,568,2],[375,667,2],[414,736,3],[375,812,3],[390,844,3],[393,852,3],[402,874,3],[414,896,2],[414,896,3],[428,926,3],[430,932,3],[440,956,3],[768,1024,2],[810,1080,2],[820,1180,2],[834,1194,2],[1024,1366,2]];
const links = [];
for (const [w,h,dpr] of devices) {
for (const orientation of ['portrait', 'landscape']) {
const width = (orientation === 'portrait' ? w : h) * dpr;
const height = (orientation === 'portrait' ? h : w) * dpr;
const name = `startup-${width}x${height}.png`;
await render(`public/startup/${name}`, width, height, 96 * dpr);
links.push(` <link rel="apple-touch-startup-image" href="./startup/${name}" media="(device-width: ${w}px) and (device-height: ${h}px) and (-webkit-device-pixel-ratio: ${dpr}) and (orientation: ${orientation})" />`);
}
}
const htmlPath = new URL('index.html', root);
const html = await readFile(htmlPath, 'utf8');
await writeFile(htmlPath, html.replace(/<!-- startup-images:start -->[\s\S]*?<!-- startup-images:end -->/, `<!-- startup-images:start -->\n${links.join('\n')}\n <!-- startup-images:end -->`));
console.log('Generated Android launcher icons and iOS startup images.');
} finally { await browser.close(); }