Notifio bundles its own Playwright-managed Chromium, on top of the Chromium that Electron already contains. That decision, and the packaging work behind it, is in Shipping Playwright's Chromium inside a packaged Electron app and its sequel about symlinks pointing back at a cache.
Once a browser is inside your installer, size stops being an aesthetic concern and starts driving the shape of your release pipeline. This post is the delivery half: how three artifacts get built, how they get to a user, and the one place I decided to ask the user a question rather than detect the answer.
Stage the superset, prune per artifact
The prebuild step stages both Mac architectures:
function getPlatformSubFolders() {
if (process.platform === 'win32') return ['chrome-win64'];
if (process.platform === 'darwin') return ['chrome-mac-arm64', 'chrome-mac-x64'];
return ['chrome-linux'];
}
Both, on one machine, because the same source tree builds both Mac targets in one electron-builder run. Then each packaged app throws away the browser it cannot use, in an afterPack hook:
/**
* Removes the Chromium browser binary that doesn't match the current build
* arch so each installer only ships the browser it actually needs (~1GB saved
* per DMG).
*
* notifio-arm64.dmg -> keeps chrome-mac-arm64, deletes chrome-mac-x64
* notifio-x64.dmg -> keeps chrome-mac-x64, deletes chrome-mac-arm64
*/
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';
const browsersBase = path.join(
appOutDir, 'Notifio.app', 'Contents', 'Resources',
'app.asar.unpacked', 'playwright-browsers'
);
// ...find the chromium-<revision> folder, rm the other arch
};
Stage a superset, prune per artifact. The alternative is a conditional copy per build, which means the staging directory's contents depend on which target you are currently building, which means a second build in the same session starts from a directory that is missing something. Staging everything and subtracting at the end makes the expensive step idempotent and the cheap step disposable.
Two things in that hook are worth flagging if you write one:
arch === 3 is a magic number. electron-builder passes its own Arch enum, and inside a plain CommonJS hook there is no enum in scope to compare against. The comment listing all five values is not decoration, it is the only thing standing between the next reader and a build that silently ships the wrong browser. Write the table down.
The hook has to know the packaged layout by path, including the app name, Contents/Resources, and app.asar.unpacked. If any of that changes the path stops resolving, so the hook logs and returns rather than throwing:
if (!fs.existsSync(browsersBase)) {
console.log(`[after-pack] playwright-browsers not found in ${appOutDir}, skipping`);
return;
}
That is arguably the wrong call. A silent skip here produces a working installer that is a gigabyte larger than it should be, which is a defect nobody notices. If I revisit it, the !darwin path stays a quiet return and the missing-directory path becomes a hard failure, because a Mac build that cannot find the browsers it staged is a build that has moved and not told anyone.
Why not one universal binary
macOS universal builds are the tidy answer, and they would double the payload. The Chromium is the payload. A universal DMG would carry chrome-mac-arm64 and chrome-mac-x64 in the same file, and every Apple Silicon user would download a gigabyte of Intel browser to never run it.
So there are two Mac artifacts and one Windows artifact, with fixed names:
const FILES: Record<string, string> = {
"mac-arm64": "notifio-arm64.dmg",
"mac-x64": "notifio-x64.dmg",
win: "notifio-setup.exe",
};
The route between the button and the bucket
Those three files live in a private Cloudflare R2 bucket. The download endpoint signs a URL and redirects:
const url = await getSignedUrl(
client,
new GetObjectCommand({ Bucket: bucket, Key: fileName }),
{ expiresIn: 60 }
);
return NextResponse.redirect(url, { status: 302 });
// Presigned URL valid for 60 seconds, enough for the browser to start the
// download without permanently exposing the file.
Sixty seconds sounds alarming for a 400MB download and is not, because the signature is checked when the request is made, not while the bytes are flowing. A transfer that starts inside the window runs to completion. What expires is the ability to start another one, so the URL someone copies out of their download manager and pastes into a group chat is dead within a minute.
The stable thing is the route, /api/download/mac-arm64, which is what the page links and what can safely appear in copy, in an email, or in structured data. The object URL is an implementation detail that never leaves the redirect.
Both error paths are deliberate:
if (!fileName) {
return NextResponse.json(
{ error: `Unknown platform "${platform}". Use "mac-arm64", "mac-x64", or "win".` },
{ status: 400 }
);
}
if (!accountId || !accessKeyId || !secretAccessKey) {
console.error("[download] Missing R2 environment variables");
return NextResponse.json({ error: "Download is temporarily unavailable." }, { status: 503 });
}
A bad platform segment gets the full list of valid values, because the only people who see it are developers and crawlers, and there is nothing sensitive about three platform names. A misconfigured server gets a sentence a customer can understand, and the diagnosis goes to the log. Which of your two audiences an error message is written for is a decision worth making on purpose, and I have made the same call elsewhere in these products.
Fixed keys, so a release is an overwrite
* R2 object keys are fixed (the download API serves them by name), so a release
* overwrites the previous one.
*
* pnpm release # patch bump, build for this OS, upload
* pnpm release --archive # also upload a version-stamped copy
* pnpm release --dry-run # print the plan, touch nothing
No latest pointer, no version resolution step, no manifest. The route names an object and the object is current by construction. The cost is that rolling back means re-uploading a previous build, which is why --archive exists, and the benefit is that there is no state anywhere that can disagree about which build is live. For a two-person release cadence that is the right trade. For a fleet with staged rollouts it would not be.
Also note --dry-run printing the plan. Any script that can overwrite the artifact your customers download should be able to tell you what it is about to do without doing it.
The page asks which Mac you have
Here is the decision I expect the most argument about. notifio.app/download does not detect your architecture. It shows two Mac buttons:
macOS Monterey 12+ · Apple Silicon & Intel
[ Apple Silicon M1 / M2 / M3 · .dmg ]
[ Intel Mac x64 · .dmg ]
The reason is that browsers will not reliably tell you. The classic user agent string on an Apple Silicon Mac says Macintosh; Intel Mac OS X 10_15_7, the same as a genuine Intel Mac, because changing it would have broken the web in 2020. Chromium-based browsers can answer honestly through client hints, navigator.userAgentData.getHighEntropyValues(["architecture"]), but Safari does not implement that, and Safari is a large share of Mac visitors. The remaining tricks are GPU renderer strings and similar fingerprinting, which are both unreliable and rude.
So the choice is between a heuristic that is wrong for some Safari users and a question that is never wrong. And the cost of being wrong is not a cosmetic glitch: an Intel build on Apple Silicon runs under translation with an x64 Chromium inside it, which is exactly the experience that makes someone decide the app is broken and ask for a refund before it has done anything.
One line of extra UI, labelled with the words people recognise (M1 / M2 / M3, Intel), beats a clever guess whose failure mode is a bad first launch. Detect when you can verify; ask when a wrong answer breaks the product.
The primary and secondary styling does the rest of the work: Apple Silicon is the filled button because it is the common case now, Intel is the outlined one below it. Nobody has to think about this for more than a second, and nobody gets the wrong file.
While you are on that page
Because the download page is where the handover happens, it is also where the app's structured data belongs:
{/* The page that hands over the installer is the one that should carry the
SoftwareApplication node: platform, price and downloadUrl in the markup
match what the page says in words. */}
export function softwareApplicationLd() {
return {
"@type": "SoftwareApplication",
"@id": `${APP_URL}/#software`,
name: "Notifio",
applicationCategory: "UtilitiesApplication",
applicationSubCategory: "Rental listing monitor",
operatingSystem: "macOS 12+, Windows 10+",
downloadUrl: `${APP_URL}/download`,
// ...offer with the real price
};
}
The @id is a stable string so that every page emitting this node merges into one entity instead of declaring a new application per URL, and there is no aggregateRating, because we do not collect ratings and inventing one is a manual action waiting to happen.
The page is also rendered per request, which sounds like an odd choice for a static download page. It is there so the licence price in the footer is shown in the visitor's currency, and getting that wrong for search engines is a whole other story: Googlebot is not in your customers' country, and your pricing page just told it so.
Go and click the thing
The two Mac builds and the Windows installer, with the size stated up front rather than as a surprise progress bar, are at notifio.app/download. What you get for the one-time licence is at notifio.app/pricing, and if you would rather see what the bundled browser actually does before committing to a download, the live demos are on notifio.app.
Top comments (0)