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; }, };