Skip to content
86 linesCodeBlameRaw
1/**
2 * Which platform a visitor to g1t.sh/download is on, and which of a
3 * release's files to offer them first (routes/download.tsx). The server
4 * reads the User-Agent, so the page is drawn for the visitor's platform
5 * from the first byte; the browser can then say which processor it has
6 * (`navigator.userAgentData`), which a User-Agent no longer does on a Mac.
7 */
8
9export const PLATFORM_IDS = ["macos", "windows", "linux", "android", "ios"] as const;
10export type PlatformId = (typeof PLATFORM_IDS)[number];
11export type Arch = "x64" | "arm64";
12
13/** The desktop app's platforms, as its releases name them. */
14export type DesktopPlatform = Extract<PlatformId, "macos" | "windows" | "linux">;
15
16export type Device = { platform: PlatformId | null; arch: Arch | null };
17
18export function isPlatform(value: unknown): value is PlatformId {
19 return typeof value === "string" && (PLATFORM_IDS as readonly string[]).includes(value);
20}
21
22export function isDesktop(platform: PlatformId): platform is DesktopPlatform {
23 return platform === "macos" || platform === "windows" || platform === "linux";
24}
25
26/**
27 * The platform and processor a User-Agent names, each null when it does not
28 * say. A phone is checked first: Android's says Linux, an iPhone's says
29 * "like Mac OS X". An iPad asking for the desktop site says Macintosh, which
30 * only the browser can tell apart (by its touch screen).
31 */
32export function deviceOf(userAgent: string | null | undefined): Device {
33 const ua = userAgent ?? "";
34 if (/iPhone|iPad|iPod/i.test(ua)) return { platform: "ios", arch: "arm64" };
35 if (/Android/i.test(ua)) return { platform: "android", arch: null };
36 if (/Windows/i.test(ua)) return { platform: "windows", arch: /ARM64|aarch64/i.test(ua) ? "arm64" : /Win64|x64|WOW64|amd64/i.test(ua) ? "x64" : null };
37 if (/Macintosh|Mac OS X/i.test(ua)) return { platform: "macos", arch: null };
38 if (/CrOS|Linux|X11/i.test(ua)) return { platform: "linux", arch: /aarch64|arm64/i.test(ua) ? "arm64" : /x86_64|amd64/i.test(ua) ? "x64" : null };
39 return { platform: null, arch: null };
40}
41
42/** The processor a browser's client hints name (`getHighEntropyValues(["architecture"])`). */
43export function archOfHint(architecture: string | undefined): Arch | null {
44 if (architecture === "arm") return "arm64";
45 if (architecture === "x86") return "x64";
46 return null;
47}
48
49/** The kinds of file a platform's download is, most wanted first. */
50const PREFERRED: Record<DesktopPlatform, string[]> = {
51 macos: ["dmg"],
52 windows: ["installer"],
53 linux: ["appimage", "deb", "rpm"],
54};
55
56type File = { platform: string; arch: string; kind: string };
57
58/**
59 * The file to offer first for a platform: its preferred kind, on the
60 * visitor's processor when known; on a Mac whose processor is not known,
61 * Apple silicon's, which every Mac sold since 2023 has. Null when the
62 * release has nothing for the platform.
63 */
64export function featuredFile<T extends File>(downloads: T[], platform: DesktopPlatform, arch: Arch | null): T | null {
65 const files = downloads.filter((d) => d.platform === platform);
66 if (files.length === 0) return null;
67 const want = arch ?? (platform === "macos" ? "arm64" : "x64");
68 const rank = (d: T) => {
69 const kind = PREFERRED[platform].indexOf(d.kind);
70 return (kind === -1 ? 10 : kind) + (d.arch === want ? 0 : 100);
71 };
72 return [...files].sort((a, b) => rank(a) - rank(b))[0];
73}
74
75/**
76 * The other ways to have the same app, shown beside the main button: on a
77 * Mac, the other processor's disk image (the Intel one, for one whose
78 * processor the page could not tell); on Linux, the other packages for the
79 * same processor; on Windows, the other processor's installer.
80 */
81export function alternateFiles<T extends File>(downloads: T[], featured: T | null): T[] {
82 if (!featured) return [];
83 const others = downloads.filter((d) => d.platform === featured.platform && d !== featured);
84 if (featured.platform === "linux") return others.filter((d) => d.arch === featured.arch).slice(0, 2);
85 return others.filter((d) => d.kind === featured.kind).slice(0, 1);
86}