Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.
So the reachability goes rather than a sixth call site.
The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile.
- Every handler takes one snapshot and answers from it, including the address
it names: activeAddressOf(s) replaced a second, later storage read that
could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
chainSwitch.js, which keeps the singleton path for the popup) inside
updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
balances that came back by address, so it never publishes an object other
in-flight work holds, and a wallet added or deleted during the round trip
survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
snapshot. They used to come from different moments, so a chain switch
committed in between moved the endpoint under an artifact already verified
against the old chain.
getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.
The prohibition is enforced mechanically, not by review, and it is enforced by
the bundler rather than by a guess at what the bundler does. The table of
modules an entry point's bundle may not contain lives in
script/lib/forbiddenBundleInputs.js — one copy, read by both layers that act on
it — and build.js's assertNoForbiddenInputs() fails the build when esbuild's
metafile reports src/shared/state.js as an input of a background bundle, naming
the import chain from the metafile's own graph. That is the resolution the
shipped bundle was built from, so no specifier syntax, no hop and no resolution
rule can slip past it; Dockerfile:42 runs make build, so it holds in CI.
Both halves of the table are checked for rot, because either one turns the
prohibition into a pass that checks nothing: a key no bundled entry point
matched fails, and so does a listed module this build bundled nowhere. The
second is what makes a rename of src/shared/state.js loud instead of silently
disarming the check, and it is stronger than an existsSync() because it also
fails when the module is still there but has dropped out of every bundle. What
the assertion does NOT cover is a COPY of the singleton at another path: it is
keyed by path, so a copy builds and lints clean. That is stated where the table
lives, with the reason it is accepted — a copy carries the singleton's own
guard, so a background read of it throws rather than being served DEFAULT_STATE.
make check does not run make build, so the assertion is unit tested against
synthetic metafiles in tests/buildForbiddenInputs.test.js: build.js runs its
build() only as a program now and exports the checks. Executing a check in CI
is not testing it — without that file, inverting the condition leaves every
check in this repo green with the singleton back in the worker.
A custom ESLint rule walks the CommonJS require graph from every src/background/
file and reports the same thing in the editor, before a full bundle. It reads
the same table, and it matches specifiers textually, so it is best-effort fast
feedback and not the guarantee — two earlier revisions of it shipped holes (a
template literal, a dynamic import(), a comment inside the call, a directory
resolved through package.json main). Those are covered now and pinned by
tests/backgroundStateLintRule.test.js. Two shapes it does not report are
recorded in its header as known divergences the build catches, both measured: a
computed specifier (require("../shared/" + "state"), which esbuild
constant-folds into the bundle) and a symlink to the module (esbuild reports the
real path). Each is make lint exit 0 and make build exit 2.
Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.
Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.
closes #320
537 lines
21 KiB
JavaScript
537 lines
21 KiB
JavaScript
const fs = require("fs");
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
const { execSync } = require("child_process");
|
|
const esbuild = require("esbuild");
|
|
const { resolveVersion } = require("./script/lib/version");
|
|
const { FORBIDDEN_INPUTS } = require("./script/lib/forbiddenBundleInputs");
|
|
|
|
const DIST = path.join(__dirname, "dist");
|
|
const DIST_CHROME = path.join(DIST, "chrome");
|
|
const DIST_FIREFOX = path.join(DIST, "firefox");
|
|
const SRC = path.join(__dirname, "src");
|
|
|
|
// The module whose compiled DEBUG state script/verify-build asserts. Which
|
|
// bundles contain it is derived from esbuild's own dependency graph rather
|
|
// than from a hardcoded list, so it tracks the bundle layout instead of
|
|
// rotting with it.
|
|
const AUDITED_MODULE = "src/shared/constants.js";
|
|
|
|
// FORBIDDEN_INPUTS — what each entry point's bundle may not contain, and what
|
|
// that covers — lives in script/lib/forbiddenBundleInputs.js, because the
|
|
// ESLint rule reads the same table and two literal copies of a path drift.
|
|
//
|
|
// This is the authoritative check, and it is here rather than in the linter
|
|
// because it consults the resolution esbuild actually performed. Any specifier
|
|
// syntax, any hop, any resolution rule that puts the module in the bundle fails
|
|
// the build, whether or not a text matcher would have recognized it.
|
|
// Dockerfile:42 runs `make build`, so it is enforced in CI.
|
|
|
|
// The build receipt: every file this build emits, with its sha256 and whether
|
|
// it is one of the audited bundles. script/verify-build is handed this and
|
|
// checks dist/ against it, so the file list comes from the build that just ran
|
|
// rather than being read back out of the tree it is supposed to vouch for.
|
|
//
|
|
// The path is supplied by the caller, not chosen here, and the Makefile makes
|
|
// a fresh one per invocation outside the repo: that is what ties a receipt to
|
|
// one build rather than leaving a standing file anyone can write.
|
|
const RECEIPT_HEADER = "autistmask-build-receipt v1";
|
|
const RECEIPT_ENV = "AUTISTMASK_BUILD_RECEIPT";
|
|
|
|
// Every emitted path must be plainly nameable, because the receipt is a
|
|
// line-oriented text file consumed by a POSIX shell script and a path with a
|
|
// space or a newline in it could not be read back unambiguously. Nothing this
|
|
// build emits looks like that; if that ever changes, the build fails here
|
|
// rather than writing a receipt that cannot be checked.
|
|
const SAFE_EMITTED_PATH = /^dist\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
|
|
|
|
function ensureDir(dir) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
// Repo-relative, forward-slashed, so the manifest reads the same on every
|
|
// platform and can be consumed by a POSIX shell script without further work.
|
|
function repoRelative(p) {
|
|
return path.relative(__dirname, p).split(path.sep).join("/");
|
|
}
|
|
|
|
// Collect the outputs of one esbuild run that bundle AUDITED_MODULE. esbuild
|
|
// reports every input that contributed to an output in the metafile, which is
|
|
// the authoritative answer to "is constants.js in this bundle" — unlike
|
|
// searching the minified text, it does not depend on what survived minification.
|
|
//
|
|
// The ".js" filter below is the only place that assumption lives:
|
|
// script/verify-build reads every file the receipt names, whatever its
|
|
// extension, and fails on any that carries a debug marker without being
|
|
// recorded as an audited bundle — so a bundle emitted under some other
|
|
// extension fails there rather than escaping both checks at once.
|
|
function outputsContainingAuditedModule(metafile) {
|
|
return Object.entries(metafile.outputs)
|
|
.filter(([outFile, info]) => {
|
|
if (!outFile.endsWith(".js")) return false;
|
|
return Object.keys(info.inputs).some(
|
|
(input) => repoRelative(input) === AUDITED_MODULE,
|
|
);
|
|
})
|
|
.map(([outFile]) => repoRelative(outFile));
|
|
}
|
|
|
|
// Shortest import chain from `entryInput` to `target` through the metafile's
|
|
// own input graph, or null when there is none. The message this feeds is the
|
|
// point of the check: "state.js is in the worker bundle" is not actionable on
|
|
// its own, "index.js -> chainSwitchFields.js -> state.js" is.
|
|
function importChain(metafile, entryInput, target) {
|
|
const graph = new Map(
|
|
Object.entries(metafile.inputs).map(([input, info]) => [
|
|
repoRelative(input),
|
|
(info.imports || []).map((i) => repoRelative(i.path)),
|
|
]),
|
|
);
|
|
const start = repoRelative(entryInput);
|
|
const seen = new Set([start]);
|
|
const queue = [[start]];
|
|
while (queue.length > 0) {
|
|
const chain = queue.shift();
|
|
for (const next of graph.get(chain[chain.length - 1]) || []) {
|
|
if (next === target) return chain.concat([next]);
|
|
if (seen.has(next)) continue;
|
|
seen.add(next);
|
|
queue.push(chain.concat([next]));
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// What the forbidden-input checks accumulate over a whole build: which
|
|
// FORBIDDEN_INPUTS keys were actually bundled, and every input of every output
|
|
// this build emitted. Both are read by assertForbiddenTableCovered() at the
|
|
// end — a table entry naming something that is not there any more enforces
|
|
// nothing, and must fail rather than pass quietly.
|
|
function newForbiddenRecord() {
|
|
return { entriesChecked: new Set(), bundledInputs: new Set() };
|
|
}
|
|
|
|
// Note every input of every output of one esbuild run. Deliberately not
|
|
// restricted to the entry points named in FORBIDDEN_INPUTS: it is the POPUP
|
|
// that legitimately bundles src/shared/state.js, and that is what makes
|
|
// "the forbidden module still exists at this path" checkable at all.
|
|
function recordBundledInputs(metafile, record) {
|
|
for (const info of Object.values(metafile.outputs)) {
|
|
for (const input of Object.keys(info.inputs)) {
|
|
record.bundledInputs.add(repoRelative(input));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fail the build when an entry point's bundle contains a module it is
|
|
// prohibited from reaching. The inputs come from esbuild's metafile, so this is
|
|
// the resolution the shipped bundle was built from and not a guess at it.
|
|
function assertNoForbiddenInputs(
|
|
entryPoint,
|
|
outfile,
|
|
metafile,
|
|
record,
|
|
table = FORBIDDEN_INPUTS,
|
|
) {
|
|
const entry = repoRelative(entryPoint);
|
|
const forbidden = table[entry];
|
|
if (!forbidden) return;
|
|
record.entriesChecked.add(entry);
|
|
|
|
const out = repoRelative(outfile);
|
|
const entryOutput = Object.entries(metafile.outputs).find(
|
|
([outFile]) => repoRelative(outFile) === out,
|
|
);
|
|
if (!entryOutput) {
|
|
throw new Error(`esbuild reported no metafile output for ${out}`);
|
|
}
|
|
const inputs = new Set(
|
|
Object.keys(entryOutput[1].inputs).map(repoRelative),
|
|
);
|
|
|
|
for (const module of forbidden) {
|
|
if (!inputs.has(module)) continue;
|
|
const chain = importChain(metafile, entryPoint, module);
|
|
throw new Error(
|
|
`${out} bundles ${module}, which ${entry} must not reach` +
|
|
`${chain ? `: ${chain.join(" -> ")}` : ""}. The MV3 worker ` +
|
|
`never populates the shared state singleton, so reading it ` +
|
|
`serves DEFAULT_STATE. Use getState()/updateState() from ` +
|
|
`src/background/state.js instead.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Fail the build when the table has rotted away from the tree it describes.
|
|
// Both halves of an entry rot independently, and either one turns the whole
|
|
// prohibition into a pass that checks nothing:
|
|
//
|
|
// - the KEY, when no bundled entry point matches it: the entry point was
|
|
// renamed or is no longer built, and no bundle was ever tested against the
|
|
// list;
|
|
// - the MODULE, when this build bundled it nowhere: the module was renamed,
|
|
// moved or deleted, so "is it an input of the background bundle" is asked
|
|
// about a path nothing resolves to and is answered no forever. The popup
|
|
// legitimately bundles src/shared/state.js, which is what makes this
|
|
// checkable — and it is stronger than an existsSync(), because it also
|
|
// fails when the file is still there but has dropped out of every bundle.
|
|
//
|
|
// This matters concretely: https://git.eeqj.de/sneak/AutistMask/issues/311
|
|
// rewrites this persistence layer, and a rename that quietly disarmed the
|
|
// guarantee would put the singleton back within reach of the worker with every
|
|
// check in the repo still green.
|
|
function assertForbiddenTableCovered(record, table = FORBIDDEN_INPUTS) {
|
|
for (const [entry, modules] of Object.entries(table)) {
|
|
if (!record.entriesChecked.has(entry)) {
|
|
throw new Error(
|
|
`${entry} is listed in FORBIDDEN_INPUTS but was not bundled, ` +
|
|
`so nothing checked it`,
|
|
);
|
|
}
|
|
for (const module of modules) {
|
|
if (record.bundledInputs.has(module)) continue;
|
|
throw new Error(
|
|
`${module} is listed in FORBIDDEN_INPUTS for ${entry}, but ` +
|
|
`this build bundled it nowhere, so the prohibition names ` +
|
|
`a module that is not in this tree at that path and ` +
|
|
`nothing enforces it. If the module moved, move it in ` +
|
|
`script/lib/forbiddenBundleInputs.js too, which both ` +
|
|
`this check and the ESLint rule read.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every file this build writes under dist/, recorded as it is written. This is
|
|
// the build's own account of what it emitted; it is never recovered by
|
|
// listing dist/, because a file that is in dist/ without this build having put
|
|
// it there is exactly what the receipt exists to expose.
|
|
const emittedFiles = [];
|
|
|
|
function recordEmitted(absPath) {
|
|
emittedFiles.push(absPath);
|
|
}
|
|
|
|
// Copying is the only other way a file reaches dist/; esbuild and the Tailwind
|
|
// CLI record their outputs where they are invoked.
|
|
function copyEmitted(src, dest) {
|
|
fs.copyFileSync(src, dest);
|
|
recordEmitted(dest);
|
|
}
|
|
|
|
function sha256File(absPath) {
|
|
return crypto
|
|
.createHash("sha256")
|
|
.update(fs.readFileSync(absPath))
|
|
.digest("hex");
|
|
}
|
|
|
|
// Write the receipt for the files this build emitted. Deliberately records no
|
|
// build mode: which mode was asked for is script/verify-build's argument, so
|
|
// build.js cannot vouch for build.js. All the receipt says is "these bytes,
|
|
// under these names, are what I wrote, and these ones bundle constants.js".
|
|
function writeReceipt(receiptPath, auditedBundles) {
|
|
const audited = new Set(auditedBundles);
|
|
const paths = [...new Set(emittedFiles.map(repoRelative))].sort();
|
|
|
|
for (const p of paths) {
|
|
if (!SAFE_EMITTED_PATH.test(p)) {
|
|
throw new Error(
|
|
`emitted path cannot be written to a build receipt: ${JSON.stringify(p)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// A bundle esbuild reported but that nothing recorded as emitted means the
|
|
// two halves have drifted apart, and the receipt would then leave an
|
|
// audited bundle out. Fail rather than emit a short receipt.
|
|
for (const bundle of audited) {
|
|
if (!paths.includes(bundle)) {
|
|
throw new Error(
|
|
`${bundle} contains ${AUDITED_MODULE} but was not recorded as emitted`,
|
|
);
|
|
}
|
|
}
|
|
if (audited.size === 0) {
|
|
throw new Error(
|
|
`no emitted bundle contains ${AUDITED_MODULE}, which is never correct`,
|
|
);
|
|
}
|
|
|
|
const lines = [RECEIPT_HEADER, `root ${fs.realpathSync(__dirname)}`];
|
|
for (const p of paths) {
|
|
const flag = audited.has(p) ? "A" : "P";
|
|
lines.push(`file ${sha256File(path.join(__dirname, p))} ${flag} ${p}`);
|
|
}
|
|
fs.writeFileSync(receiptPath, lines.map((l) => `${l}\n`).join(""));
|
|
|
|
console.log(
|
|
`Build receipt: ${paths.length} emitted file(s), ${audited.size} ` +
|
|
`containing ${AUDITED_MODULE} (${receiptPath})`,
|
|
);
|
|
}
|
|
|
|
// Where the receipt goes, decided before anything is emitted so a build that
|
|
// cannot produce a checkable receipt fails before it writes any artifacts.
|
|
// Inside dist/ is refused: a receipt that lives in the tree it describes can
|
|
// be rewritten by whoever rewrites the tree, which is the hole this replaces.
|
|
function receiptTarget() {
|
|
const requested = process.env[RECEIPT_ENV];
|
|
if (!requested) {
|
|
return null;
|
|
}
|
|
const resolved = path.resolve(requested);
|
|
if (resolved === DIST || resolved.startsWith(DIST + path.sep)) {
|
|
throw new Error(
|
|
`${RECEIPT_ENV} points inside dist/ (${resolved}). The receipt ` +
|
|
`describes dist/ and must not live in it.`,
|
|
);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
// DEBUG is a build-time flag, off unless explicitly requested. It is the only
|
|
// thing that makes the hardcoded test mnemonic reachable, so the opt-in must be
|
|
// exact: anything other than the literal "1" (unset, empty, "true", a typo)
|
|
// produces a release build. Failing towards the safe mode is deliberate.
|
|
function isDebugBuild() {
|
|
return process.env.AUTISTMASK_DEBUG === "1";
|
|
}
|
|
|
|
// A short git output, or null when git cannot answer. Distinguishing "git said
|
|
// nothing" from "git could not be asked" matters below: a working tree whose
|
|
// state is unknown must not be stamped as clean.
|
|
function git(args) {
|
|
try {
|
|
return execSync(`git ${args}`, {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
}).trim();
|
|
} catch {
|
|
// not a git repo, or git not available
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// The working-tree state, as a suffix for the displayed commit: "" when the
|
|
// tree matches HEAD, "-dirty" when it does not, "-unknown" when git answered
|
|
// the hash but not the status. Without this a build from a modified tree
|
|
// stamped a clean hash, so the About screen named a commit whose contents were
|
|
// not what was running — the one thing that stamp exists to establish.
|
|
//
|
|
// git status --porcelain honours .gitignore, so dist/ and node_modules/ do not
|
|
// make every build dirty; an untracked file that is NOT ignored does, and
|
|
// correctly: it may well be in the bundle.
|
|
function worktreeSuffix() {
|
|
const status = git("status --porcelain");
|
|
if (status === null) return "-unknown";
|
|
return status === "" ? "" : "-dirty";
|
|
}
|
|
|
|
function getBuildInfo() {
|
|
const pkg = JSON.parse(
|
|
fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
|
|
);
|
|
const commitHashFull = git("rev-parse HEAD") || "unknown";
|
|
const shortHash = git("rev-parse --short HEAD") || "unknown";
|
|
// The full hash is left clean because it is the href of the commit link in
|
|
// the About screen, and "abc123-dirty" is not a commit anyone can fetch.
|
|
// The displayed short hash carries the marker, so the screen says the tree
|
|
// was modified while still linking somewhere real.
|
|
const commitHash =
|
|
shortHash === "unknown" ? shortHash : shortHash + worktreeSuffix();
|
|
return {
|
|
// Fails the build when package.json and the two manifests disagree;
|
|
// see script/lib/version.js. Called before anything is emitted, so a
|
|
// tree with no single version never reaches dist/.
|
|
version: resolveVersion(__dirname),
|
|
license: pkg.license,
|
|
author: pkg.author,
|
|
commitHash,
|
|
commitHashFull,
|
|
buildDate: new Date().toISOString().slice(0, 10),
|
|
};
|
|
}
|
|
|
|
async function build() {
|
|
console.log("Building AutistMask extension...");
|
|
|
|
const receiptPath = receiptTarget();
|
|
if (!receiptPath) {
|
|
console.warn(
|
|
`WARNING: ${RECEIPT_ENV} is unset, so this build writes no ` +
|
|
`receipt and script/verify-build cannot verify what it ` +
|
|
`emitted. Build through make build / make build-debug.`,
|
|
);
|
|
}
|
|
|
|
const buildInfo = getBuildInfo();
|
|
console.log("Build info:", buildInfo);
|
|
|
|
const debugBuild = isDebugBuild();
|
|
console.log(
|
|
debugBuild
|
|
? "Build mode: DEBUG (INSECURE - hardcoded test mnemonic, do not ship)"
|
|
: "Build mode: release (DEBUG off)",
|
|
);
|
|
|
|
const define = {
|
|
__BUILD_DEBUG__: JSON.stringify(debugBuild),
|
|
__BUILD_VERSION__: JSON.stringify(buildInfo.version),
|
|
__BUILD_LICENSE__: JSON.stringify(buildInfo.license),
|
|
__BUILD_AUTHOR__: JSON.stringify(buildInfo.author),
|
|
__BUILD_COMMIT__: JSON.stringify(buildInfo.commitHash),
|
|
__BUILD_COMMIT_FULL__: JSON.stringify(buildInfo.commitHashFull),
|
|
__BUILD_DATE__: JSON.stringify(buildInfo.buildDate),
|
|
};
|
|
|
|
// Emitted bundles that contain constants.js, accumulated across every
|
|
// esbuild run below and recorded in the receipt for script/verify-build.
|
|
const auditedBundles = [];
|
|
|
|
// What the forbidden-input checks accumulate across those same runs.
|
|
const forbiddenRecord = newForbiddenRecord();
|
|
|
|
// compile tailwind CSS
|
|
console.log("Compiling Tailwind CSS...");
|
|
const tailwindInput = path.join(SRC, "popup", "styles", "main.css");
|
|
const tailwindOutput = path.join(DIST, "styles.css");
|
|
|
|
// Start from an empty dist/, so what is there afterwards is what this
|
|
// build put there and nothing else. Leftovers from an earlier build are
|
|
// not covered by this build's receipt, and script/verify-build rejects
|
|
// any file it did not emit rather than ignoring it.
|
|
fs.rmSync(DIST, { recursive: true, force: true });
|
|
ensureDir(DIST);
|
|
|
|
// The locally installed binary, not `npx` — npx silently fetches from the
|
|
// registry when the binary is absent, which is an unpinned network fetch
|
|
// in the middle of a build.
|
|
const tailwindBin = path.join(
|
|
__dirname,
|
|
"node_modules",
|
|
".bin",
|
|
"tailwindcss",
|
|
);
|
|
execSync(
|
|
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
|
|
{ stdio: "inherit" },
|
|
);
|
|
recordEmitted(tailwindOutput);
|
|
|
|
// Every bundle goes through here, so metafile collection cannot be
|
|
// forgotten when a new entry point is added.
|
|
async function bundle(entryPoint, outfile) {
|
|
const result = await esbuild.build({
|
|
entryPoints: [entryPoint],
|
|
bundle: true,
|
|
format: "iife",
|
|
outfile,
|
|
platform: "browser",
|
|
target: ["chrome110", "firefox110"],
|
|
minify: true,
|
|
metafile: true,
|
|
define,
|
|
});
|
|
// Before the output is recorded as emitted: a bundle that violates a
|
|
// prohibition must abort the build, not be written into a receipt.
|
|
recordBundledInputs(result.metafile, forbiddenRecord);
|
|
assertNoForbiddenInputs(
|
|
entryPoint,
|
|
outfile,
|
|
result.metafile,
|
|
forbiddenRecord,
|
|
);
|
|
recordEmitted(outfile);
|
|
auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
|
|
}
|
|
|
|
for (const distDir of [DIST_CHROME, DIST_FIREFOX]) {
|
|
ensureDir(path.join(distDir, "src", "popup"));
|
|
ensureDir(path.join(distDir, "src", "background"));
|
|
ensureDir(path.join(distDir, "src", "content"));
|
|
|
|
// bundle popup JS with esbuild (inlines ethers, libsodium, etc.)
|
|
await bundle(
|
|
path.join(SRC, "popup", "index.js"),
|
|
path.join(distDir, "src", "popup", "index.js"),
|
|
);
|
|
|
|
// bundle background script
|
|
await bundle(
|
|
path.join(SRC, "background", "index.js"),
|
|
path.join(distDir, "src", "background", "index.js"),
|
|
);
|
|
|
|
// bundle content script
|
|
await bundle(
|
|
path.join(SRC, "content", "index.js"),
|
|
path.join(distDir, "src", "content", "index.js"),
|
|
);
|
|
|
|
// bundle inpage script (injected into page context, separate file)
|
|
await bundle(
|
|
path.join(SRC, "content", "inpage.js"),
|
|
path.join(distDir, "src", "content", "inpage.js"),
|
|
);
|
|
|
|
// copy popup HTML
|
|
copyEmitted(
|
|
path.join(SRC, "popup", "index.html"),
|
|
path.join(distDir, "src", "popup", "index.html"),
|
|
);
|
|
|
|
// place compiled CSS next to popup HTML
|
|
copyEmitted(
|
|
tailwindOutput,
|
|
path.join(distDir, "src", "popup", "styles.css"),
|
|
);
|
|
}
|
|
|
|
// copy manifests
|
|
copyEmitted(
|
|
path.join(__dirname, "manifest", "chrome.json"),
|
|
path.join(DIST_CHROME, "manifest.json"),
|
|
);
|
|
copyEmitted(
|
|
path.join(__dirname, "manifest", "firefox.json"),
|
|
path.join(DIST_FIREFOX, "manifest.json"),
|
|
);
|
|
|
|
assertForbiddenTableCovered(forbiddenRecord);
|
|
|
|
// Written last so a build that died partway through leaves no receipt at
|
|
// all, which script/verify-build treats as a hard failure rather than as
|
|
// "nothing to check".
|
|
if (receiptPath) {
|
|
writeReceipt(receiptPath, auditedBundles);
|
|
}
|
|
|
|
console.log("Build complete: dist/chrome/ and dist/firefox/");
|
|
}
|
|
|
|
// Run only as a program. Required as a module — which is how
|
|
// tests/buildForbiddenInputs.test.js reaches the checks below — this file
|
|
// builds nothing and writes nothing.
|
|
if (require.main === module) {
|
|
build().catch((err) => {
|
|
console.error(
|
|
`Build failed: ${err && err.message ? err.message : err}`,
|
|
);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
// Exported for tests/buildForbiddenInputs.test.js only. The prohibition these
|
|
// three functions enforce is the guarantee behind
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/324, and `make check` does not
|
|
// run `make build` — so they are unit tested against synthetic metafiles
|
|
// rather than being exercised only by CI, where "it ran" is not "it works".
|
|
module.exports = {
|
|
importChain,
|
|
newForbiddenRecord,
|
|
recordBundledInputs,
|
|
assertNoForbiddenInputs,
|
|
assertForbiddenTableCovered,
|
|
};
|