diff --git a/web/app/download/download.module.css b/web/app/download/download.module.css new file mode 100644 index 00000000..5511f69b --- /dev/null +++ b/web/app/download/download.module.css @@ -0,0 +1,266 @@ +.page { + min-height: 100svh; + background: var(--bg); + color: var(--fg); + font-family: var(--font-geist-sans), sans-serif; + -webkit-font-smoothing: antialiased; + transition: + background 0.3s ease, + color 0.3s ease; +} + +.main { + max-width: 72rem; + margin: 0 auto; + padding: 3.5rem 1.25rem 5rem; +} + +/* ── Hero ── */ + +.hero { + text-align: center; + padding: 1rem 0 2.75rem; +} + +.eyebrow { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--primary); + margin: 0 0 12px; +} + +.headline { + font-family: var(--font-heading), sans-serif; + font-size: clamp(2.4rem, 6vw, 4rem); + font-weight: 700; + letter-spacing: -0.04em; + color: var(--primary); + margin: 0 0 16px; +} + +.lead { + max-width: 42rem; + margin: 0 auto; + color: var(--fg-muted); + font-size: 1.05rem; + line-height: 1.7; +} + +.recommendation { + width: fit-content; + max-width: 100%; + margin: 2rem auto 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.65rem; +} + +.detected { + margin: 0; + color: var(--fg-faint); + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.primary { + composes: btn btn-primary from global; + min-height: 56px; + padding: 16px 28px; + font-size: 1.0625rem; + gap: 10px; +} + +.installNote { + margin: 0; + color: var(--fg-muted); + font-size: 0.9rem; + line-height: 1.5; +} + +.warning { + max-width: 36rem; + margin: 0.25rem auto 0; + color: var(--fg-faint); + font-size: 0.82rem; + line-height: 1.55; +} + +.warning em { + color: var(--fg-muted); + font-style: italic; +} + +.warning strong { + color: var(--fg-muted); + font-weight: 600; +} + +.choose { + margin: 1.75rem 0 0; + color: var(--fg-muted); + font-size: 0.95rem; +} + +/* ── Platform cards ── */ + +.platforms { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.platform { + display: flex; + flex-direction: column; + gap: 1.1rem; + padding: 1.35rem; + border: 1px solid var(--line); + border-radius: var(--r-lg); + background: var(--card-bg); + transition: + border-color 0.2s, + box-shadow 0.2s; +} + +.platformRecommended { + border-color: color-mix(in srgb, var(--primary) 55%, var(--line)); + box-shadow: 0 2px 20px color-mix(in srgb, var(--primary) 8%, transparent); +} + +.platformHeader h2 { + margin: 0 0 0.45rem; + font-family: var(--font-heading), sans-serif; + font-size: 1.35rem; + letter-spacing: -0.02em; + color: var(--fg); +} + +.platformHeader p { + margin: 0; + color: var(--fg-muted); + font-size: 0.9rem; + line-height: 1.55; +} + +.choices { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.choice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + min-height: 48px; + padding: 0.7rem 0.9rem; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--surface); + color: var(--fg); + text-decoration: none; + transition: + border-color 0.2s, + background 0.2s; +} + +@media (hover: hover) and (pointer: fine) { + .choice:hover { + border-color: var(--primary); + background: var(--secondary-surface); + } +} + +.choiceRecommended { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 10%, var(--surface)); +} + +.choiceArch { + font-size: 0.92rem; + font-weight: 600; +} + +.choiceFormat { + color: var(--fg-faint); + font-family: var(--font-geist-mono), monospace; + font-size: 0.78rem; +} + +.alternative { + margin: -0.35rem 0 0; + color: var(--fg-faint); + font-size: 0.82rem; +} + +.alternative a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.platformNote { + margin: auto 0 0; + color: var(--fg-faint); + font-size: 0.78rem; + line-height: 1.55; +} + +/* ── Release note ── */ + +.releaseNote { + max-width: 46rem; + margin: 2.5rem auto 0; + padding-top: 1.75rem; + border-top: 1px solid var(--line); + text-align: center; + color: var(--fg-faint); + font-size: 0.86rem; + line-height: 1.65; +} + +.releaseNote p { + margin: 0.4rem 0; +} + +.releaseNote a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +@media (max-width: 900px) { + .platforms { + grid-template-columns: 1fr; + } + + .main { + padding-top: 2.5rem; + } +} + +@media (max-width: 560px) { + .main { + padding-inline: 1rem; + } + + .headline { + font-size: clamp(2.1rem, 10vw, 2.8rem); + } + + .primary { + width: 100%; + justify-content: center; + } + + .platform { + padding: 1.1rem; + } +} diff --git a/web/app/download/page.tsx b/web/app/download/page.tsx new file mode 100644 index 00000000..e9c452a7 --- /dev/null +++ b/web/app/download/page.tsx @@ -0,0 +1,188 @@ +import type { Metadata } from 'next'; +import { headers } from 'next/headers'; + +import { cn } from '../../lib/utils'; +import { SiteFooter } from '../../components/SiteFooter'; +import { SiteNav } from '../../components/SiteNav'; +import { + LINUX_TARBALLS, + WINDOWS_PORTABLE, + detectDesktopDownload, + downloadLabel, + downloadsFor, + platformName, + type DesktopPlatform, +} from '../../lib/desktop-downloads'; +import { defaultOgImage } from '../../lib/og-meta'; +import { absoluteUrl, SITE_NAME } from '../../lib/site'; +import s from './download.module.css'; + +// The recommendation comes from the request's User-Agent, so each visitor +// gets their platform's download in the first response. +export const dynamic = 'force-dynamic'; + +const TITLE = 'Download Agent Relay for macOS, Windows, and Linux'; +const DESCRIPTION = + 'Download Agent Relay for macOS, Windows, or Linux. The same local relay for Claude Code, Codex, and your other agents on every desktop platform.'; + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { + canonical: absoluteUrl('/download'), + }, + openGraph: { + siteName: SITE_NAME, + title: TITLE, + description: DESCRIPTION, + url: absoluteUrl('/download'), + type: 'website', + images: [defaultOgImage()], + }, + twitter: { + card: 'summary_large_image', + title: TITLE, + description: DESCRIPTION, + images: [defaultOgImage().url], + }, +}; + +const platforms: Array<{ + id: DesktopPlatform; + name: string; + blurb: string; + note: string; +}> = [ + { + id: 'macos', + name: 'macOS', + blurb: 'For Apple silicon and Intel Macs.', + note: 'Requires macOS 13 or later. Open the disk image and drag Agent Relay to Applications.', + }, + { + id: 'windows', + name: 'Windows', + blurb: 'For Intel, AMD, and Arm PCs.', + note: 'Windows 10 and 11. Run the installer: it installs for your account without administrator rights. Releases are not code-signed yet, so SmartScreen may ask once; Smart App Control must be off.', + }, + { + id: 'linux', + name: 'Linux', + blurb: 'For x64 and arm64 distributions.', + note: 'Install the .deb with your package manager, or use a tarball if you prefer a relocatable install.', + }, +]; + +export default async function DownloadPage() { + const requestHeaders = await headers(); + const detected = detectDesktopDownload({ + userAgent: requestHeaders.get('user-agent') ?? '', + platform: requestHeaders.get('sec-ch-ua-platform'), + architecture: requestHeaders.get('sec-ch-ua-arch'), + }); + + return ( +
+ + +
+
+

