notifio.app/download is the page that hands over an installer. It is also the page where a wrong guess costs the most, because somebody who has already paid downloads a file, double clicks it, and gets an error from the operating system rather than an app. There is no recovery path from that except an email to support.
So the page guesses nothing. Three buttons, labelled, with the architecture written on them. Here is why it ended up that way, and what the route behind the buttons does.
Chrome on an M3 says it is an Intel Mac
The tempting version of this page detects your platform and shows one big button. It works fine for Windows. It is actively wrong for Mac.
Browsers on Apple Silicon report themselves as Intel for compatibility. A Mac running an M series chip sends a user agent containing Macintosh; Intel Mac OS X 10_15_7, and navigator.platform returns MacIntel. Those strings were frozen to stop sites breaking during the transition, and the result is that the two things a download page most needs to tell apart are the two things the browser has decided to report identically.
There are ways round it, and none of them is good enough to bet a paid download on:
-
navigator.userAgentData.getHighEntropyValues(["architecture"])does reportarmon Apple Silicon, but it is Chromium only, so it tells you nothing in Safari, and it is async, so the button has a state before the answer arrives. - The WebGL renderer string often contains the chip name. That is fingerprinting adjacent, it can be absent or spoofed, and the failure mode is silent.
Both of those can be right nine times out of ten. The tenth user gets a DMG their machine refuses to open, with no explanation and nothing on the page suggesting another option existed. So the page says this instead:
<a href={MAC_ARM64_URL}>
<span>Apple Silicon</span>
<span>M1 / M2 / M3 · .dmg</span>
</a>
<a href={MAC_X64_URL}>
<span>Intel Mac</span>
<span>x64 · .dmg</span>
</a>
Two visible options with the chip families spelled out. Apple Silicon is styled as the primary and Intel as the secondary, which is the only place a guess is allowed to show up: a hint about which is more likely, not a decision about which you get. A user who knows what is in their Mac can act in one click, and a user who does not has something to look up rather than something to be wrong about.
The reason there are two Mac builds rather than one universal binary is specific to this app: it ships a Chromium with it, so a universal build would carry two of them. That got its own post.
The route is an allowlist and a 60 second URL
The buttons do not point at the binaries. They point at our own route, which redirects:
const FILES: Record<string, string> = {
"mac-arm64": "notifio-arm64.dmg",
"mac-x64": "notifio-x64.dmg",
win: "notifio-setup.exe",
};
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ platform: string }> }
) {
const { platform } = await params;
const fileName = FILES[platform];
if (!fileName) {
return NextResponse.json(
{ error: `Unknown platform "${platform}". Use "mac-arm64", "mac-x64", or "win".` },
{ status: 400 }
);
}
...
That object is the whole security model for the endpoint, and it is the right shape for it. The dynamic segment never becomes part of an object key in storage. It is used to look up a value in a map of three entries, so /api/download/../../something-else is not a traversal attempt, it is a cache miss. Any scheme where the URL segment is concatenated into the storage key needs validation to be correct; this one cannot be wrong, because an unknown key has no value.
Then the file itself:
// Presigned URL valid for 60 seconds, enough for the browser to start the
// download without permanently exposing the file.
const url = await getSignedUrl(
client,
new GetObjectCommand({ Bucket: bucket, Key: fileName }),
{ expiresIn: 60 }
);
return NextResponse.redirect(url, { status: 302 });
The bucket is private. Nothing on the site ever contains a URL that works twice. Sixty seconds is chosen for what it has to cover, which is the gap between the redirect and the browser beginning the transfer, not the length of the download itself: the signature authorises the start of the request, and a transfer already underway is not interrupted when the signature expires.
The practical benefits of putting a route in front of a bucket are the dull ones, and they are the reason to do it. The release filenames can change without touching the page. The storage provider can change without touching the page. And a missing configuration fails in a way a user can read:
if (!accountId || !accessKeyId || !secretAccessKey) {
console.error("[download] Missing R2 environment variables");
return NextResponse.json(
{ error: "Download is temporarily unavailable." },
{ status: 503 }
);
}
A 503 saying "temporarily unavailable" is both true and useful. The alternative is an exception from the S3 client turning into a 500, which tells the user nothing and tells us the same thing one layer later.
The markup on the page that does the handing over
One more decision worth defending, which is about where structured data lives rather than how the download works. The SoftwareApplication node is emitted on this page and not on the home page:
{/* 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. */}
/**
* `@id` is stable so that every page emitting this node merges into one entity
* rather than declaring a new application per URL. No `aggregateRating`: we do
* not collect ratings, and inventing one is a manual action waiting to happen.
*/
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`,
...
};
}
Two things in there I would repeat on any site.
The @id is a fixed string rather than the current URL. Several pages can emit this node, and with a stable identifier they all describe one application instead of declaring a separate app per page. The same technique holds our publisher entity together across four different products, which is a post of its own.
There is no aggregateRating, and that is a deliberate omission rather than a gap. Rating markup is the single most rewarding field to invent and the single most penalised one to be caught inventing. We do not collect ratings, so the honest value is no field.
The operatingSystem string, "macOS 12+, Windows 10+", is the same claim the visible platform cards make. That is the rule the whole node follows: every field in it has to be a restatement of something a human can read on the page. The price comes from the same constant the visible price does, which also means the figure Googlebot sees and the figure you see can differ by currency, and that turned out to deserve its own explanation.
The general version
A download page is a tiny surface with an unusually sharp failure. The useful question is not "can we detect the platform" but "what happens to the user we get wrong". If the answer is a file that does not run and no visible alternative, then detection is not a convenience, it is a trap with a 90% pass rate, and two labelled buttons are better than one clever one.
The page is at notifio.app/download, what the app does once installed is on notifio.app/help, the licence is at notifio.app/pricing, and notifio.app/alerts lists the sites it watches.
Top comments (0)