diff --git a/TODO.md b/TODO.md index b5ad5c4..bb91c7b 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,18 @@ but the review is broader than any of them. # Completed Steps +- 2026-08-23: Both manifests declare toolbar icons, and real PNGs at + 16/32/48/128 ship inside both archives + ([#371](https://git.eeqj.de/sneak/AutistMask/issues/371)). Neither manifest + had an `icons` block, so both browsers drew a generic puzzle piece — the first + thing the owner sees on every launch, and how a user tells a real extension + from a look-alike. The sizes `build.js` copies into each browser directory are + read out of the manifest that ships next to them rather than from a second + list, so a declared size `icons/` does not hold fails `make build`; + `script/lib/package.js` already resolves `.png` references, so an icon that + reached a manifest but not the archive fails packaging. The artwork is + original: a flat dark-navy rounded field with a teal triangular "A", drawn + from geometry and rasterised into PNG, nothing traced or downloaded. - 2026-08-23: A swap amount and the token it is counted in now always come from the same hop, on both sides of the approval screen ([#359](https://git.eeqj.de/sneak/AutistMask/issues/359) and diff --git a/build.js b/build.js index 90ac8f5..20d553f 100644 --- a/build.js +++ b/build.js @@ -51,6 +51,17 @@ const RECEIPT_ENV = "AUTISTMASK_BUILD_RECEIPT"; // rather than writing a receipt that cannot be checked. const SAFE_EMITTED_PATH = /^dist\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/; +// Where each browser directory's manifest comes from, and — through its +// "icons" — which image files ship inside that directory. +const MANIFEST_SOURCES = new Map([ + [DIST_CHROME, path.join(__dirname, "manifest", "chrome.json")], + [DIST_FIREFOX, path.join(__dirname, "manifest", "firefox.json")], +]); + +// What an "icons" entry may name: a plain file under icons/, so a manifest +// value is never joined into a path that leaves the repo. +const ICON_REF_RE = /^icons\/[A-Za-z0-9._-]+\.png$/; + function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } @@ -257,6 +268,42 @@ function copyEmitted(src, dest) { recordEmitted(dest); } +// Copy the icons one browser directory ships. The sizes come from the manifest +// that will sit next to them, not from a second list here: a size the manifest +// declares and icons/ does not hold fails the build, rather than shipping a +// manifest whose reference resolves to nothing. Relative to the browser +// directory, so nothing points up and out of it the way dist/styles.css does. +function copyIcons(distDir) { + const manifestPath = MANIFEST_SOURCES.get(distDir); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const refs = Object.values(manifest.icons || {}); + if (refs.length === 0) { + throw new Error( + `${repoRelative(manifestPath)} declares no icons, so the browser ` + + `renders a generic placeholder for this extension`, + ); + } + for (const ref of refs) { + if (!ICON_REF_RE.test(ref)) { + throw new Error( + `${repoRelative(manifestPath)} declares icon ` + + `${JSON.stringify(ref)}, which is not a plain file under ` + + `icons/`, + ); + } + const src = path.join(__dirname, ref); + if (!fs.existsSync(src)) { + throw new Error( + `${repoRelative(manifestPath)} declares ${ref}, which is not ` + + `in this tree`, + ); + } + const dest = path.join(distDir, ref); + ensureDir(path.dirname(dest)); + copyEmitted(src, dest); + } +} + function sha256File(absPath) { return crypto .createHash("sha256") @@ -524,6 +571,8 @@ async function build() { tailwindOutput, path.join(distDir, "src", "popup", "styles.css"), ); + + copyIcons(distDir); } // copy manifests diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000..ba9f3fb Binary files /dev/null and b/icons/icon128.png differ diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000..ab8969a Binary files /dev/null and b/icons/icon16.png differ diff --git a/icons/icon32.png b/icons/icon32.png new file mode 100644 index 0000000..956fecc Binary files /dev/null and b/icons/icon32.png differ diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000..ba06004 Binary files /dev/null and b/icons/icon48.png differ diff --git a/manifest/chrome.json b/manifest/chrome.json index 991618e..b46dd49 100644 --- a/manifest/chrome.json +++ b/manifest/chrome.json @@ -9,6 +9,12 @@ "content_security_policy": { "extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'" }, + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, "action": { "default_popup": "src/popup/index.html" }, diff --git a/manifest/firefox.json b/manifest/firefox.json index 90e64de..5d2c0e2 100644 --- a/manifest/firefox.json +++ b/manifest/firefox.json @@ -5,6 +5,12 @@ "description": "Minimal Ethereum wallet for Firefox", "permissions": ["storage", "activeTab", "alarms", ""], "content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'", + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, "browser_action": { "default_popup": "src/popup/index.html" }, diff --git a/tests/manifest.test.js b/tests/manifest.test.js index 7ebc1da..7e3b258 100644 --- a/tests/manifest.test.js +++ b/tests/manifest.test.js @@ -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. diff --git a/tests/packaging.test.js b/tests/packaging.test.js index cb87836..b60a64a 100644 --- a/tests/packaging.test.js +++ b/tests/packaging.test.js @@ -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": "", + "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(