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.
624 lines
25 KiB
JavaScript
624 lines
25 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 {
|
|
BACKGROUND_ENTRY_PREFIX,
|
|
FORBIDDEN_INPUTS,
|
|
assertTableWellFormed,
|
|
} = 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. A
|
|
// background entry point the table does not name fails as well, so a second
|
|
// worker is protected by default rather than by someone remembering this file.
|
|
// 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._/-]*$/;
|
|
|
|
// 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 });
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// A background entry point with no line in the table fails here too. The five
|
|
// defects this exists to prevent were accidents, and so is adding a second
|
|
// worker entry point without knowing that a table somewhere needs a line: the
|
|
// protection has to be the default for that directory rather than something
|
|
// the next author must opt into.
|
|
function assertNoForbiddenInputs(
|
|
entryPoint,
|
|
outfile,
|
|
metafile,
|
|
record,
|
|
table = FORBIDDEN_INPUTS,
|
|
) {
|
|
const entry = repoRelative(entryPoint);
|
|
const forbidden = table[entry];
|
|
if (!forbidden) {
|
|
if (!entry.startsWith(BACKGROUND_ENTRY_PREFIX)) return;
|
|
throw new Error(
|
|
`${entry} is a background entry point with no line in ` +
|
|
`FORBIDDEN_INPUTS, so nothing stops its bundle from ` +
|
|
`containing the shared state singleton. Add it to ` +
|
|
`script/lib/forbiddenBundleInputs.js. The MV3 worker never ` +
|
|
`populates that singleton, so reading it serves ` +
|
|
`DEFAULT_STATE; use getState()/updateState() from ` +
|
|
`src/background/state.js instead.`,
|
|
);
|
|
}
|
|
|
|
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),
|
|
);
|
|
|
|
// Recorded only once the bundle's inputs are actually in hand. Marking the
|
|
// entry checked any earlier — as this did — means an early return above
|
|
// satisfies assertForbiddenTableCovered() with a bundle nobody examined,
|
|
// and the coverage half cannot tell that from a real check. The lookup
|
|
// above is the fragile step: repoRelative() resolves against process.cwd()
|
|
// while esbuild's output keys are cwd-relative, so a change to where the
|
|
// build runs from could miss.
|
|
record.entriesChecked.add(entry);
|
|
|
|
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.
|
|
//
|
|
// The third way — an entry that lists no modules at all — is refused where the
|
|
// table is defined, at require time, because that one also empties the ESLint
|
|
// rule's forbidden set and so has to fail before either layer runs. It is
|
|
// re-checked here so the build's own half does not depend on the table having
|
|
// been loaded from that file.
|
|
function assertForbiddenTableCovered(record, table = FORBIDDEN_INPUTS) {
|
|
assertTableWellFormed(table);
|
|
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);
|
|
}
|
|
|
|
// 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")
|
|
.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"),
|
|
);
|
|
|
|
copyIcons(distDir);
|
|
}
|
|
|
|
// 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,
|
|
};
|