fix: declare and ship toolbar icons, so neither browser renders a puzzle piece (closes #371)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 32s

Neither manifest declared any icons, so both browsers showed a generic puzzle-piece -- the first thing seen on every browser start, and how a user tells a real extension from a look-alike. Both manifests now declare 16/32/48/128, and the PNGs ship inside each browser archive rather than being left at dist/ root, which is the trap that made a naive zip incomplete before.

build.js reads which icons to copy from each manifest's own icons block, so the manifest is the single source of truth and a declared-but-absent size fails the build rather than shipping a dangling reference; the packager's reference-resolver covers them independently. Manifest values are constrained before being joined into a path. The artwork is original, generated from geometry rather than traced or fetched.
This commit was merged in pull request #376.
This commit is contained in:
2026-08-23 21:26:14 +02:00
parent 1b52aa1723
commit 45500e66cf
10 changed files with 144 additions and 0 deletions

View File

@@ -47,6 +47,12 @@ const fs = require("fs");
const path = require("path");
const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
const ROOT = path.join(__dirname, "..");
// The sizes both stores and both toolbars ask for.
const EXPECTED_ICON_SIZES = ["16", "32", "48", "128"];
const PNG_SIGNATURE = Buffer.from("89504e470d0a1a0a", "hex");
const EXPECTED_DIRECTIVES = {
"default-src": ["'self'"],
@@ -115,6 +121,51 @@ function assertPolicy(policy) {
}
}
// The declared icons, in both manifests.
//
// Without an "icons" block a browser draws a generic puzzle piece in the
// toolbar for this extension, which is both the first thing the user sees and
// how a real extension is told apart from a look-alike. Declaring one is not
// enough on its own: an entry naming a file that is not in the tree ships a
// reference to nothing, so the referenced bytes are read here and required to
// be a PNG of the size the entry claims. build.js copies these into each
// browser directory, relative to it, and script/lib/package.js then refuses to
// build an archive that does not contain everything the manifest names.
function assertIcons(target) {
const icons = readManifest(target).icons;
expect(Object.keys(icons).sort()).toEqual(EXPECTED_ICON_SIZES.sort());
for (const size of EXPECTED_ICON_SIZES) {
const ref = icons[size];
expect([size, ref]).toEqual([size, `icons/icon${size}.png`]);
const bytes = fs.readFileSync(path.join(ROOT, ref));
expect(bytes.subarray(0, 8)).toEqual(PNG_SIGNATURE);
// IHDR width and height, at fixed offsets right after the signature
// and the chunk header.
expect([ref, bytes.readUInt32BE(16), bytes.readUInt32BE(20)]).toEqual([
ref,
Number(size),
Number(size),
]);
}
}
describe("declared icons", () => {
test("chrome declares real icons at every size", () => {
assertIcons("chrome");
});
test("firefox declares real icons at every size", () => {
assertIcons("firefox");
});
test("both targets declare the same icons", () => {
expect(readManifest("firefox").icons).toEqual(
readManifest("chrome").icons,
);
});
});
describe("shipped Content Security Policy", () => {
// MV3 takes an object and applies extension_pages to the popup and the
// background service worker, which is where libsodium runs.

View File

@@ -90,6 +90,26 @@ describe("archive self-containment", () => {
);
});
// Toolbar icons are the other file the manifest names and no bundler
// emits, so an archive built without them would carry a manifest whose
// "icons" resolve to nothing and a browser would fall back to a generic
// placeholder without saying so.
test("a manifest naming an icon that is not in the archive fails", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify({
...MINIMAL_MANIFEST,
icons: { 16: "icons/icon16.png", 128: "icons/icon128.png" },
}),
"src/popup/index.html": "<html></html>",
"src/popup/index.js": "//",
"src/background/index.js": "//",
"icons/icon16.png": "PNG",
});
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/would not be self-contained.*icons\/icon128\.png/s,
);
});
test("an archive with no manifest.json at its root fails", () => {
const { members, read } = archiveOf({ "src/popup/index.js": "//" });
expect(() => checkSelfContained("chrome", members, read)).toThrow(