| 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 | |
| 9 | export const PLATFORM_IDS = ["macos", "windows", "linux", "android", "ios"] as const; |
| 10 | export type PlatformId = (typeof PLATFORM_IDS)[number]; |
| 11 | export type Arch = "x64" | "arm64"; |
| 12 | |
| 13 | /** The desktop app's platforms, as its releases name them. */ |
| 14 | export type DesktopPlatform = Extract<PlatformId, "macos" | "windows" | "linux">; |
| 15 | |
| 16 | export type Device = { platform: PlatformId | null; arch: Arch | null }; |
| 17 | |
| 18 | export function isPlatform(value: unknown): value is PlatformId { |
| 19 | return typeof value === "string" && (PLATFORM_IDS as readonly string[]).includes(value); |
| 20 | } |
| 21 | |
| 22 | export 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 | */ |
| 32 | export 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"])`). */ |
| 43 | export 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. */ |
| 50 | const PREFERRED: Record<DesktopPlatform, string[]> = { |
| 51 | macos: ["dmg"], |
| 52 | windows: ["installer"], |
| 53 | linux: ["appimage", "deb", "rpm"], |
| 54 | }; |
| 55 | |
| 56 | type 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 | */ |
| 64 | export 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 | */ |
| 81 | export 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 | } |