manifest/chrome.json now carries a fixed public key, so the extension id and the chrome.storage.local partition holding the wallet stay stable across checkout moves and re-clones instead of being derived from the absolute path. A release entrypoint produces a self-contained versioned artifact per browser, including the files that sit at dist/ root outside both browser directories. One version source of truth, enforced: the build fails naming the culprit when the two manifests and package.json disagree, and BUILD_COMMIT now marks a dirty tree as dirty. Firefox ships an unsigned XPI; the README states that release Firefox and ESR refuse it, that Developer Edition or Unbranded is required, and that Remove is irreversible except from the recovery phrase, which is asserted by test.
366 lines
14 KiB
JavaScript
366 lines
14 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 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";
|
|
|
|
// 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));
|
|
}
|
|
|
|
// 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 = [];
|
|
|
|
// 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,
|
|
});
|
|
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"),
|
|
);
|
|
|
|
// 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/");
|
|
}
|
|
|
|
build().catch((err) => {
|
|
console.error(`Build failed: ${err && err.message ? err.message : err}`);
|
|
process.exit(1);
|
|
});
|