Agent Relay Desktop

+

Download Agent Relay

+

+ The same window for your local agents on macOS, Windows, and Linux. Sign in once and your + sessions, agents, and messages are there. +

+ + {detected ? ( +
+

+ Detected {platformName(detected.platform)} + {detected.arch === 'arm64' ? ' on Arm' : ''} +

+ + {downloadLabel(detected)} + +

{detected.installNote}

+ {detected.platform === 'windows' ? ( +

+ The Windows installer is not code-signed yet: SmartScreen may show Windows protected your PC{' '} + once. Choose More info → Run anyway. +

+ ) : null} +
+ ) : ( +

Choose the download for your computer.

+ )} +
+ +
+ {platforms.map((platform) => { + const items = downloadsFor(platform.id); + const isRecommended = detected?.platform === platform.id; + + return ( +
+
+

{platform.name}

+

{platform.blurb}

+
+ +
+ {items.map((item) => { + const isRecommendedChoice = + detected?.platform === item.platform && detected.arch === item.arch; + + return ( + + {item.archLabel} + .{item.format} + + ); + })} +
+ + {platform.id === 'windows' ? ( +

+ Prefer portable? x64 .zip or{' '} + arm64 .zip +

+ ) : null} + + {platform.id === 'linux' ? ( +

+ Prefer a tarball? x64 or arm64 +

+ ) : null} + +

{platform.note}

+
+ ); + })} +
+ +
+

