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.
This commit was merged in pull request #376.
This commit is contained in:
12
TODO.md
12
TODO.md
@@ -45,6 +45,18 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
the same hop, on both sides of the approval screen
|
||||||
([#359](https://git.eeqj.de/sneak/AutistMask/issues/359) and
|
([#359](https://git.eeqj.de/sneak/AutistMask/issues/359) and
|
||||||
|
|||||||
49
build.js
49
build.js
@@ -51,6 +51,17 @@ const RECEIPT_ENV = "AUTISTMASK_BUILD_RECEIPT";
|
|||||||
// rather than writing a receipt that cannot be checked.
|
// rather than writing a receipt that cannot be checked.
|
||||||
const SAFE_EMITTED_PATH = /^dist\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
|
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) {
|
function ensureDir(dir) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -257,6 +268,42 @@ function copyEmitted(src, dest) {
|
|||||||
recordEmitted(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) {
|
function sha256File(absPath) {
|
||||||
return crypto
|
return crypto
|
||||||
.createHash("sha256")
|
.createHash("sha256")
|
||||||
@@ -524,6 +571,8 @@ async function build() {
|
|||||||
tailwindOutput,
|
tailwindOutput,
|
||||||
path.join(distDir, "src", "popup", "styles.css"),
|
path.join(distDir, "src", "popup", "styles.css"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
copyIcons(distDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy manifests
|
// copy manifests
|
||||||
|
|||||||
BIN
icons/icon128.png
Normal file
BIN
icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
icons/icon16.png
Normal file
BIN
icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 292 B |
BIN
icons/icon32.png
Normal file
BIN
icons/icon32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 534 B |
BIN
icons/icon48.png
Normal file
BIN
icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 725 B |
@@ -9,6 +9,12 @@
|
|||||||
"content_security_policy": {
|
"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'"
|
"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": {
|
"action": {
|
||||||
"default_popup": "src/popup/index.html"
|
"default_popup": "src/popup/index.html"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
"description": "Minimal Ethereum wallet for Firefox",
|
"description": "Minimal Ethereum wallet for Firefox",
|
||||||
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
||||||
"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'",
|
"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": {
|
"browser_action": {
|
||||||
"default_popup": "src/popup/index.html"
|
"default_popup": "src/popup/index.html"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ const fs = require("fs");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
|
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 = {
|
const EXPECTED_DIRECTIVES = {
|
||||||
"default-src": ["'self'"],
|
"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", () => {
|
describe("shipped Content Security Policy", () => {
|
||||||
// MV3 takes an object and applies extension_pages to the popup and the
|
// MV3 takes an object and applies extension_pages to the popup and the
|
||||||
// background service worker, which is where libsodium runs.
|
// background service worker, which is where libsodium runs.
|
||||||
|
|||||||
@@ -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", () => {
|
test("an archive with no manifest.json at its root fails", () => {
|
||||||
const { members, read } = archiveOf({ "src/popup/index.js": "//" });
|
const { members, read } = archiveOf({ "src/popup/index.js": "//" });
|
||||||
expect(() => checkSelfContained("chrome", members, read)).toThrow(
|
expect(() => checkSelfContained("chrome", members, read)).toThrow(
|
||||||
|
|||||||
Reference in New Issue
Block a user