0.2.3: 重绘系列通用安装图标并记录功能审核决定(#413、#77/#408、#444、#407)

This commit is contained in:
sganggs committed 2026-10-10 02:11:23 +08:00
1 parent ba5b998a72
commit 9042125430
21 files changed
+249 -13

No files matched your search

+12
View File
@@ -20,6 +20,18 @@ const FAKE_HOME = ['', 'Users', 'someone'].join('/');
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const TOOL = path.join(ROOT, 'tools', 'package.mjs');
test('all install and favicon assets ship independently of optional game art', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/manifest.json'), 'utf8'));
const html = fs.readFileSync(path.join(ROOT, 'public/index.html'), 'utf8');
const urls = [...manifest.icons.map((i) => i.src),
...[...html.matchAll(/<link rel="(?:icon|apple-touch-icon)"[^>]+href="([^"]+)"/g)].map((m) => m[1])];
const files = [...new Set(['public/manifest.json', ...urls.map((url) => `public${url}`),
...[16, 32, 48].map((size) => `public/icons/favicon-${size}.png`)])].sort();
for (const f of files) assert.ok(fs.statSync(path.join(ROOT, f)).size > 0, f);
assert.deepEqual(selectTracked([...files, 'tools/export-app-icons.py', 'handoff/claude-review-023/icon/light.png']).keep,
files, 'full and lite share the same tracked static-file allowlist');
});
// hermetic scans: not this machine's account name, but a planted one
const ENV = { SP_PACKAGE_SCAN_USER: '0', SP_PACKAGE_SCAN_NAMES: 'plantedname' };
+14
View File
@@ -170,6 +170,20 @@ describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
const client = await page.createCDPSession();
const manifest = await client.send('Page.getAppManifest');
assert.deepEqual(manifest.errors, []);
const icons = await page.evaluate(async () => {
const m = await (await fetch('/manifest.json')).json();
const entries = [...m.icons, ...[...document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]')]
.map((link) => ({ src: link.getAttribute('href'), purpose: link.rel }))];
return Promise.all(entries.map(async (entry) => {
const response = await fetch(entry.src);
if (!response.ok) throw new Error(`${entry.src}: ${response.status}`);
const img = new Image(); img.src = entry.src; await img.decode();
return { purpose: entry.purpose, width: img.naturalWidth, height: img.naturalHeight };
}));
});
assert.deepEqual(icons.filter((i) => i.purpose === 'maskable').map((i) => i.width).sort((a, b) => a - b), [192, 512]);
assert.ok(icons.every((i) => i.width > 0 && i.width === i.height));
assert.ok(icons.some((i) => i.purpose === 'apple-touch-icon' && i.width === 180));
const install = await client.send('Page.getInstallabilityErrors');
assert.deepEqual(install.installabilityErrors, []);
await page.evaluate(() => {
+53 -2
View File
@@ -2,6 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createPwaInstall } from '../../public/js/pwa.js';
import { decodePng } from '../../tools/crop-board-atlas.mjs';
function rig(standalone = false) {
const win = new EventTarget(), display = new EventTarget();
display.matches = standalone;
@@ -42,13 +43,63 @@ test('installed / standalone hides stale offers; disposing removes listeners', a
assert.equal(offer().e.defaultPrevented, false);
const native = rig(true); native.offer(); assert.equal(native.pwa.available(), false); native.pwa.dispose();
});
test('manifest uses actual 192/512 icons shipped outside optional game assets', () => {
test('manifest provides opaque 192/512 icons for both purposes with a safe maskable mark', () => {
const m = JSON.parse(readFileSync(new URL('../../public/manifest.json', import.meta.url)));
assert.equal(m.start_url, '/'); assert.equal(m.scope, '/');
assert.deepEqual(m.icons.map((i) => `${i.purpose}:${i.sizes}`).sort(),
['any:192x192', 'any:512x512', 'maskable:192x192', 'maskable:512x512']);
for (const icon of m.icons) {
assert.ok(icon.src.startsWith('/icons/'));
assert.equal(icon.type, 'image/png');
const bytes = readFileSync(new URL('../../public' + icon.src, import.meta.url));
const size = Number(icon.sizes.split('x')[0]);
assert.equal(bytes.readUInt32BE(16), size); assert.equal(bytes.readUInt32BE(20), size);
const { w, h, rgba } = decodePng(bytes);
assert.equal(w, size); assert.equal(h, size);
let foreground = 0;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
assert.equal(rgba[i + 3], 255, 'opaque edge-to-edge');
assert.equal(rgba[i], rgba[i + 1], 'season-independent neutral palette');
assert.equal(rgba[i], rgba[i + 2]);
if (rgba[i] === 36) continue; // #242424 background
foreground++;
if (icon.purpose === 'maskable') {
// W3C safe zone is a circle, not merely a centred 80% square.
const dx = x + 0.5 - w / 2, dy = y + 0.5 - h / 2;
assert.ok(dx * dx + dy * dy <= (size * 0.4) ** 2, `clipped mark at ${x},${y}`);
}
}
assert.ok(foreground > size * size * 0.12, 'the exported mark is not empty or tiny');
}
});
test('favicon and Apple links resolve to real sizes, including all legacy ICO frames', () => {
const html = readFileSync(new URL('../../public/index.html', import.meta.url), 'utf8');
const links = [...html.matchAll(/<link rel="(?:icon|apple-touch-icon)"[^>]+>/g)].map(([s]) => s);
assert.equal(links.length, 5);
for (const link of links) {
const href = link.match(/href="([^"]+)"/)[1];
assert.ok(href.startsWith('/icons/'), href);
const bytes = readFileSync(new URL('../../public' + href, import.meta.url));
if (href.endsWith('.png')) {
const { w, h } = decodePng(bytes);
assert.ok(link.includes(`sizes="${w}x${h}"`), href);
} else if (href.endsWith('.svg')) {
assert.match(bytes.toString(), /viewBox="0 0 512 512"/);
assert.doesNotMatch(bytes.toString(), /<(?:image|text|use|foreignObject)\b|(?:href|font-family)=/);
} else {
assert.equal(bytes.readUInt16LE(2), 1);
assert.equal(bytes.readUInt16LE(4), 3);
for (const [i, size] of [16, 32, 48].entries()) {
const entry = 6 + i * 16;
assert.equal(bytes[entry], size); assert.equal(bytes[entry + 1], size);
const length = bytes.readUInt32LE(entry + 8), offset = bytes.readUInt32LE(entry + 12);
assert.ok(length > 0 && offset >= 54 && offset + length <= bytes.length);
}
}
}
for (const size of [16, 32, 48]) {
const { w, h } = decodePng(readFileSync(new URL(`../../public/icons/favicon-${size}.png`, import.meta.url)));
assert.equal(w, size); assert.equal(h, size);
}
});