+ Every link points at the latest release, so it always serves the newest version. Each release also + publishes version-pinned files and a SHA-256 checksum for every download. +

+

+ + See the latest release, checksums, and version history + +

+
+
+ + +
+ ); +} diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index 21b8901c..27404a28 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -21,6 +21,12 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: 'weekly', priority: 1.0, }, + { + url: absoluteUrl('/download'), + lastModified: now, + changeFrequency: 'weekly', + priority: 0.9, + }, { url: absoluteUrl('/pear'), lastModified: now, diff --git a/web/components/SiteFooter.tsx b/web/components/SiteFooter.tsx index 39c34ba5..d30b63b0 100644 --- a/web/components/SiteFooter.tsx +++ b/web/components/SiteFooter.tsx @@ -21,6 +21,7 @@ const footerColumns: FooterColumn[] = [ title: 'Products', links: [ { label: 'Relay Cloud', href: 'https://agentrelay.com/cloud' }, + { label: 'Download', href: '/download' }, { label: 'Flows', href: '/flows' }, { label: 'Skip', href: 'https://heyskip.dev' }, { label: 'Pear', href: '/pear' }, diff --git a/web/components/SiteNav.tsx b/web/components/SiteNav.tsx index b91f0453..e49f3f69 100644 --- a/web/components/SiteNav.tsx +++ b/web/components/SiteNav.tsx @@ -101,6 +101,11 @@ export function SiteNav({ Log in +
  • + + Download + +
  • Docs @@ -172,6 +177,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 new file mode 100644 index 00000000..d0216847 --- /dev/null +++ b/web/lib/desktop-downloads.ts @@ -0,0 +1,176 @@ +const RELEASES = 'https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download'; + +export type DesktopPlatform = 'macos' | 'windows' | 'linux'; +export type DesktopArch = 'arm64' | 'x64'; +export type DesktopDownloadFormat = 'dmg' | 'exe' | 'zip' | 'deb'; + +export type DesktopDownload = { + platform: DesktopPlatform; + arch: DesktopArch; + format: DesktopDownloadFormat; + filename: string; + url: string; + /** Short architecture name for a button, such as "Apple silicon" or "x64". */ + archLabel: string; + /** What to do with the file once it arrives. */ + installNote: string; +}; + +const download = ( + platform: DesktopPlatform, + arch: DesktopArch, + format: DesktopDownloadFormat, + filename: string, + archLabel: string, + installNote: string, +): DesktopDownload => ({ + platform, + arch, + format, + filename, + url: `${RELEASES}/${filename}`, + archLabel, + installNote, +}); + +/** + * Every download link the website offers, one entry per platform and + * architecture. The stable asset names are published by relay-desktop's + * Release workflow, so these URLs follow a new release without a website + * deploy. + */ +export const DESKTOP_DOWNLOADS: readonly DesktopDownload[] = [ + download( + 'macos', + 'arm64', + 'dmg', + 'AgentRelay-macOS-arm64.dmg', + 'Apple silicon', + 'Open the disk image and drag Agent Relay to Applications.', + ), + download( + 'macos', + 'x64', + 'dmg', + 'AgentRelay-macOS-x64.dmg', + 'Intel', + 'Open the disk image and drag Agent Relay to Applications.', + ), + download( + 'windows', + 'x64', + 'exe', + 'AgentRelay-Windows-x64-Setup.exe', + 'x64', + 'Run the installer. It installs for your account without administrator rights.', + ), + download( + 'windows', + 'arm64', + 'exe', + 'AgentRelay-Windows-arm64-Setup.exe', + 'Arm64', + 'Run the installer. It installs for your account without administrator rights.', + ), + download( + 'linux', + 'x64', + 'deb', + 'AgentRelay-Linux-x64.deb', + 'x64', + 'Install the .deb with your package manager.', + ), + download( + 'linux', + 'arm64', + 'deb', + 'AgentRelay-Linux-arm64.deb', + 'arm64', + 'Install the .deb with your package manager.', + ), +]; + +/** + * The zips are the portable alternative to the Windows installer: unpack one + * and run agent-relay.exe without installing anything. + */ +export const WINDOWS_PORTABLE: Record = { + x64: `${RELEASES}/AgentRelay-Windows-x64.zip`, + arm64: `${RELEASES}/AgentRelay-Windows-arm64.zip`, +}; + +export const LINUX_TARBALLS: Record = { + x64: `${RELEASES}/AgentRelay-Linux-x64.tar.gz`, + arm64: `${RELEASES}/AgentRelay-Linux-arm64.tar.gz`, +}; + +export function platformName(platform: DesktopPlatform): string { + if (platform === 'macos') return 'macOS'; + if (platform === 'windows') return 'Windows'; + return 'Linux'; +} + +export function downloadsFor(platform: DesktopPlatform): DesktopDownload[] { + return DESKTOP_DOWNLOADS.filter((item) => item.platform === platform); +} + +export function downloadFor(platform: DesktopPlatform, arch: DesktopArch): DesktopDownload { + const match = DESKTOP_DOWNLOADS.find((item) => item.platform === platform && item.arch === arch); + if (!match) throw new Error(`No ${platform} ${arch} download`); + return match; +} + +export function downloadLabel(item: DesktopDownload): string { + return `Download for ${platformName(item.platform)} (${item.archLabel})`; +} + +export type DesktopDetectInput = { + userAgent: string; + /** `navigator.platform`, or the quoted `Sec-CH-UA-Platform` request header. */ + platform?: string | null; + /** `Sec-CH-UA-Arch`, when a browser sends it. */ + architecture?: string | null; +}; + +/** + * Work out which download to put first. Runs on the server from the request's + * User-Agent (and Client Hint headers), so Windows visitors get the Windows + * build in the first response instead of a Mac download that swaps after + * hydration. When the platform is unclear, the page shows every option. + */ +export function detectDesktopDownload(input: DesktopDetectInput): DesktopDownload | null { + const userAgent = input.userAgent ?? ''; + const platformHint = (input.platform ?? '').replaceAll('"', '').toLowerCase(); + + // Phones, tablets, and ChromeOS are not desktop installs. + if (/iphone|ipad|ipod|android|windows phone|mobile/i.test(userAgent) || /cros/i.test(userAgent)) { + return null; + } + + let platform: DesktopPlatform | null = null; + if (platformHint.includes('windows') || /windows|win32|win64/i.test(userAgent)) { + platform = 'windows'; + } else if (platformHint.includes('mac') || /macintosh|mac os x/i.test(userAgent)) { + platform = 'macos'; + } else if (platformHint.includes('linux') || /linux|x11/i.test(userAgent)) { + platform = 'linux'; + } + + if (!platform) return null; + + return downloadFor(platform, detectArch(platform, userAgent, input.architecture)); +} + +function detectArch( + platform: DesktopPlatform, + userAgent: string, + architecture: string | null | undefined, +): DesktopArch { + const hint = (architecture ?? '').replaceAll('"', '').toLowerCase(); + if (hint === 'arm' || /arm64|aarch64|armv8/i.test(userAgent)) return 'arm64'; + if (hint === 'x86' || /x86_64|amd64|win64/i.test(userAgent)) return 'x64'; + + // Safari does not expose the Mac's architecture. Apple silicon is the + // common case; Intel Macs can still choose Intel from the options below. + return platform === 'macos' ? 'arm64' : 'x64'; +} diff --git a/web/lib/test/desktop-downloads.test.ts b/web/lib/test/desktop-downloads.test.ts new file mode 100644 index 00000000..0439a0fc --- /dev/null +++ b/web/lib/test/desktop-downloads.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { + LINUX_TARBALLS, + WINDOWS_PORTABLE, + downloadFor, + detectDesktopDownload, + downloadLabel, +} from '../desktop-downloads'; + +const WINDOWS_X64 = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'; +const WINDOWS_ARM64 = + 'Mozilla/5.0 (Windows NT 10.0; Win64; ARM64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'; +const MAC_APPLE_SILICON = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15'; +const LINUX_X64 = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'; + +describe('desktop downloads', () => { + it('detects Windows and recommends the Windows installer, not the Mac disk image', () => { + const detected = detectDesktopDownload({ userAgent: WINDOWS_X64 }); + + expect(detected?.platform).toBe('windows'); + expect(detected?.arch).toBe('x64'); + expect(detected?.filename).toBe('AgentRelay-Windows-x64-Setup.exe'); + expect(detected?.url).toBe( + 'https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/AgentRelay-Windows-x64-Setup.exe', + ); + expect(downloadLabel(detected!)).toBe('Download for Windows (x64)'); + }); + + it('uses Client Hints or the user agent to pick the Arm installer', () => { + expect( + detectDesktopDownload({ userAgent: WINDOWS_X64, architecture: '"arm"' })?.filename, + ).toBe('AgentRelay-Windows-arm64-Setup.exe'); + expect(detectDesktopDownload({ userAgent: WINDOWS_ARM64 })?.filename).toBe( + 'AgentRelay-Windows-arm64-Setup.exe', + ); + }); + + it('uses the Sec-CH-UA-Platform header when the user agent is reduced', () => { + expect( + detectDesktopDownload({ userAgent: 'Mozilla/5.0', platform: '"Windows"' })?.platform, + ).toBe('windows'); + expect( + detectDesktopDownload({ userAgent: 'Mozilla/5.0', platform: '"macOS"' })?.platform, + ).toBe('macos'); + }); + + it('defaults Macs to Apple silicon and honors an Intel Client Hint', () => { + expect(detectDesktopDownload({ userAgent: MAC_APPLE_SILICON })?.filename).toBe( + 'AgentRelay-macOS-arm64.dmg', + ); + expect( + detectDesktopDownload({ userAgent: MAC_APPLE_SILICON, architecture: '"x86"' })?.filename, + ).toBe('AgentRelay-macOS-x64.dmg'); + }); + + it('detects Linux and links both package formats', () => { + expect(detectDesktopDownload({ userAgent: LINUX_X64 })?.filename).toBe('AgentRelay-Linux-x64.deb'); + expect(LINUX_TARBALLS.x64).toMatch(/AgentRelay-Linux-x64\.tar\.gz$/); + expect(LINUX_TARBALLS.arm64).toMatch(/AgentRelay-Linux-arm64\.tar\.gz$/); + }); + + it('offers every option when the platform is unclear or not a desktop', () => { + expect(detectDesktopDownload({ userAgent: 'curl/8.0' })).toBeNull(); + expect( + detectDesktopDownload({ + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', + }), + ).toBeNull(); + expect( + detectDesktopDownload({ + userAgent: + 'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', + }), + ).toBeNull(); + expect(downloadFor('windows', 'arm64').format).toBe('exe'); + expect(WINDOWS_PORTABLE.x64).toMatch(/AgentRelay-Windows-x64\.zip$/); + expect(WINDOWS_PORTABLE.arm64).toMatch(/AgentRelay-Windows-arm64\.zip$/); + }); +});