Notifio ships a browser. The desktop app bundles Playwright's Chromium so that installing it is one drag and no prompts, which is a nice property for the user and a slightly awkward one for the build, because it means a dependency in package.json now has a 344MB consequence sitting outside node_modules.
"playwright": "1.61.1",
That string selects a Chromium revision. You can ask it which:
$ node -e "console.log(require('playwright-core/browsers.json').browsers.find(b => b.name === 'chromium'))"
{
name: 'chromium',
revision: '1228',
installByDefault: true,
browserVersion: '149.0.7827.55',
title: 'Chrome for Testing'
}
Two numbers come out of one number. Nothing in our source code contains either of them. Our documentation contains both, in two places, and this post is about why that split happened and whether it was the right one.
The script does it properly
scripts/copy-browsers.js runs in prebuild and stages Chromium where electron-builder can see it. It starts like this:
// ── Resolve the Chromium revision expected by the installed playwright version ──
process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(__dirname, '..', 'playwright-browsers');
const { chromium } = require('playwright');
const exePath = chromium.executablePath();
// e.g. "chromium-1228"
const revisionFolder = exePath.split(path.sep).find(p => p.startsWith('chromium-'));
if (!revisionFolder) {
console.error('[copy-browsers] Could not determine Chromium revision from:', exePath);
process.exit(1);
}
This is my favourite three lines in the repo, and the trick is in the order.
chromium.executablePath() does not check that a browser is there. It tells you where this installation of Playwright would look, given the current environment. So we set PLAYWRIGHT_BROWSERS_PATH to our own staging directory first, then ask, and what comes back is a path into our staging directory with the right revision folder in the middle of it. We are using Playwright's own path resolver as an oracle for a number we are not allowed to know, and simultaneously asking it about the directory we intend to fill rather than the one it came from.
Then the revision is extracted by string match, because the thing we actually want is the folder name, and the folder name is the revision. Bumping playwright in package.json changes this automatically and silently, which is the correct behaviour for a value that is not ours.
The source side is resolved the same way, by platform rather than by hardcoding:
function getCacheDir() {
if (process.platform === 'win32') return path.join(os.homedir(), 'AppData', 'Local', 'ms-playwright');
if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright');
return path.join(os.homedir(), '.cache', 'ms-playwright');
}
function getPlatformSubFolders() {
if (process.platform === 'win32') return ['chrome-win64'];
if (process.platform === 'darwin') return ['chrome-mac-arm64', 'chrome-mac-x64'];
return ['chrome-linux'];
}
One Chromium on Windows, two on macOS, because a Mac build produces two DMGs and each one needs the browser for its own architecture.
The part that cannot be automated
And there is the problem. npx playwright install chromium installs the Chromium for the machine you are standing on. On an Apple Silicon Mac that is chrome-mac-arm64, and there is no supported flag that says "and also fetch the Intel one for me". The build needs both. The runbook therefore contains this:
curl -L "https://storage.googleapis.com/chrome-for-testing-public/149.0.7827.55/mac-x64/chrome-mac-x64.zip" \
-o /tmp/chrome-mac-x64.zip
unzip /tmp/chrome-mac-x64.zip -d ~/Library/Caches/ms-playwright/chromium-1228/
rm /tmp/chrome-mac-x64.zip
Both numbers, written out by hand, into a path that belongs to another tool. We are reaching into Playwright's cache directory and putting a browser in it that Playwright did not put there, so that a script which asks Playwright where things are will find it. That works because the cache is a convention rather than a database: a revision folder with the right sub-folders in it is indistinguishable from one the installer made.
It is also the reason the version numbers had to appear in the docs. The script is allowed to derive the revision because it runs after the thing it is asking about exists. The human running step three is operating before that point, with nothing installed, which is the one moment the number cannot be derived from anything local. So the runbook opens with how to go and get the numbers:
node -e "
const b = require('./node_modules/.pnpm/playwright-core@<version>/node_modules/playwright-core/browsers.json');
console.log(b.browsers.find(x => x.name === 'chromium'));
"
and then uses them in the next two steps. The hardcoded 149.0.7827.55 in the documentation is not a duplicated constant. It is an example output of the command directly above it, which happens to still be correct. Those two things look identical in a markdown file and age completely differently, and the only defence is that the command to regenerate it is in the same code block.
Step four is a human invalidating a cache
The runbook's fourth step is:
rm -rf app/playwright-browsers/
with the explanation that copy-browsers.js needs to re-copy from the updated cache. That step exists because of this, which I added after a release build failed on a machine whose cache had been cleaned:
// What is already staged, and what still has to come from the cache.
const staged = subFolders.filter((sub) => fs.existsSync(path.join(dest, sub)));
const needed = subFolders.filter((sub) => !staged.includes(sub));
// The cache is only required for what is actually missing. A machine that has
// staged both browsers already (or had them copied in from elsewhere) can build
// without an ms-playwright cache at all, which is what "safe to re-run" above
// has always claimed. Failing here regardless used to block the release build
// on any machine whose cache had been cleaned.
if (needed.length === 0) {
console.log('[copy-browsers] All browsers already staged, nothing to copy.');
process.exit(0);
}
The check is fs.existsSync. Not a version comparison, not a manifest, not a hash. A directory called chrome-mac-x64 inside a directory called chromium-1228 is treated as the correct browser, because the revision is in the path and the path was derived from the installed Playwright.
That is sound as far as it goes, and it has a hole exactly one revision wide. Bump Playwright, and dest now points at chromium-1230, which does not exist, so needed is both folders and everything re-copies correctly. The staging directory just quietly accumulates the old revision too, 700MB of a browser nothing will ever run again. Which is why the runbook says rm -rf rather than explaining itself: the manual step is not really invalidation, it is garbage collection that I never wrote.
What it does not protect against is a half-copied folder. If fs.cpSync dies partway through, the directory exists, so the next run says "already staged" and ships whatever is in it. I have not hit that, and the honest reason it has not been fixed is that the failure I did hit was the opposite one: the script being too strict and refusing to build on a machine that had everything it needed.
What the build is actually carrying
For scale, from the last Mac build:
app/playwright-browsers/chromium-1228/chrome-mac-arm64 344M
app/playwright-browsers/chromium-1228/chrome-mac-x64 356M
Notifio.app (arm64, after the wrong arch is deleted) 645M
notifio-arm64.dmg 278M
notifio-x64.dmg 288M
The staging directory holds both. Each packaged app holds one, because an afterPack hook deletes the one it will never run. And then compression takes 645MB down to 278MB on the way into the disk image, which is the number a user actually waits for.
Our own release documentation says those DMGs are about 600MB each. It is describing the installed size and labelling it as the file size, and I only noticed because I ran ls -lh while writing this paragraph. The table has been wrong since the day the app shipped, in the user's favour, in a document nobody but me reads.
What I would take from this
The useful question about a runbook is not "why is this not automated". It is "where exactly is the boundary, and does the doc explain why it is there".
Ours sits in a very specific place: everything after Playwright is installed can be derived, and the one thing that happens before Playwright is installed cannot. The script holds the derivable half and refuses to hardcode any of it. The README holds the other half, with the command to regenerate its own constants printed immediately above them.
The step I would actually go and fix is step four, because a manual rm -rf in a runbook is almost always a cache without an eviction rule. Everything else in that document is a human doing the one part a machine cannot.
The thing all of this is in service of is at notifio.app/download, where the three buttons and no guessing are the other end of the same architecture problem, and the sites it watches with that bundled browser are listed under alerts.
Top comments (0)