Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import { Trans } from "@lingui/react/macro";
import { formatNumber } from "@superset/i18n/format";
import { DOWNLOAD_URL_MAC_X64 } from "@superset/shared/constants";
import { useEffect, useRef } from "react";
import { WaitlistForm } from "@/app/[lang]/components/WaitlistForm";
import { Platform, usePlatform } from "@/app/[lang]/hooks/useOS";
import { ArchSource, Platform, usePlatform } from "@/app/[lang]/hooks/useOS";
import { track } from "@/lib/analytics";
import { desktopUrlFor, shouldAutoDownload } from "../../utils/desktopUrlFor";
import { formatReleaseDate } from "../../utils/formatReleaseDate";
Expand Down Expand Up @@ -46,9 +47,14 @@ interface DownloadInterstitialProps {
export function DownloadInterstitial({
latestRelease,
}: DownloadInterstitialProps) {
const { platform } = usePlatform();
const { platform, archSource } = usePlatform();
const firedRef = useRef(false);

// The browser told us nothing about the chip, so Apple Silicon was assumed
// rather than detected. Say that instead of labelling the guess as a
// detection, and give Intel Macs a one-click way out.
const archUnconfirmed = archSource === ArchSource.Default;

// A phone can't run the app, so mobile visitors get a link to open on their
// desktop. Windows has no published build and falls through to the waitlist.
const showEmailLink = platform === Platform.Mobile;
Expand All @@ -60,7 +66,7 @@ export function DownloadInterstitial({
if (!canAutoDownload) return;

const url = desktopUrlFor(platform);
track("download_started", { platform });
track("download_started", { platform, archSource });

// Latched in the callback, not here: if `platform` resolved again before
// the timer fired, latching early would strand the pending redirect on
Expand All @@ -70,7 +76,7 @@ export function DownloadInterstitial({
window.location.href = url;
}, AUTO_DOWNLOAD_DELAY_MS);
return () => window.clearTimeout(timer);
}, [canAutoDownload, platform]);
}, [canAutoDownload, platform, archSource]);

