mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix: resolve invite downloads by platform (#2001)
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent
084e442d2d
commit
57316a378e
@@ -1,4 +1,8 @@
|
||||
import buzzAppIcon from "@/assets/app-icon@3x.png";
|
||||
import {
|
||||
BUZZ_RELEASES_URL,
|
||||
resolveBuzzDownloadUrl,
|
||||
} from "@/shared/lib/buzz-download";
|
||||
import { relayWsUrl } from "@/shared/lib/relay-url";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import * as React from "react";
|
||||
@@ -7,7 +11,6 @@ import remarkGfm from "remark-gfm";
|
||||
|
||||
import { InviteJoinPolicyNotice } from "./InviteJoinPolicyNotice";
|
||||
|
||||
const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
|
||||
type JoinPolicy = {
|
||||
terms_markdown?: string;
|
||||
privacy_markdown?: string;
|
||||
@@ -28,6 +31,11 @@ export function InvitePage({ code }: { code: string }) {
|
||||
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
|
||||
const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
|
||||
const [opening, setOpening] = React.useState(false);
|
||||
const [downloadUrl, setDownloadUrl] = React.useState(BUZZ_RELEASES_URL);
|
||||
|
||||
React.useEffect(() => {
|
||||
resolveBuzzDownloadUrl().then(setDownloadUrl);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch("/api/join-policy")
|
||||
@@ -150,7 +158,7 @@ export function InvitePage({ code }: { code: string }) {
|
||||
Don't have the app?{" "}
|
||||
<a
|
||||
className="ml-1 font-medium text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
|
||||
href={DOWNLOAD_URL}
|
||||
href={downloadUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
export const BUZZ_RELEASES_URL = "https://github.com/block/buzz/releases";
|
||||
const BUZZ_RELEASES_API_URL =
|
||||
"https://api.github.com/repos/block/buzz/releases?per_page=10";
|
||||
const CACHE_KEY = "buzz.latestDownload.v1";
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
export type BuzzDownloadPlatform = {
|
||||
operatingSystem: "linux" | "macos" | "windows" | "unknown";
|
||||
architecture: "arm64" | "x64" | "unknown";
|
||||
};
|
||||
|
||||
type GitHubRelease = {
|
||||
draft: boolean;
|
||||
prerelease: boolean;
|
||||
assets: Array<{ name: string; browser_download_url: string }>;
|
||||
};
|
||||
|
||||
type UserAgentData = {
|
||||
platform?: string;
|
||||
mobile?: boolean;
|
||||
getHighEntropyValues?: (
|
||||
hints: string[],
|
||||
) => Promise<{ architecture?: string; bitness?: string }>;
|
||||
};
|
||||
|
||||
function normalizeOperatingSystem(
|
||||
navigatorValue: Navigator,
|
||||
userAgentData?: UserAgentData,
|
||||
): BuzzDownloadPlatform["operatingSystem"] {
|
||||
const userAgent = navigatorValue.userAgent.toLowerCase();
|
||||
const platform = (
|
||||
userAgentData?.platform ??
|
||||
navigatorValue.platform ??
|
||||
""
|
||||
).toLowerCase();
|
||||
|
||||
// Compatibility tokens are treacherous: iPadOS can report MacIntel and a
|
||||
// Macintosh UA, while Android and ChromeOS expose Linux platform strings.
|
||||
// Reject non-desktop devices before admitting desktop-looking signals.
|
||||
const isIPadDesktopMode =
|
||||
platform === "macintel" && navigatorValue.maxTouchPoints > 1;
|
||||
const isUnsupportedDevice =
|
||||
userAgentData?.mobile === true ||
|
||||
isIPadDesktopMode ||
|
||||
/android|iphone|ipad|ipod|mobile|tablet|windows phone|iemobile|opera mini|opera mobi|webos|blackberry|bb10|kindle|silk|kaios|cros/.test(
|
||||
userAgent,
|
||||
);
|
||||
if (isUnsupportedDevice) return "unknown";
|
||||
|
||||
if (
|
||||
platform === "macos" ||
|
||||
platform.startsWith("mac") ||
|
||||
userAgent.includes("macintosh")
|
||||
)
|
||||
return "macos";
|
||||
if (
|
||||
platform === "windows" ||
|
||||
platform.startsWith("win") ||
|
||||
userAgent.includes("windows nt")
|
||||
)
|
||||
return "windows";
|
||||
if (
|
||||
platform === "linux" ||
|
||||
platform.startsWith("linux") ||
|
||||
userAgent.includes("linux")
|
||||
)
|
||||
return "linux";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function normalizeArchitecture(
|
||||
value: string,
|
||||
): BuzzDownloadPlatform["architecture"] {
|
||||
const normalized = value.toLowerCase();
|
||||
if (/arm|aarch64/.test(normalized)) return "arm64";
|
||||
if (/x86|x64|amd64|64/.test(normalized)) return "x64";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export async function detectBuzzDownloadPlatform(
|
||||
navigatorValue: Navigator,
|
||||
): Promise<BuzzDownloadPlatform> {
|
||||
const userAgentData = (
|
||||
navigatorValue as Navigator & { userAgentData?: UserAgentData }
|
||||
).userAgentData;
|
||||
const operatingSystem = normalizeOperatingSystem(
|
||||
navigatorValue,
|
||||
userAgentData,
|
||||
);
|
||||
let architecture = normalizeArchitecture(navigatorValue.userAgent);
|
||||
|
||||
if (userAgentData?.getHighEntropyValues) {
|
||||
try {
|
||||
const values = await userAgentData.getHighEntropyValues([
|
||||
"architecture",
|
||||
"bitness",
|
||||
]);
|
||||
architecture = normalizeArchitecture(
|
||||
`${values.architecture ?? ""} ${values.bitness ?? ""}`,
|
||||
);
|
||||
} catch {
|
||||
// Privacy settings may reject high-entropy client hints. The matcher
|
||||
// below applies the safest compatible fallback for the detected OS.
|
||||
}
|
||||
}
|
||||
|
||||
return { operatingSystem, architecture };
|
||||
}
|
||||
|
||||
function assetPattern(platform: BuzzDownloadPlatform): RegExp | undefined {
|
||||
switch (platform.operatingSystem) {
|
||||
case "macos":
|
||||
// Safari withholds CPU architecture and reports MacIntel on Apple
|
||||
// Silicon. The Intel build remains compatible there through Rosetta.
|
||||
return platform.architecture === "arm64"
|
||||
? /_aarch64\.dmg$/i
|
||||
: /_x64\.dmg$/i;
|
||||
case "windows":
|
||||
return /_x64-setup[^/]*\.exe$/i;
|
||||
case "linux":
|
||||
return platform.architecture === "arm64"
|
||||
? undefined
|
||||
: /_amd64\.AppImage$/i;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectBuzzDownloadUrl(
|
||||
releases: GitHubRelease[],
|
||||
platform: BuzzDownloadPlatform,
|
||||
): string | undefined {
|
||||
const pattern = assetPattern(platform);
|
||||
if (!pattern) return undefined;
|
||||
|
||||
for (const release of releases) {
|
||||
if (release.draft || release.prerelease) continue;
|
||||
const asset = release.assets.find(({ name }) => pattern.test(name));
|
||||
if (asset) return asset.browser_download_url;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolveBuzzDownloadUrl(): Promise<string> {
|
||||
const platform = await detectBuzzDownloadPlatform(navigator);
|
||||
try {
|
||||
const cached = JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? "null") as {
|
||||
expiresAt: number;
|
||||
platform: BuzzDownloadPlatform;
|
||||
url: string;
|
||||
} | null;
|
||||
if (
|
||||
cached &&
|
||||
cached.expiresAt > Date.now() &&
|
||||
cached.platform.operatingSystem === platform.operatingSystem &&
|
||||
cached.platform.architecture === platform.architecture
|
||||
) {
|
||||
return cached.url;
|
||||
}
|
||||
} catch {
|
||||
// Storage is only an optimization.
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(BUZZ_RELEASES_API_URL, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
});
|
||||
if (!response.ok) return BUZZ_RELEASES_URL;
|
||||
const url = selectBuzzDownloadUrl(
|
||||
(await response.json()) as GitHubRelease[],
|
||||
platform,
|
||||
);
|
||||
if (!url) return BUZZ_RELEASES_URL;
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
platform,
|
||||
url,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Storage is only an optimization.
|
||||
}
|
||||
return url;
|
||||
} catch {
|
||||
return BUZZ_RELEASES_URL;
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,51 @@ test("invite requires age and legal consent before opening Buzz", async ({
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("https://api.github.com/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
body: JSON.stringify([
|
||||
{ draft: false, prerelease: false, assets: [] },
|
||||
{
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: "Buzz_0.4.9_aarch64.dmg",
|
||||
browser_download_url:
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_aarch64.dmg",
|
||||
},
|
||||
{
|
||||
name: "Buzz_0.4.9_x64.dmg",
|
||||
browser_download_url:
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_x64.dmg",
|
||||
},
|
||||
{
|
||||
name: "Buzz_0.4.9_amd64.AppImage",
|
||||
browser_download_url:
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_amd64.AppImage",
|
||||
},
|
||||
{
|
||||
name: "Buzz_0.4.9_x64-setup_alpha-unsigned.exe",
|
||||
browser_download_url:
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_x64-setup_alpha-unsigned.exe",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
await page.goto("/invite/demo-code");
|
||||
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Download it now" }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_x64-setup_alpha-unsigned.exe",
|
||||
);
|
||||
|
||||
const ageConfirmation = page.getByLabel("I am 18 years of age or older.");
|
||||
const agreementConfirmation = page.getByLabel(
|
||||
"I agree to the Buzz Terms of Service and Privacy Policy.",
|
||||
@@ -73,3 +116,91 @@ test("invite requires age and legal consent before opening Buzz", async ({
|
||||
expect(consentBox?.y).toBeLessThan(acceptButtonBox?.y ?? 0);
|
||||
expect(consentBox?.width).toBe(acceptButtonBox?.width);
|
||||
});
|
||||
|
||||
test("invite download falls back for mobile and non-desktop devices", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const unsupportedDevices = [
|
||||
{
|
||||
name: "iPhone Safari",
|
||||
platform: "iPhone",
|
||||
userAgent:
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15",
|
||||
maxTouchPoints: 5,
|
||||
},
|
||||
{
|
||||
name: "iPadOS desktop mode",
|
||||
platform: "MacIntel",
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15",
|
||||
maxTouchPoints: 5,
|
||||
},
|
||||
{
|
||||
name: "Android phone",
|
||||
platform: "Linux armv8l",
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro) AppleWebKit/537.36 Mobile",
|
||||
maxTouchPoints: 5,
|
||||
},
|
||||
{
|
||||
name: "ChromeOS",
|
||||
platform: "Linux x86_64",
|
||||
userAgent: "Mozilla/5.0 (X11; CrOS x86_64 16093.68.0) AppleWebKit/537.36",
|
||||
maxTouchPoints: 0,
|
||||
},
|
||||
];
|
||||
|
||||
for (const device of unsupportedDevices) {
|
||||
const context = await browser.newContext({ userAgent: device.userAgent });
|
||||
await context.addInitScript(({ platform, maxTouchPoints }) => {
|
||||
Object.defineProperties(navigator, {
|
||||
platform: { configurable: true, value: platform },
|
||||
maxTouchPoints: { configurable: true, value: maxTouchPoints },
|
||||
userAgentData: {
|
||||
configurable: true,
|
||||
value: { platform, mobile: maxTouchPoints > 0 },
|
||||
},
|
||||
});
|
||||
}, device);
|
||||
const page = await context.newPage();
|
||||
await page.route("**/api/join-policy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ policy: null }),
|
||||
});
|
||||
});
|
||||
await page.route("https://api.github.com/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
body: JSON.stringify([
|
||||
{
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: "Buzz_0.4.9_x64.dmg",
|
||||
browser_download_url:
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_x64.dmg",
|
||||
},
|
||||
{
|
||||
name: "Buzz_0.4.9_amd64.AppImage",
|
||||
browser_download_url:
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_amd64.AppImage",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/invite/demo-code");
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Download it now" }),
|
||||
device.name,
|
||||
).toHaveAttribute("href", "https://github.com/block/buzz/releases");
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user