mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00: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:
@@ -1,4 +1,8 @@
|
|||||||
import buzzAppIcon from "@/assets/app-icon@3x.png";
|
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 { relayWsUrl } from "@/shared/lib/relay-url";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -7,7 +11,6 @@ import remarkGfm from "remark-gfm";
|
|||||||
|
|
||||||
import { InviteJoinPolicyNotice } from "./InviteJoinPolicyNotice";
|
import { InviteJoinPolicyNotice } from "./InviteJoinPolicyNotice";
|
||||||
|
|
||||||
const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
|
|
||||||
type JoinPolicy = {
|
type JoinPolicy = {
|
||||||
terms_markdown?: string;
|
terms_markdown?: string;
|
||||||
privacy_markdown?: string;
|
privacy_markdown?: string;
|
||||||
@@ -28,6 +31,11 @@ export function InvitePage({ code }: { code: string }) {
|
|||||||
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
|
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
|
||||||
const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
|
const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
|
||||||
const [opening, setOpening] = React.useState(false);
|
const [opening, setOpening] = React.useState(false);
|
||||||
|
const [downloadUrl, setDownloadUrl] = React.useState(BUZZ_RELEASES_URL);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
resolveBuzzDownloadUrl().then(setDownloadUrl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetch("/api/join-policy")
|
fetch("/api/join-policy")
|
||||||
@@ -150,7 +158,7 @@ export function InvitePage({ code }: { code: string }) {
|
|||||||
Don't have the app?{" "}
|
Don't have the app?{" "}
|
||||||
<a
|
<a
|
||||||
className="ml-1 font-medium text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
|
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"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
|
|||||||
190
web/src/shared/lib/buzz-download.ts
Normal file
190
web/src/shared/lib/buzz-download.ts
Normal file
@@ -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 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 ageConfirmation = page.getByLabel("I am 18 years of age or older.");
|
||||||
const agreementConfirmation = page.getByLabel(
|
const agreementConfirmation = page.getByLabel(
|
||||||
"I agree to the Buzz Terms of Service and Privacy Policy.",
|
"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?.y).toBeLessThan(acceptButtonBox?.y ?? 0);
|
||||||
expect(consentBox?.width).toBe(acceptButtonBox?.width);
|
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