const assetKey = PLATFORM_ASSET_KEY[platform];
const asset = assetKey
Expand All @@ -84,7 +90,11 @@ export function DownloadInterstitial({
<div>
<div className="mb-6 inline-flex w-max items-center gap-2 whitespace-nowrap rounded-[2px] border border-border bg-background/80 px-3 py-1.5 font-mono text-muted-foreground text-xs">
<span className="shrink-0 text-brand">●</span>
<span>{PLATFORM_LABELS[platform]}</span>
<span>
{archUnconfirmed
? PLATFORM_LABELS[Platform.Unknown]
: PLATFORM_LABELS[platform]}
</span>
</div>

{showEmailLink ? (
Expand Down Expand Up @@ -130,6 +140,24 @@ export function DownloadInterstitial({
<div className="mt-6">
<DesktopDownloadButton />
</div>
{archUnconfirmed ? (
<p className="mt-4 text-muted-foreground text-sm">
<Trans>
Your browser doesn't report which chip you have, so this is
the Apple Silicon build. On an Intel Mac,{" "}
<a
href={DOWNLOAD_URL_MAC_X64}
onClick={() =>
track("download_arch_switched", { platform, archSource })
}
className="text-foreground underline underline-offset-4"
>
get the Intel build
</a>{" "}
instead.
</Trans>
</p>
) : null}
</div>
)}
</div>
Expand Down
3 changes: 3 additions & 0 deletions apps/marketing/src/app/[lang]/hooks/useOS/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export {
ArchSource,
detectPlatform,
isMacPlatform,
normalizeArch,
Platform,
type PlatformInfo,
usePlatform,
Expand Down
156 changes: 156 additions & 0 deletions apps/marketing/src/app/[lang]/hooks/useOS/useOS.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
ArchSource,
detectPlatform,
isMacPlatform,
normalizeArch,
Platform,
} from "./useOS";

const MAC_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36";

interface StubOptions {
userAgent?: string;
/** Omit to stub a browser with no userAgentData (Safari, Firefox). */
architecture?: string;
hasUserAgentData?: boolean;
rejects?: boolean;
}

function stubNavigator({
userAgent = MAC_UA,
architecture,
hasUserAgentData = true,
rejects = false,
}: StubOptions) {
const nav: Record<string, unknown> = { userAgent };
if (hasUserAgentData) {
nav.userAgentData = {
getHighEntropyValues: async () => {
if (rejects) throw new Error("blocked by the browser");
return { architecture };
},
};
}
globalThis.navigator = nav as unknown as Navigator;
}

const resolve = (options: StubOptions) => {
stubNavigator(options);
return detectPlatform();
};

const REAL_NAVIGATOR = globalThis.navigator;

afterEach(() => {
globalThis.navigator = REAL_NAVIGATOR;
});

describe("normalizeArch", () => {
test.each([
["arm", "arm"],
["x86", "x86"],
["ARM", "arm"],
[" x86 ", "x86"],
["arm64", "arm"],
["x86_64", "x86"],
["amd64", "x86"],
["", null],
[null, null],
[undefined, null],
["unknown", null],
["wasm", null],
])("normalises %p to %p", (value, expected) => {
expect(normalizeArch(value as string | null)).toBe(
expected as "arm" | "x86" | null,
);
});
});

describe("Mac architecture detection", () => {
test("uses the client hint when the browser exposes one", async () => {
expect(await resolve({ architecture: "arm" })).toEqual({
platform: Platform.MacAppleSilicon,
archSource: ArchSource.ClientHint,
});
expect(await resolve({ architecture: "x86" })).toEqual({
platform: Platform.MacIntel,
archSource: ArchSource.ClientHint,
});
});

// A real Intel Mac must never fall through to the Apple Silicon default
// just because its engine spelled the architecture differently.
test.each([
["x86_64", Platform.MacIntel],
["amd64", Platform.MacIntel],
["arm64", Platform.MacAppleSilicon],
])("normalises a %p client hint", async (architecture, expected) => {
const result = await resolve({ architecture });
expect(result.platform).toBe(expected);
expect(result.archSource).toBe(ArchSource.ClientHint);
});

// The regression from issue #7036: an Apple Silicon Mac was handed the x64
// build because the browser exposed no usable architecture and the old
// WebGL heuristic read "not an Apple GPU" as proof of Intel. Nothing may
// resolve to Intel unless a browser actually said so.
test.each([
[
"the browser has no userAgentData (Safari, Firefox)",
{ hasUserAgentData: false },
],
[
"userAgentData is present but architecture is empty",
{ architecture: "" },
],
["getHighEntropyValues rejects", { rejects: true }],
["architecture is an unrecognised value", { architecture: "wasm" }],
])("defaults to Apple Silicon when %s", async (_label, options) => {
expect(await resolve(options)).toEqual({
platform: Platform.MacAppleSilicon,
archSource: ArchSource.Default,
});
});
});

describe("operating system detection", () => {
test.each([
[
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
Platform.Windows,
],
["Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", Platform.Linux],
[
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15",
Platform.Mobile,
],
["something else entirely", Platform.Unknown],
])("classifies %p", async (userAgent, expected) => {
const result = await resolve({ userAgent, hasUserAgentData: false });
expect(result.platform).toBe(expected);
expect(result.archSource).toBe(ArchSource.NotMac);
});

test("an iPad reporting Mac OS X is treated as mobile, not a Mac", async () => {
const result = await resolve({
userAgent:
"Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15",
});
expect(result.platform).toBe(Platform.Mobile);
});
});

describe("isMacPlatform", () => {
test.each([
[Platform.MacAppleSilicon, true],
[Platform.MacIntel, true],
[Platform.Windows, false],
[Platform.Linux, false],
[Platform.Mobile, false],
[Platform.Unknown, false],
])("%p is a Mac: %p", (platform, expected) => {
expect(isMacPlatform(platform)).toBe(expected);
});
});
Loading
Loading