Skip to content
Open
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
39 changes: 27 additions & 12 deletions web/app/download/DownloadButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DesktopPlatform | null>(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<DesktopPlatform | null>(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;
};
Expand Down
25 changes: 21 additions & 4 deletions web/app/download/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 <file>',
},
];
Expand All @@ -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'),
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
platform: requestHeaders.get('sec-ch-ua-platform'),
});

return (
<div className={`${s.page} ${s.messagingPage} ${s.homeLanding}`}>
<a className="skip-link" href="#main">
Expand All @@ -74,7 +91,7 @@ export default function DownloadPage() {
<header className={d.hero}>
<h1 className={d.heroTitle}>Download Agent Relay</h1>
<p className={d.heroLine}>The desktop app connects the coding agents on your machine to your team’s relay.</p>
<DownloadButton />
<DownloadButton initialPlatform={detected} />
<p className={d.agentInstall}>
On macOS or Linux, your coding agent can install it for you. <a href="/signup/teams">Get the setup prompt</a>
</p>
Expand Down
10 changes: 10 additions & 0 deletions web/components/SiteNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ export function SiteNav({
Log in
</a>
</li>
<li>
<Link href="/download" className={s.link}>
Download
</Link>
</li>
<li>
<Link href="/docs" className={s.link}>
Docs
Expand Down Expand Up @@ -171,6 +176,11 @@ export function SiteNav({
Log in
</a>
)}
{!hideLinks && (
<Link href="/download" className={s.mobileLink} onClick={() => setMenuOpen(false)}>
Download
</Link>
)}
{!hideLinks && !hideMobileDocsLink && (
<Link href="/docs" className={s.mobileLink} onClick={() => setMenuOpen(false)}>
Docs
Expand Down
57 changes: 46 additions & 11 deletions web/lib/desktop-downloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
38 changes: 37 additions & 1 deletion web/lib/test/desktop-downloads.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
11 changes: 11 additions & 0 deletions web/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading