From 45500e66cf748c67a7db3d20d6ffb38be358eadc Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 23 Aug 2026 21:26:14 +0200 Subject: [PATCH] fix: declare and ship toolbar icons, so neither browser renders a puzzle piece (closes #371) 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. --- TODO.md | 12 ++++++++++ build.js | 49 ++++++++++++++++++++++++++++++++++++++ icons/icon128.png | Bin 0 -> 1875 bytes icons/icon16.png | Bin 0 -> 292 bytes icons/icon32.png | Bin 0 -> 534 bytes icons/icon48.png | Bin 0 -> 725 bytes manifest/chrome.json | 6 +++++ manifest/firefox.json | 6 +++++ tests/manifest.test.js | 51 ++++++++++++++++++++++++++++++++++++++++ tests/packaging.test.js | 20 ++++++++++++++++ 10 files changed, 144 insertions(+) create mode 100644 icons/icon128.png create mode 100644 icons/icon16.png create mode 100644 icons/icon32.png create mode 100644 icons/icon48.png 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 0000000000000000000000000000000000000000..ba9f3fb47663c402a534210d3336cece63231051 GIT binary patch literal 1875 zcmV-Z2dwysP)C3i&>^^)L3n4wbhQbZFE`%DH;?JBq%6_B5p)cccv&Bgd(CiD2fQuC@MmO zq7+3$7gBW82!h~F7_i6)vzWy!W;x4vPq-OF(@Ea_^Ul5ZoO9s^Gs#O{-Z@|L?)%BSsY^P4)Cr0~fFdeSU=Kffr2PCkpl=Lh-; zYdT9(+-w59s)cHPU1wQ}nq8o0^$^WplVWC@HD?IS$G*JtX?zmYTqb<7&!;sfsMr7? zvwea`2R6s~QZSln7BPGxd1%X`xoH4&q)6n`nRa4F+&+(U2UU6Z%csD^VV|7R<#j5# zG7dmXio!0faR7Q8i*WVH2Zmt^KyMrXmX~qe^4YmzxL#g1egP-D0$kgAba3L|Ux$Ie znL^M|=898x8-MiT=UV{4-%J5$D|6W~s}0}t#FJai9|dk>2tZevYtPwi^p1TCgT`-P zO#lG5!F|jC=qvLD1U4JJ@62-(%^wBsV+O#A;`EK3oj2V%SorIQZ2^G$U>Q!|KvJBD zKk(YwZJR#|EW@cLYyhY=?j9Wf`^T98z%sC`06;7^pL=U&^GAVY1pq?1=AH)!$N%}c zFaWSDJVpQ@lEYtoP`LS{z+(geV#?Px927Bm0LGQCZ8#`m(g0jZ`PzoMA|?yKcFNZ_ z)D8b56j+x4KuGy=R>UL#*!$GeXw4r5 z)+qp_qI`e<1|0xcCj~{EJplV(ID_8&QDEHy03qeeQ4wbiz=1bjiProrMXXoMz6;A2hwoen{LNJnX9++E<-7NpXaDyI`fsj^I6DAB zDBt<>?@v50+{RH6X9s|<^1c1f?*_Z(@0xgCxDD>(ric|c<$K+!$F_ZbxR0A6Ryir( z+y400t2qbXetqV8U>Tovz-!D&5oZIy{+FKjYRs`;E(~_wcw)9))XTuKUW!=1RskR( zI z`viXx57>n=}AJ zv2V znC)9n_$)zaiY!W2@-!93FvhSW=MIu_`<KjN*vT178H#KZ;UBDT5{0CAGQmF)h%UJ*b N002ovPDHLkV1g;ZXjcFL literal 0 HcmV?d00001 diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..ab8969a1eaaf6b98e231afb5ad4d4f5dd3c85248 GIT binary patch literal 292 zcmV+<0o(qGP)@ERZ}Yw+LT$lCvUyO)94$l@R|kQ}l&h5-tBIsZ*=?Zq1E z;8+35fz)6%KuFE}ztPz(|BWtg`>(TZE>;YZ1E~S2#V|mrwgMh)gfK`gvH_Pt{!*LJ q_FsKk7f~3b78(|pDbGxlW@i8$bD2`R-qBD10000# z9Pl0-9ng3o%Md|X12Cq*5zP$H#6gw;aw%zGOo0O~Gmx9w1r?0{8=c((#vnGixr2dR z*DF<({WpDZ2#i5&a?K&j0m7PA|BbKgggXGl28q$l0jiyiaLZvBBt{nph?+V7H@SNN z!vP>MkQ~h&pfRT(!*Un~$7rCzD5rXkQ$mfKxf@t6w8zws=ydo45Wr84v-Fw zLa|79#}ar+2x244fz(pf0U%5C_pL-^yX(9Ei&?tDdi7n%pYV=~}fvq8~09mwuE Y0HjcAEpX)nng9R*07*qoM6N<$f&hls)Bpeg literal 0 HcmV?d00001 diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..ba06004027d081456b4ceba45a0c027f216afc97 GIT binary patch literal 725 zcmV;`0xJE9P)JSZOA9*YRQ2`vQ?J=jAL zp%uZy(p#ZWDD;2G(Ze_|Y(l!3>}1w&%w!iHgqb({EfxQ2e^reDF1D|93&sT6HZjX zNsf{apNZ&h!*dFkmA5G)0a~VJd>9l^WaYiqNPwYF`#uZ`C^1tHZ~NR!KJo9;2d+&$ z%*@p4jGliE1>o9L93uicmzKli1E4Jz0venGcdw3P0?>v&>jgOLZ(_*@K%dnD8V4qM z;n%lB0q6tAS^<6An~BK>0LMxJ58Y>c{Lh6_065@UC*bk^o-+9W;94c1b$XVM|NWj? z0Js=qjey}V2hv1Gz8?CJ^eu#0=Cga}KVlZmUF!u9|Hw`|G@zI7pYKy_BEyJV5SBYcLM{O|}rDf=U zzoQ%r4yDClRF>h%k0ajLJE0aYIN(xQ3~d3}7Aq|S3RByQxzuxjTUq!pC+0RTKxP?w zHr{Cm#uy5V!E`NytlBnoy864A7$d9Fx)wuA!0^#WX|=6&2a#22#4sYDV}7w}wXGGh zDvem=0xB(otlHL!$||j6F;WTWT3L;)Quo_+znub@D|UB9JBa|_GF+dg_WD#n%rq85 zDL}S5_XWhhlK|CQ2GdAmF{%pa+uh16`OspB98?0lw3ZoWQ_!iQOy4lQ|=G>>f)F+_Xpi|@u@*weC(0O&uwl2^H*e}"], "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(