Notifio is an Electron app that ships a second Chromium inside it, the one Playwright drives. Electron has its own Chromium for the window; this is a separate browser the app uses to do its work, and it is bundled so that installing the app is one download and no setup. You can see the result on the download page: three buttons, and two of them are macOS builds that differ only in which processor they are for.
Getting a browser out of Playwright's cache and into an installer is three scripts in the prebuild chain:
"prebuild": "pnpm clean && pnpm compile && pnpm renderer && pnpm copy:browsers && pnpm fix:symlinks"
This post is about the last one, which exists entirely because of five symlinks per architecture.
What a copied framework actually contains
copy-browsers.js resolves the revision Playwright expects, finds the OS cache, and copies the platform folders it needs:
fs.cpSync(src, dstSub, { recursive: true });
fs.cpSync does not follow symlinks. It copies them as symlinks, with their targets unchanged, which is almost always what you want and is exactly wrong here.
A macOS .framework is a tree of links. In our staged copy there are exactly ten, five per architecture:
chrome-mac-arm64/.../Google Chrome for Testing Framework.framework/
Resources -> Versions/149.0.7827.55/Resources
Libraries -> Versions/149.0.7827.55/Libraries
Google Chrome for Testing Framework -> Versions/149.0.7827.55/Google Chrome for Testing Framework
Helpers -> Versions/149.0.7827.55/Helpers
Versions/Current -> 149.0.7827.55
That is the layout after the fix script has run. The comment the fix script was written against records what the cache holds instead: the four top level links are absolute there, naming a path under ~/Library/Caches/ms-playwright. Copy the tree anywhere else and you get a browser whose framework reaches back into the cache for its own binary.
And it works. That is the trap. Every build on that machine succeeds, the app runs, the DMG installs. Then someone cleans the cache, or builds on a fresh machine, and the build dies with an ENOENT on a path inside a directory that is not supposed to be part of the build at all. Not during the copy, which is when it would have been obvious, but minutes later during packaging, when electron-builder walks the tree it was handed.
I am writing this on a machine where ~/Library/Caches/ms-playwright does not exist any more. The staged copy still builds, because of the next fifty lines.
Rewriting a link without knowing where the cache was
function toStagedPath(target) {
const parts = target.split(path.sep);
const index = parts.findIndex((p) => /^chromium[-_]/.test(p));
if (index === -1) return null;
return path.join(ROOT, ...parts.slice(index));
}
The pivot is the chromium-<revision> segment, not the cache directory. Everything below that segment is identical in the cache and in the staged copy, because the copy was of that folder. So the translation is: throw away everything to the left of the revision, keep everything to the right, and reattach it to our own root.
Keying on the revision rather than on a known cache path means the script does not care whether the cache was ~/Library/Caches/ms-playwright, or somewhere a PLAYWRIGHT_BROWSERS_PATH pointed at, or a CI runner's own location. It only has to recognise the one folder name Playwright itself generates.
Then each absolute link becomes the relative link a normal framework would have had:
for (const link of links) {
const target = fs.readlinkSync(link);
if (!path.isAbsolute(target)) continue;
const staged = toStagedPath(target);
if (!staged) { console.warn(`Cannot place target, leaving alone: ${link}`); continue; }
if (!fs.existsSync(staged)) { console.error(`Target missing from the staged copy: ${staged}`); broken++; continue; }
const relative = path.relative(path.dirname(link), staged);
fs.unlinkSync(link);
fs.symlinkSync(relative, link);
fixed++;
}
Three properties I would keep if I wrote this again:
It is idempotent. if (!path.isAbsolute(target)) continue means a link that is already relative is left alone, so this can run on every build, including the ones where there is nothing to do. The final log line says which case it was: Rewrote N absolute link(s) as relative, or All 10 link(s) already relative, which is what it prints for us today.
It fails loudly for the one case it cannot fix. If an absolute link points at something that is genuinely not in the staged copy, rewriting it relative would produce a broken link that packaging accepts and the app discovers at runtime. So that path counts, and the script exits non-zero with the command to run:
[fix-symlinks] N link(s) point at files that are not staged.
Re-download the browser: npx playwright install chromium
It verifies the target rather than trusting the translation. fs.existsSync(staged) is what turns a clever string transform into a checked one. Without it, a change in Playwright's cache layout would silently produce ten links to nowhere.
The hook that throws half of it away
Both Mac architectures get staged, because the same build machine produces both DMGs. On disk that is 344MB for arm64 and 356MB for x64. Shipping both inside both installers would mean every user downloading a browser they cannot execute, so an afterPack hook deletes the wrong one after packaging and before the DMG is built:
module.exports = async function afterPack({ appOutDir, arch, electronPlatformName }) {
if (electronPlatformName !== 'darwin') return;
// arch values: 0 = ia32, 1 = x64, 2 = armv7l, 3 = arm64, 4 = universal
const archName = arch === 3 ? 'arm64' : 'x64';
const remove = archName === 'arm64' ? 'chrome-mac-x64' : 'chrome-mac-arm64';
...
fs.rmSync(toRemove, { recursive: true, force: true });
};
That arch === 3 is an electron-builder enum compared as a number, which is the kind of line I would normally refuse to write. The defence is the comment above it listing all five values, and the fact that the else branch is x64 rather than a throw, so an unexpected value produces an Intel build rather than a crash. If universal builds ever get added, this is the line that is wrong, and it is wrong in a way the DMG size would show immediately.
The hook also refuses to guess at a missing tree. If playwright-browsers is not where it expects inside the .app, it logs and returns rather than failing the build, because an installer that is 350MB too big is a worse outcome than a failed build only if you never notice, and the size is checked on every release.
An aside on numbers I had written down
While reading these two scripts I checked them against our own release notes, which say each Mac DMG is around 600MB and breaks that down as roughly 350MB Electron, 180MB Chromium, 70MB everything else.
The actual artifacts from the last build are 278MB and 288MB. The staged Chromium per architecture is 344MB, not 180MB, and DMG compression is doing more work than the table implies.
Neither number is load bearing anywhere in the code, which is exactly why both were allowed to drift. The size that matters to a user is the one on the download page, and the size that matters to me is the one the release script prints after an upload. The table in the middle was a note to myself that aged into a wrong answer.
If you ship a browser inside an app
Three things I would hand to anyone doing this on macOS.
Copy frameworks with something that knows what a symlink is, then check what you produced: find . -type l and readlink on each result takes a second and answers the question directly.
Make the fix idempotent and part of the build, not a thing you remember. Mine runs on every single build and usually reports that there was nothing to do.
Pivot on a path segment that the tool itself generates, never on the absolute path of someone's cache. The machine where this breaks is by definition not the machine you wrote it on.
Top comments (0)