diff --git a/web/app/download/DownloadButton.tsx b/web/app/download/DownloadButton.tsx index 921eff57..a017b420 100644 --- a/web/app/download/DownloadButton.tsx +++ b/web/app/download/DownloadButton.tsx @@ -22,23 +22,38 @@ const EVERY_PLATFORM: readonly DesktopPlatform[] = [ { os: 'windows', arch: 'x64' }, ]; -/** Leads with the build for the visitor's OS and architecture. */ -export function DownloadButton() { - const [platform, setPlatform] = useState(null); +/** + * Leads with the build for the visitor's OS and architecture. `initialPlatform` + * is the server's read of this request, so that build is in the first HTML + * response. The effect replaces it only once a client-only signal arrives: + * touch points (iPad) or Chromium's high-entropy architecture. It does not + * apply a user-agent-only guess first, which would overwrite a server + * architecture hint with the x64 or Apple-silicon default. + */ +export function DownloadButton({ initialPlatform = null }: { initialPlatform?: DesktopPlatform | null }) { + const [platform, setPlatform] = useState(initialPlatform); useEffect(() => { let cancelled = false; const nav = navigator as UADataNavigator; - const detect = (architecture?: string) => { - if (!cancelled) { - setPlatform(detectDesktopPlatform(nav.userAgent, { architecture, touchPoints: nav.maxTouchPoints })); - } + const apply = (architecture?: string) => { + if (cancelled) return; + const next = detectDesktopPlatform(nav.userAgent, { architecture, touchPoints: nav.maxTouchPoints }); + setPlatform((current) => { + // Keep the server's architecture when the client learned nothing new + // and still agrees on the OS. An empty hint must not flip Windows on + // Arm back to the x64 default. + if (!architecture && current && next && next.os === current.os) return current; + return next; + }); }; - detect(); - nav.userAgentData - ?.getHighEntropyValues(['architecture']) - .then(({ architecture }) => detect(architecture || undefined)) - .catch(() => {}); + + const pending = nav.userAgentData?.getHighEntropyValues(['architecture']); + if (pending) { + pending.then(({ architecture }) => apply(architecture || undefined)).catch(() => apply()); + } else { + apply(); + } return () => { cancelled = true; }; diff --git a/web/app/download/page.tsx b/web/app/download/page.tsx index b0e71604..b3443ec7 100644 --- a/web/app/download/page.tsx +++ b/web/app/download/page.tsx @@ -1,10 +1,17 @@ import type { Metadata } from 'next'; +import { headers } from 'next/headers'; import { WaveDivider } from '../../components/home/icons'; import sections from '../../components/home/home-sections.module.css'; import { SiteFooter } from '../../components/SiteFooter'; import { SiteNav } from '../../components/SiteNav'; -import { DESKTOP_ASSETS, type DesktopAsset, type DesktopOs, desktopAssetUrl } from '../../lib/desktop-downloads'; +import { + DESKTOP_ASSETS, + type DesktopAsset, + type DesktopOs, + desktopAssetUrl, + detectDesktopPlatform, +} from '../../lib/desktop-downloads'; import { absoluteUrl } from '../../lib/site'; import { teamsGoogleAuthHref } from '../../lib/teams-cloud'; import s from '../landing.module.css'; @@ -38,7 +45,7 @@ const PLATFORMS: readonly { os: DesktopOs; name: string; requirements: string; v os: 'windows', name: 'Windows', requirements: - 'Installs for your account without admin rights. The build is unsigned, so SmartScreen may ask you to confirm: choose More info › Run anyway.', + 'Installs for your account without admin rights. The build is unsigned, so SmartScreen may ask you to confirm: choose More info › Run anyway. If Smart App Control blocks the installer, review the release and Microsoft guidance before deciding whether to turn it off; turning it off reduces protection against untrusted apps.', verify: 'Get-FileHash ', }, ]; @@ -48,7 +55,17 @@ function archName(asset: DesktopAsset): string { return asset.arch === 'arm64' ? 'ARM64' : 'x64'; } -export default function DownloadPage() { +// Chosen from this request, so a Windows visitor's installer is in the first +// response instead of appearing after hydration. +export const dynamic = 'force-dynamic'; + +export default async function DownloadPage() { + const requestHeaders = await headers(); + const detected = detectDesktopPlatform(requestHeaders.get('user-agent') ?? '', { + architecture: requestHeaders.get('sec-ch-ua-arch'), + platform: requestHeaders.get('sec-ch-ua-platform'), + }); + return (
@@ -74,7 +91,7 @@ export default function DownloadPage() {

Download Agent Relay

The desktop app connects the coding agents on your machine to your team’s relay.

- +

On macOS or Linux, your coding agent can install it for you. Get the setup prompt

diff --git a/web/components/SiteNav.tsx b/web/components/SiteNav.tsx index 14361cc3..5f5fe7dc 100644 --- a/web/components/SiteNav.tsx +++ b/web/components/SiteNav.tsx @@ -100,6 +100,11 @@ export function SiteNav({ Log in +
  • + + Download + +
  • Docs @@ -171,6 +176,11 @@ export function SiteNav({ Log in )} + {!hideLinks && ( + setMenuOpen(false)}> + Download + + )} {!hideLinks && !hideMobileDocsLink && ( setMenuOpen(false)}> Docs diff --git a/web/lib/desktop-downloads.ts b/web/lib/desktop-downloads.ts index 32bba5e3..ecc2a6e0 100644 --- a/web/lib/desktop-downloads.ts +++ b/web/lib/desktop-downloads.ts @@ -32,23 +32,58 @@ export function desktopAssetUrl(file: string): string { export type DesktopPlatform = { os: DesktopOs; arch: DesktopArch }; +/** + * Headers `/download` sends so Chromium includes architecture on the navigation + * itself. `Sec-CH-UA-Arch` is high-entropy and stays absent until the server + * asks; `Critical-CH` makes the browser retry that first request with the hint + * instead of showing a defaulted build. Kept in sync with `web/next.config.mjs`. + */ +export const DOWNLOAD_CLIENT_HINT_HEADERS = { + 'Accept-CH': 'Sec-CH-UA-Arch, Sec-CH-UA-Platform', + 'Critical-CH': 'Sec-CH-UA-Arch, Sec-CH-UA-Platform', +} as const; + +/** Structured client-hint values arrive quoted (`"arm"`, `"Windows"`). */ +function hintValue(value: string | null | undefined): string { + return (value ?? '').replaceAll('"', '').trim().toLowerCase(); +} + /** * The visitor's platform from what the browser reports. `architecture` is the - * User-Agent Client Hints value (Chromium only). Safari and Firefox report every - * Mac as Intel, so a Mac without that hint is taken to be Apple silicon, which - * every Mac sold since 2023 is; the Intel build stays one click away. iPadOS - * Safari also reports a Mac, and only its touch points tell it apart. + * User-Agent Client Hints value (Chromium only), and `platform` is + * `Sec-CH-UA-Platform` for a reduced user agent that no longer names the OS. + * Safari and Firefox report every Mac as Intel, so a Mac without an + * architecture hint is taken to be Apple silicon, which every Mac sold since + * 2023 is; the Intel build stays one click away. iPadOS Safari also reports a + * Mac, and only its touch points tell it apart. */ -export function detectDesktopPlatform(userAgent: string, { architecture, touchPoints = 0 }: { architecture?: string; touchPoints?: number } = {}): DesktopPlatform | null { +export function detectDesktopPlatform( + userAgent: string, + { + architecture, + platform, + touchPoints = 0, + }: { architecture?: string | null; platform?: string | null; touchPoints?: number } = {}, +): DesktopPlatform | null { const ua = userAgent.toLowerCase(); - const arm = architecture ? architecture.toLowerCase().startsWith('arm') : /\b(aarch64|arm64|armv8)\b/.test(ua); - if (/windows/.test(ua)) return { os: 'windows', arch: arm ? 'arm64' : 'x64' }; - if (/android|iphone|ipad|cros/.test(ua)) return null; - if (/macintosh|mac os x/.test(ua)) { + const platformHint = hintValue(platform); + const architectureHint = hintValue(architecture); + const arm = architectureHint ? architectureHint.startsWith('arm') : /\b(aarch64|arm64|armv8)\b/.test(ua); + const windows = platformHint === 'windows' || /windows/.test(ua); + if (windows) return { os: 'windows', arch: arm ? 'arm64' : 'x64' }; + if ( + platformHint === 'android' || + platformHint === 'ios' || + platformHint === 'chrome os' || + /android|iphone|ipad|cros/.test(ua) + ) { + return null; + } + if (platformHint === 'macos' || /macintosh|mac os x/.test(ua)) { if (touchPoints > 1) return null; - return { os: 'macos', arch: architecture && !arm ? 'x64' : 'arm64' }; + return { os: 'macos', arch: architectureHint && !arm ? 'x64' : 'arm64' }; } - if (/linux|x11/.test(ua)) return { os: 'linux', arch: arm ? 'arm64' : 'x64' }; + if (platformHint === 'linux' || /linux|x11/.test(ua)) return { os: 'linux', arch: arm ? 'arm64' : 'x64' }; return null; } diff --git a/web/lib/test/desktop-downloads.test.ts b/web/lib/test/desktop-downloads.test.ts index 5c7ffb27..14a78d90 100644 --- a/web/lib/test/desktop-downloads.test.ts +++ b/web/lib/test/desktop-downloads.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { alternateDesktopAsset, DESKTOP_ASSETS, desktopAssetUrl, detectDesktopPlatform, primaryDesktopAsset } from '../desktop-downloads'; +import { + DOWNLOAD_CLIENT_HINT_HEADERS, + alternateDesktopAsset, + DESKTOP_ASSETS, + desktopAssetUrl, + detectDesktopPlatform, + primaryDesktopAsset, +} from '../desktop-downloads'; const MAC = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15'; const LINUX_X64 = 'Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0'; @@ -22,6 +29,20 @@ describe('detectDesktopPlatform', () => { it('takes Windows to be x64 unless Client Hints report Arm', () => { expect(detectDesktopPlatform(WINDOWS)).toEqual({ os: 'windows', arch: 'x64' }); expect(detectDesktopPlatform(WINDOWS, { architecture: 'arm' })).toEqual({ os: 'windows', arch: 'arm64' }); + expect(detectDesktopPlatform(WINDOWS, { architecture: '"arm"' })).toEqual({ os: 'windows', arch: 'arm64' }); + expect(detectDesktopPlatform(MAC, { architecture: '"x86"' })).toEqual({ os: 'macos', arch: 'x64' }); + }); + + it('reads a reduced user agent from the platform client hint', () => { + expect(detectDesktopPlatform('Mozilla/5.0', { platform: '"Windows"' })).toEqual({ os: 'windows', arch: 'x64' }); + expect(detectDesktopPlatform('Mozilla/5.0', { platform: '"Windows"', architecture: '"arm"' })).toEqual({ + os: 'windows', + arch: 'arm64', + }); + expect(detectDesktopPlatform('Mozilla/5.0', { platform: '"macOS"' })).toEqual({ os: 'macos', arch: 'arm64' }); + expect(detectDesktopPlatform('Mozilla/5.0', { platform: '"Linux"' })).toEqual({ os: 'linux', arch: 'x64' }); + expect(detectDesktopPlatform('Mozilla/5.0', { platform: '"Android"' })).toBeNull(); + expect(detectDesktopPlatform('Mozilla/5.0', { platform: '"Chrome OS"' })).toBeNull(); }); it('has no build for phones, tablets, or ChromeOS', () => { @@ -63,3 +84,18 @@ describe('desktop assets', () => { ); }); }); + +describe('download client hints', () => { + it('requests architecture on /download before the page is shown', async () => { + // A variable keeps the untyped config out of the TypeScript module graph, + // the same way the dashboard header test loads it. + const configPath: string = '../../next.config.mjs'; + const nextConfig = (await import(configPath)).default; + const rules = await (await nextConfig('phase-production-server')).headers(); + const rule = rules.find((entry: { source: string }) => entry.source === '/download'); + const headers = Object.fromEntries( + rule.headers.map((header: { key: string; value: string }) => [header.key, header.value]), + ); + expect(headers).toEqual(DOWNLOAD_CLIENT_HINT_HEADERS); + }); +}); diff --git a/web/next.config.mjs b/web/next.config.mjs index 2b1f209d..2c318878 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -104,6 +104,17 @@ const nextConfig = { { key: 'X-Robots-Tag', value: 'noindex, nofollow' }, ], }, + { + // Chromium withholds Sec-CH-UA-Arch until the response asks for it. + // Critical-CH retries the navigation with that hint, so the download + // page can name the Intel or Arm build in the response the visitor + // actually sees. Values match DOWNLOAD_CLIENT_HINT_HEADERS. + source: '/download', + headers: [ + { key: 'Accept-CH', value: 'Sec-CH-UA-Arch, Sec-CH-UA-Platform' }, + { key: 'Critical-CH', value: 'Sec-CH-UA-Arch, Sec-CH-UA-Platform' }, + ], + }, ]; }, async rewrites() {