From d4841b68ffe453b8ce35f1659a5e8ac6602bdede Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 2 Sep 2026 15:46:16 +0200 Subject: [PATCH] fix(website): detect the visitor platform during SSR (#4222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The homepage download CTA was chosen by a client-only hook that started at "mac-silicon" and corrected itself in an effect, so the server-rendered HTML said "Download for Mac" for every visitor and only became right after hydration. On a phone that window is long enough to photograph. Worse, the detection had no mobile branch at all: Android user agents also say "Linux", so once hydration did land, a Pixel was offered an AppImage. Resolve the platform from the request user agent during SSR instead, so the first paint is already correct. Mobile visitors get the App Store or Play Store as the primary action and a link to /download for the desktop build, rather than a .dmg they cannot open. The markup now varies by user agent, so HTML and server-function responses carry `Cache-Control: private, no-store` and `Vary: User-Agent`. An absent cache-control is not no-store — a shared cache may assign heuristic freshness, and turning on Workers Cache would store a headerless 200 for two hours under a key that ignores the user agent, letting one Android visitor pin the Play Store button for every Mac visitor after them. Also tighten the mobile layout the wrong CTA was sitting in: the primary action gets its own row so the icon buttons stop orphaning one of themselves, provider cards drop to a two-column grid, and the hardcoded mobile line break in the phone showcase gives way to a balanced measure. --- .../website/src/components/landing-page.tsx | 155 +++++++++++------- packages/website/src/downloads.tsx | 72 ++------ packages/website/src/platform.test.ts | 37 +++++ packages/website/src/platform.ts | 35 ++++ packages/website/src/routes/__root.tsx | 24 ++- packages/website/src/server-entry.ts | 29 +++- 6 files changed, 236 insertions(+), 116 deletions(-) create mode 100644 packages/website/src/platform.test.ts create mode 100644 packages/website/src/platform.ts diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index a1fe6bca48..638609fcf1 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -56,13 +56,15 @@ import { AGENT_PAGES } from "~/data/agent-pages"; import { appStoreUrl, playStoreUrl, - getDownloadOptions, - useDetectedPlatform, + getDesktopDownload, + MOBILE_STORES, AppleIcon, PlayStoreIcon, TerminalIcon, } from "~/downloads"; -import { useRelease } from "~/routes/__root"; +import type { DesktopPlatform, MobilePlatform } from "~/platform"; +import { isMobilePlatform } from "~/platform"; +import { useRelease, useVisitorPlatform } from "~/routes/__root"; import { HeroMockup } from "~/components/hero-mockup"; import { ClaudeCodeIcon, @@ -315,7 +317,7 @@ function SectionTitle({ )} -

{description}

+

{description}

{links ? (
{links.map((link) => ( @@ -414,13 +416,15 @@ function SocialProofCard({ tweet, inert }: { tweet: SocialProofTweet; inert?: bo ); } +const PROVIDER_ICON_CLASS = "h-5 w-5 sm:h-7 sm:w-7"; + function MultiProviderSection() { const providers = [ - { name: "Claude Code", icon: }, - { name: "Codex", icon: }, - { name: "OpenCode", icon: }, - { name: "Pi", icon: }, - { name: "Cursor", icon: }, + { name: "Claude Code", icon: }, + { name: "Codex", icon: }, + { name: "OpenCode", icon: }, + { name: "Pi", icon: }, + { name: "Cursor", icon: }, ]; return ( @@ -428,21 +432,21 @@ function MultiProviderSection() { title="Works with your tools" description="Bring your subscriptions, skills and configuration" > -
+
{providers.map((p) => (
- {p.icon} - {p.name} + {p.icon} + {p.name}
))} - +{ADDITIONAL_AGENT_COUNT} more + +{ADDITIONAL_AGENT_COUNT} more
@@ -842,31 +846,24 @@ function ExtensibleCard({ } function GetStarted() { + const platform = useVisitorPlatform(); return (
-
- - - - - - - - + {/* The primary call to action owns its own row on phones so the small icon + buttons never wrap and orphan one of themselves onto a line alone. It + still hugs its label rather than stretching across the row. */} +
+ {isMobilePlatform(platform) ? ( + + ) : ( + + )} +
+ {isMobilePlatform(platform) ? : } + +
-
+
Supports
@@ -877,7 +874,7 @@ function GetStarted() {
+{ADDITIONAL_AGENT_COUNT} more @@ -886,27 +883,74 @@ function GetStarted() { ); } -function DownloadButton() { - const release = useRelease(); - const detectedPlatform = useDetectedPlatform(); - const primary = getDownloadOptions(release).find((o) => o.platform === detectedPlatform)!; - const PrimaryIcon = primary.icon; +const PRIMARY_CTA_CLASS = + "inline-flex items-center justify-center gap-2 rounded-lg bg-foreground px-4 py-2.5 text-sm font-medium text-background hover:bg-foreground/90 transition-colors"; +const SECONDARY_CTA_CLASS = + "inline-flex items-center justify-center gap-2 rounded-lg border border-white/12 px-3 py-2.5 text-sm text-white hover:bg-white/10 transition-colors"; +function DesktopDownloadButton({ platform }: { platform: DesktopPlatform }) { + const download = getDesktopDownload(useRelease(), platform); + const Icon = download.icon; return ( - - - Download for {primary.label} + + + Download for {download.label} ); } +function StoreButton({ platform }: { platform: MobilePlatform }) { + const store = MOBILE_STORES[platform]; + const Icon = store.icon; + return ( + + + Get the {store.label} app + + ); +} + +// On a phone the desktop build is the secondary path, so it points at /download +// instead of handing the visitor a .dmg they cannot open. +function DesktopAppLink() { + return ( + + + Desktop app + + ); +} + +function StoreIconLinks() { + return ( + <> + + + + + + + + ); +} + const SERVER_INSTALL_TRIGGER = ( - + ); @@ -989,11 +1033,10 @@ function PhoneShowcase() { > -

- When you want to step away from your desk, -
you can. +

+ When you want to step away from your desk, you can.

-

+

The native mobile app has full feature parity with desktop.

diff --git a/packages/website/src/downloads.tsx b/packages/website/src/downloads.tsx index 3a19085126..cce40a7791 100644 --- a/packages/website/src/downloads.tsx +++ b/packages/website/src/downloads.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import type { DesktopPlatform, MobilePlatform } from "~/platform"; export function releaseBase(version: string) { return `https://github.com/getpaseo/paseo/releases/download/v${version}`; @@ -30,68 +31,31 @@ export const appStoreUrl = "https://apps.apple.com/app/paseo-pocket-engineer/id6 export const playStoreUrl = "https://play.google.com/store/apps/details?id=sh.paseo"; export const webAppUrl = "https://app.paseo.sh"; -type Platform = "mac-silicon" | "mac-intel" | "windows" | "linux"; - -export interface DownloadOption { - platform: Platform; +export interface PrimaryDownload { label: string; href: string; icon: (props: React.SVGProps) => React.ReactElement; } -export function getDownloadOptions(release: ReleaseAssetInfo): DownloadOption[] { +export function getDesktopDownload( + release: ReleaseAssetInfo, + platform: DesktopPlatform, +): PrimaryDownload { const urls = downloadUrls(release); - return [ - { - platform: "mac-silicon", - label: "Mac", - href: urls.macAppleSilicon, - icon: AppleIcon, - }, - { - platform: "mac-intel", - label: "Mac Intel", - href: urls.macIntel, - icon: AppleIcon, - }, - { - platform: "windows", - label: "Windows", - href: urls.windowsExeX64, - icon: WindowsIcon, - }, - { - platform: "linux", - label: "Linux", - href: urls.linuxAppImage, - icon: LinuxIcon, - }, - ]; + switch (platform) { + case "windows": + return { label: "Windows", href: urls.windowsExeX64, icon: WindowsIcon }; + case "linux": + return { label: "Linux", href: urls.linuxAppImage, icon: LinuxIcon }; + case "mac": + return { label: "Mac", href: urls.macAppleSilicon, icon: AppleIcon }; + } } -export function useDetectedPlatform(): Platform { - const [platform, setPlatform] = React.useState("mac-silicon"); - - React.useEffect(() => { - const ua = navigator.userAgent.toLowerCase(); - if (ua.includes("win")) { - setPlatform("windows"); - } else if (ua.includes("linux")) { - setPlatform("linux"); - } else if (ua.includes("mac")) { - // Check for Apple Silicon vs Intel - // navigator.platform is deprecated but still the most reliable check - const isARM = - /arm|aarch64/i.test(navigator.platform) || - // Chrome/Edge on Apple Silicon report x86 platform but have ARM in userAgentData - (navigator as unknown as { userAgentData?: { architecture?: string } }).userAgentData - ?.architecture === "arm"; - setPlatform(isARM ? "mac-silicon" : "mac-silicon"); // Default to Silicon for modern Macs - } - }, []); - - return platform; -} +export const MOBILE_STORES: Record = { + ios: { label: "iPhone", href: appStoreUrl, icon: AppleIcon }, + android: { label: "Android", href: playStoreUrl, icon: PlayStoreIcon }, +}; export function AppleIcon(props: React.SVGProps) { return ( diff --git a/packages/website/src/platform.test.ts b/packages/website/src/platform.test.ts new file mode 100644 index 0000000000..36c91acb18 --- /dev/null +++ b/packages/website/src/platform.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { detectPlatform } from "./platform"; + +const AGENTS = { + android: + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Mobile Safari/537.36", + iphone: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + windows: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36", + linux: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36", + mac: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36", +}; + +describe("detectPlatform", () => { + it.each([ + [AGENTS.android, "android"], + [AGENTS.iphone, "ios"], + [AGENTS.windows, "windows"], + [AGENTS.linux, "linux"], + [AGENTS.mac, "mac"], + ])("resolves %s to %s", (userAgent, expected) => { + expect(detectPlatform(userAgent)).toBe(expected); + }); + + it("prefers the mobile OS over the kernel or vendor the same agent also claims", () => { + // Android agents say "Linux" and iOS ones say "like Mac OS X", so a + // desktop-first check hands a phone a .dmg or an AppImage. + expect(detectPlatform(AGENTS.android)).not.toBe("linux"); + expect(detectPlatform(AGENTS.iphone)).not.toBe("mac"); + }); + + it("falls back to mac for an unknown user agent", () => { + expect(detectPlatform("")).toBe("mac"); + }); +}); diff --git a/packages/website/src/platform.ts b/packages/website/src/platform.ts new file mode 100644 index 0000000000..3886539183 --- /dev/null +++ b/packages/website/src/platform.ts @@ -0,0 +1,35 @@ +import { createServerFn } from "@tanstack/react-start"; +import { getRequestHeader } from "@tanstack/react-start/server"; + +export type DesktopPlatform = "mac" | "windows" | "linux"; +export type MobilePlatform = "ios" | "android"; +export type VisitorPlatform = DesktopPlatform | MobilePlatform; + +export function isMobilePlatform(platform: VisitorPlatform): platform is MobilePlatform { + return platform === "ios" || platform === "android"; +} + +/** + * Order matters: Android user agents also say "Linux", and iOS ones also say + * "like Mac OS X". iPadOS Safari sends a desktop Mac user agent with no mobile + * marker at all, so an iPad is detected as a Mac — there is no server-side + * signal that separates them. + */ +export function detectPlatform(userAgent: string): VisitorPlatform { + const ua = userAgent.toLowerCase(); + if (/android/.test(ua)) return "android"; + if (/iphone|ipad|ipod/.test(ua)) return "ios"; + if (/windows|win32|win64/.test(ua)) return "windows"; + if (/linux|x11|cros/.test(ua)) return "linux"; + return "mac"; +} + +/** + * Resolved during SSR so the first paint already shows the right call to + * action. The worker renders every HTML response per request and sets no + * cache-control, so varying the markup on the user agent is safe — if the + * homepage ever gains an edge cache, it has to vary on `user-agent` too. + */ +export const getVisitorPlatform = createServerFn({ method: "GET" }).handler( + (): VisitorPlatform => detectPlatform(getRequestHeader("user-agent") ?? ""), +); diff --git a/packages/website/src/routes/__root.tsx b/packages/website/src/routes/__root.tsx index 82b9de5132..1f80338275 100644 --- a/packages/website/src/routes/__root.tsx +++ b/packages/website/src/routes/__root.tsx @@ -2,6 +2,8 @@ import type { ReactNode } from "react"; import { createContext, useContext } from "react"; import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router"; import type { ReleaseChannels, ReleaseInfo } from "~/latest-release"; +import type { VisitorPlatform } from "~/platform"; +import { getVisitorPlatform } from "~/platform"; import { getLatestRelease } from "~/release"; import { getStarCount } from "~/stars"; @@ -19,6 +21,7 @@ const ReleaseCtx = createContext({ beta: null, }); const StarsCtx = createContext({ stars: "" }); +const PlatformCtx = createContext("mac"); const PLAUSIBLE_INIT_SCRIPT = { __html: `window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()`, @@ -38,10 +41,19 @@ export function useStars(): StarsContext { return useContext(StarsCtx); } +/** The platform the visitor is browsing from, resolved from the request user agent during SSR. */ +export function useVisitorPlatform(): VisitorPlatform { + return useContext(PlatformCtx); +} + export const Route = createRootRoute({ loader: async () => { - const [release, stars] = await Promise.all([getLatestRelease(), getStarCount()]); - return { release, ...stars }; + const [release, stars, platform] = await Promise.all([ + getLatestRelease(), + getStarCount(), + getVisitorPlatform(), + ]); + return { release, platform, ...stars }; }, head: () => ({ meta: [ @@ -68,9 +80,11 @@ function RootComponent() { return ( - - - + + + + + ); diff --git a/packages/website/src/server-entry.ts b/packages/website/src/server-entry.ts index 1bd82b8113..75f46e2450 100644 --- a/packages/website/src/server-entry.ts +++ b/packages/website/src/server-entry.ts @@ -33,6 +33,32 @@ function docSlugFromMarkdownPath(pathname: string): string | null { return match ? match[1] : null; } +/** + * SSR reads the request user agent to pick the download call to action (see + * `~/platform`), so two visitors on the same URL get different markup. Nothing + * caches these responses today — the zone cache sits behind the Worker and + * Workers Cache is off — but an absent `cache-control` is not `no-store`: a + * shared cache may assign heuristic freshness (RFC 9111 §4.2.2), and enabling + * Workers Cache would store a headerless 200 for two hours under a key that + * ignores `user-agent`. Either way one Android visitor could pin the Play Store + * button for every Mac visitor after them. + * + * `private` is what actually stops that. `Vary` records the contract so a + * compliant cache stays correct if the policy is ever loosened. Static assets + * never reach this Worker, so their long-lived `_headers` rules are untouched. + */ +function withoutSharedCaching(response: Response): Response { + const result = new Response(response.body, response); + result.headers.set("cache-control", "private, no-store"); + result.headers.set("vary", "user-agent"); + return result; +} + +function variesByUserAgent(pathname: string, response: Response): boolean { + if (pathname.startsWith("/_serverFn/")) return true; + return response.headers.get("content-type")?.includes("text/html") ?? false; +} + export default { async fetch(request: Request, env: WebsiteEnv, context: ExecutionContext): Promise { const url = new URL(request.url); @@ -73,6 +99,7 @@ export default { return markdownResponse(doc.content); } - return startEntry.fetch(request); + const response = await startEntry.fetch(request); + return variesByUserAgent(url.pathname, response) ? withoutSharedCaching(response) : response; }, };