From 2d5201957226edb8355a817f9cedf546a2a1df5a Mon Sep 17 00:00:00 2001 From: vedanth Date: Tue, 27 Oct 2026 08:23:40 +0000 Subject: [PATCH] fix(web): handle unknown Mac architecture hints --- .github/workflows/ci.yml | 1 + .../unit/download-architecture.test.ts | 116 ++++++++++++++++++ apps/web/hooks/useDetectPlatform.ts | 37 ++++-- 3 files changed, 144 insertions(+), 10 deletions(-) create mode 100644 apps/web/__tests__/unit/download-architecture.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70bb14815be..54505f64303 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,6 +141,7 @@ jobs: __tests__/unit/invite-decline.test.ts __tests__/unit/linkified-text.test.ts __tests__/unit/loom-import-ui.test.ts + __tests__/unit/download-architecture.test.ts __tests__/unit/media-comment-playback-cleanup.test.ts __tests__/unit/media-format-converter-selection.test.ts __tests__/unit/media-format-converter.test.ts diff --git a/apps/web/__tests__/unit/download-architecture.test.ts b/apps/web/__tests__/unit/download-architecture.test.ts new file mode 100644 index 00000000000..fb8bf515e23 --- /dev/null +++ b/apps/web/__tests__/unit/download-architecture.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment jsdom + +import { useDetectPlatform } from "hooks/useDetectPlatform"; +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getDownloadUrl } from "@/utils/platform"; + +vi.mock("next/image", () => ({ default: () => null })); +vi.mock("@/app/utils/analytics", () => ({ trackEvent: vi.fn() })); +let root: Root; +let container: HTMLDivElement; +let platform: ReturnType; +function Harness() { + platform = useDetectPlatform(); + return createElement( + "a", + { href: getDownloadUrl(platform.platform, platform.isIntel) }, + "Download", + ); +} +function navigatorInfo( + userAgent: string, + getHighEntropyValues?: () => Promise<{ architecture?: string }>, +) { + vi.stubGlobal("navigator", { + userAgent, + ...(getHighEntropyValues + ? { userAgentData: { getHighEntropyValues } } + : {}), + }); +} +function renderer(name: string | null) { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((() => + name === null + ? null + : { + getExtension: () => ({ UNMASKED_RENDERER_WEBGL: 37446 }), + getParameter: () => name, + }) as typeof HTMLCanvasElement.prototype.getContext); +} +const mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +async function render() { + await act(async () => { + root.render(createElement(Harness)); + await Promise.resolve(); + }); +} +function href() { + return container.querySelector("a")?.getAttribute("href"); +} +describe("Mac download architecture", () => { + it.each(["", "unknown", "mips"])( + "uses the Apple GPU fallback for an unrecognized %j hint", + async (architecture) => { + navigatorInfo(mac, async () => ({ architecture })); + renderer("Apple M2"); + await render(); + expect(href()).toBe("/download/apple-silicon"); + }, + ); + it("keeps the safe default when the hint is empty and WebGL is unavailable", async () => { + navigatorInfo(mac, async () => ({ architecture: "" })); + renderer(null); + await render(); + expect(href()).toBe("/download/apple-silicon"); + }); + it("uses WebGL to identify Intel when the hint is unknown", async () => { + navigatorInfo(mac, async () => ({ architecture: "" })); + renderer("Intel Iris"); + await render(); + expect(href()).toBe("/download/apple-intel"); + }); + it.each([ + ["x86", "/download/apple-intel"], + ["arm", "/download/apple-silicon"], + ["arm64", "/download/apple-silicon"], + ["aarch64", "/download/apple-silicon"], + ["amd64", "/download/apple-intel"], + ])("uses the known %s hint", async (architecture, expected) => { + navigatorInfo(mac, async () => ({ architecture })); + renderer(null); + await render(); + expect(href()).toBe(expected); + }); + it("falls back to the renderer when high entropy access rejects", async () => { + navigatorInfo(mac, async () => { + throw new Error("unavailable"); + }); + renderer("Intel Iris"); + await render(); + expect(href()).toBe("/download/apple-intel"); + }); + it.each([ + ["Windows", "/download/windows"], + ["Linux", "/download/linux-deb"], + ])("keeps %s downloads", async (agent, expected) => { + const hints = vi.fn(); + navigatorInfo(agent, hints); + await render(); + expect(href()).toBe(expected); + expect(hints).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/hooks/useDetectPlatform.ts b/apps/web/hooks/useDetectPlatform.ts index 6265903caab..3e94c83d2cf 100644 --- a/apps/web/hooks/useDetectPlatform.ts +++ b/apps/web/hooks/useDetectPlatform.ts @@ -56,18 +56,35 @@ export const useDetectPlatform = (): PlatformInfo => { return; } - if ( - "userAgentData" in navigator && - (navigator as any).userAgentData?.getHighEntropyValues - ) { + const userAgentData = ( + navigator as Navigator & { + userAgentData?: { + getHighEntropyValues?: ( + hints: string[], + ) => Promise<{ architecture?: string }>; + }; + } + ).userAgentData; + if (userAgentData?.getHighEntropyValues) { try { - const uaData = await ( - navigator as any - ).userAgentData.getHighEntropyValues(["architecture"]); + const uaData = await userAgentData.getHighEntropyValues([ + "architecture", + ]); if (!isMounted) return; - const arch = (uaData.architecture ?? "").toLowerCase(); - if (isMounted) setIsIntel(arch !== "arm" && arch !== "arm64"); - return; + const arch = (uaData.architecture ?? "").trim().toLowerCase(); + if (arch === "arm" || arch === "arm64" || arch === "aarch64") { + setIsIntel(false); + return; + } + if ( + arch === "x86" || + arch === "x86_64" || + arch === "x64" || + arch === "amd64" + ) { + setIsIntel(true); + return; + } } catch {} }