Files
buzz/web/src/shared/lib/buzz-download.ts
Tom Brow 4ab4f78608 fix(web): route mobile invite downloads to app stores (#7702)
Route the invite page’s existing download link to the App Store on
iOS/iPadOS and Google Play on Android. Appearance and app-opening
behavior stay unchanged.

The browser test checks platform routing using shared URL constants. Web
build, lint, and all six smoke tests pass. Rebased onto main to pick up
the relay test compilation fix.

Refs #3357. Alternative to #3464 with a smaller implementation and no
duplicated store URLs in tests.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-16 21:48:05 -07:00

218 lines
6.3 KiB
TypeScript

export const BUZZ_RELEASES_URL = "https://github.com/block/buzz/releases";
/** Official iOS download destination. */
export const BUZZ_IOS_APP_STORE_URL = "https://apps.apple.com/app/id6779728271";
/** Official Android download destination. */
export const BUZZ_ANDROID_PLAY_STORE_URL =
"https://play.google.com/store/apps/details?id=xyz.block.buzz.mobile";
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"
| "ios"
| "android"
| "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.
// Classify mobile devices before admitting desktop-looking signals.
const isIPadDesktopMode =
platform === "macintel" && navigatorValue.maxTouchPoints > 1;
if (isIPadDesktopMode || /iphone|ipad|ipod/.test(userAgent)) return "ios";
// Fire OS identifies as Android but does not ship with Google Play.
if (/kindle|silk/.test(userAgent)) return "unknown";
if (/android/.test(userAgent) || platform === "android") return "android";
const isUnsupportedDevice =
userAgentData?.mobile === true ||
/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,
);
if (operatingSystem === "ios" || operatingSystem === "android") {
return { operatingSystem, architecture: "unknown" };
}
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":
if (platform.architecture === "arm64") return /_aarch64\.dmg$/i;
if (platform.architecture === "x64") return /_x64\.dmg$/i;
return undefined;
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 resolveBuzzDownloadUrlForPlatform(
platform: BuzzDownloadPlatform,
): Promise<string> {
if (platform.operatingSystem === "ios") return BUZZ_IOS_APP_STORE_URL;
if (platform.operatingSystem === "android")
return BUZZ_ANDROID_PLAY_STORE_URL;
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;
}
}
export async function resolveBuzzDownloadUrl(): Promise<string> {
return resolveBuzzDownloadUrlForPlatform(
await detectBuzzDownloadPlatform(navigator),
);
}