Notifio is a desktop app that watches rental search pages and tells you when a new listing appears. It ships Playwright's Chromium inside the installer, because the alternative is a first-run experience that asks a person looking for a room to install a browser engine.
That decision has a size. From our own RELEASES.md:
| File | Platform | Size |
|---|---|---|
notifio-arm64.dmg |
macOS Apple Silicon | ~600MB |
notifio-x64.dmg |
macOS Intel | ~600MB |
notifio-setup.exe |
Windows 10+ | ~300MB |
Two Mac files rather than one universal build, which I wrote about separately in Two DMGs, because a universal build would ship two Chromiums. This post is the part that post skipped: the hook that actually does the removing, and why it is written to be allowed to do nothing.
Both files are on notifio.app/download, next to a Windows one.
The shape of the problem
A build machine that can produce both Mac targets has both browsers in its cache, and the pre-build step copies that cache into the project:
"files": [
"dist-ts/**/*",
"playwright-browsers/**/*",
"node_modules/**/*",
"config.json"
],
"asarUnpack": [
"node_modules/playwright/**/*",
"playwright-browsers/**/*"
]
playwright-browsers/**/* is a glob, and a glob does not know what a CPU is. Run electron-builder --mac with arch: ["arm64", "x64"] and the same directory, both browsers in it, lands inside both apps. The Intel installer then carries an Apple Silicon Chromium it can never execute, and vice versa.
There is no electron-builder config for "include this file only for this arch". files is evaluated per build, not per target.
The hook
So the deletion happens after packaging, per arch, in scripts/after-pack.js, wired up with one line of build config:
"afterPack": "scripts/after-pack.js"
Here it is with the logging trimmed:
module.exports = async function afterPack({ appOutDir, arch, electronPlatformName }) {
// Only applies to macOS builds
if (electronPlatformName !== 'darwin') return;
// arch values: 0 = ia32, 1 = x64, 2 = armv7l, 3 = arm64, 4 = universal
const archName = arch === 3 ? 'arm64' : 'x64';
const keep = `chrome-mac-${archName}`;
const remove = archName === 'arm64' ? 'chrome-mac-x64' : 'chrome-mac-arm64';
const browsersBase = path.join(
appOutDir,
'Notifio.app',
'Contents',
'Resources',
'app.asar.unpacked',
'playwright-browsers'
);
if (!fs.existsSync(browsersBase)) {
console.log(`[after-pack] playwright-browsers not found in ${appOutDir}, skipping`);
return;
}
// Find the revision folder (e.g. chromium-1228)
const revFolder = fs.readdirSync(browsersBase).find(d => d.startsWith('chromium-'));
if (!revFolder) {
console.log('[after-pack] No chromium revision folder found, skipping');
return;
}
const toRemove = path.join(browsersBase, revFolder, remove);
if (fs.existsSync(toRemove)) {
fs.rmSync(toRemove, { recursive: true, force: true });
console.log(`[after-pack] Done. Keeping: ${keep}`);
} else {
console.log('[after-pack] Nothing to remove for this arch');
}
};
Four things in there were decisions rather than typing.
1. The window this runs in is the only one that works
afterPack fires once per arch, after electron-builder has assembled Notifio.app and before it wraps that app in a DMG. That is a narrow window and it is the only correct one.
Earlier is wrong: anything that edits playwright-browsers/ in the project directory is editing a shared input. The arm64 pass would delete the x64 browser, and then the x64 pass, running from the same source tree in the same electron-builder --mac invocation, would have nothing to copy.
Later is wrong too, because by then the file is inside a compressed disk image and the only way to remove it is to build the image again.
So the hook mutates the per-arch output directory, which is the one copy of these files that belongs to exactly one artifact.
2. arch is a number, and the number is not the one you want
const archName = arch === 3 ? 'arm64' : 'x64';
arch arrives as an electron-builder enum, not a string: 0 is ia32, 1 is x64, 2 is armv7l, 3 is arm64, 4 is universal. appOutDir does contain the arch as text, so parsing the path is possible, but the path format is electron-builder's business and the enum is documented. I kept the enum and wrote the mapping in a comment directly above it, because arch === 3 is unreadable on its own and will be read by me in a year.
The comparison is also deliberately not a lookup table. Two Mac targets exist; a table of five would imply the other three are handled, and they are not. electronPlatformName !== 'darwin' returns early precisely so the else branch of this ternary can mean x64 rather than "anything that is not arm64".
3. The revision folder is found, not pinned
const revFolder = fs.readdirSync(browsersBase).find(d => d.startsWith('chromium-'));
Playwright stores browsers under a revision directory, chromium-1228 at the moment. That number is decided by the Playwright version in package.json, and it changes on upgrade.
Hardcoding it would make this hook a second place that has to be edited in lockstep with a dependency bump, and the failure mode of forgetting is not an error. It is a successful build producing a DMG with an extra 180MB of unusable binary in it, discovered by a user on a metered connection. A prefix search has no version to keep in sync.
4. Every failure is a log and a return
Three branches here end in a console.log and a return: no browsers directory, no revision folder, nothing to remove.
A thrown error in afterPack fails the whole build. So the question is which is worse: a build that does not finish, or a DMG that is bigger than it should be. For this hook the answer is clear in one direction, because the fat DMG still installs and still runs. Every file it contains is a real Chromium, just not the one this machine will execute.
The honest cost is that a silent skip is quiet, and the symptom is a number nobody looks at. That is covered one step downstream instead, in the release script, which prints the size of each artifact it is about to upload and refuses anything whose mtime predates the build:
const stat = fs.statSync(full);
if (stat.mtimeMs < buildStart - 5000) {
fail(`${file} was not rebuilt (last modified ${stat.mtime.toISOString()}).`);
}
info(`${file} ${humanSize(stat.size)}`);
So every release prints one line per artifact with its size on it, immediately before asking me to confirm an upload. A DMG that is suddenly hundreds of megabytes heavier than the last one is the thing I would actually notice, and it is noticed before the file goes anywhere.
What it saves, with the number I can defend
The comment at the top of the hook claims about a gigabyte per DMG. The table in RELEASES.md attributes roughly 180MB of each finished DMG to Chromium.
Both are right, and the gap between them is compression. The removed directory on disk is close to a gigabyte of uncompressed browser; its contribution to the compressed disk image would have been a few hundred megabytes. If you are wiring up something similar, measure the artifact and not the directory, because the artifact is the thing your users download.
There is a price for the split, and it is paid on the build machine rather than by the user: Playwright only downloads the Chromium for the arch it is running on, so the Intel browser has to be fetched by hand on an Apple Silicon Mac before a release. That is written down in RELEASES.md as a prerequisite, which is the correct place for a step that is done once per machine and then forgotten.
See it from the other end
The two DMGs this produces are both live. The download page detects what you are on and still shows all three buttons rather than guessing for you, which has its own post, because Chrome on an M3 reports itself as an Intel Mac:
- notifio.app/download for the installers
- notifio.app/help for what happens after the DMG mounts
The bundled browser is the reason that page says 600MB out loud instead of hiding it behind a button. A person on a student connection would rather read the number than discover it.
Top comments (0)