diff --git a/packages/website/e2e/download-thanks.spec.ts b/packages/website/e2e/download-thanks.spec.ts new file mode 100644 index 0000000000..bc33f8f087 --- /dev/null +++ b/packages/website/e2e/download-thanks.spec.ts @@ -0,0 +1,47 @@ +import { expect, test, type Page } from "playwright/test"; + +const APPLE_SILICON_DMG = /\/getpaseo\/paseo\/releases\/download\/v[^/]+\/Paseo-[^/]+-arm64\.dmg$/; + +async function openDownloadPage(page: Page) { + // Serve a stand-in file so the test never downloads a real release. + await page.route(APPLE_SILICON_DMG, (route) => + route.fulfill({ + status: 200, + headers: { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=Paseo.dmg", + }, + body: "dmg", + }), + ); + await page.goto("/download"); +} + +async function clickAppleSilicon(page: Page) { + await page.getByRole("link", { name: "Apple Silicon", exact: true }).click(); +} + +test("a download button opens the thanks page and requests the file", async ({ page }) => { + await openDownloadPage(page); + + const fileRequest = page.waitForRequest(APPLE_SILICON_DMG); + await clickAppleSilicon(page); + + await fileRequest; + await expect(page).toHaveURL(/\/download\/thanks\?file=/); + await expect(page.getByRole("heading", { name: "Thanks for downloading Paseo" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Try again" })).toHaveAttribute( + "href", + APPLE_SILICON_DMG, + ); +}); + +test("the thanks page starts a browser download", async ({ page, browserName }) => { + test.skip(browserName === "webkit", "WebKit reports no download event for a stubbed response"); + await openDownloadPage(page); + + const download = page.waitForEvent("download"); + await clickAppleSilicon(page); + + expect((await download).url()).toMatch(APPLE_SILICON_DMG); +}); diff --git a/packages/website/src/components/brand-icons.tsx b/packages/website/src/components/brand-icons.tsx index c7692b43d6..4e964be8e5 100644 --- a/packages/website/src/components/brand-icons.tsx +++ b/packages/website/src/components/brand-icons.tsx @@ -66,3 +66,17 @@ export function BuyMeACoffeeIcon(props: React.SVGProps) { ); } + +export function RedditIcon(props: React.SVGProps) { + return ( + + ); +} diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 6a29c62235..ab70cd5a22 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -994,7 +994,7 @@ function DesktopDownloadButton({ platform }: { platform: DesktopPlatform }) { const download = getDesktopDownload(useRelease(), platform); const Icon = download.icon; return ( - + Download for {download.label} diff --git a/packages/website/src/downloads.test.ts b/packages/website/src/downloads.test.ts new file mode 100644 index 0000000000..3b6130234a --- /dev/null +++ b/packages/website/src/downloads.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { parseReleaseAssetUrl } from "./downloads"; + +describe("parseReleaseAssetUrl", () => { + it("accepts a Paseo release file", () => { + const url = + "https://github.com/getpaseo/paseo/releases/download/v0.10.3/Paseo-0.10.3-arm64.dmg"; + expect(parseReleaseAssetUrl(url)).toBe(url); + }); + + it("rejects a path that climbs into another repository's releases", () => { + expect( + parseReleaseAssetUrl( + "https://github.com/getpaseo/paseo/releases/download/v1/../../../../evil/repo/releases/download/v1/app.dmg", + ), + ).toBeNull(); + expect( + parseReleaseAssetUrl( + "https://github.com/getpaseo/paseo/releases/download/v1/%2e%2e/%2e%2e/%2e%2e/%2e%2e/evil/repo/app.dmg", + ), + ).toBeNull(); + }); + + it("rejects other hosts and malformed values", () => { + expect( + parseReleaseAssetUrl( + "https://github.com.evil.test/getpaseo/paseo/releases/download/v1/a.dmg", + ), + ).toBeNull(); + expect( + parseReleaseAssetUrl("http://github.com/getpaseo/paseo/releases/download/v1/a.dmg"), + ).toBeNull(); + expect(parseReleaseAssetUrl("not a url")).toBeNull(); + expect(parseReleaseAssetUrl(undefined)).toBeNull(); + }); +}); diff --git a/packages/website/src/downloads.tsx b/packages/website/src/downloads.tsx index cce40a7791..0f55f99acf 100644 --- a/packages/website/src/downloads.tsx +++ b/packages/website/src/downloads.tsx @@ -27,6 +27,27 @@ export function downloadUrls(release: ReleaseAssetInfo) { }; } +const RELEASE_ASSETS_ORIGIN = "https://github.com"; +const RELEASE_ASSETS_PATH = "/getpaseo/paseo/releases/download/"; + +/** + * The release file a thanks-page link asks for, or null when it is not one of + * ours. Parsing normalizes `..` segments, so a link cannot climb out of this + * repo's releases into another repo's. + */ +export function parseReleaseAssetUrl(value: unknown): string | null { + if (typeof value !== "string" || !URL.canParse(value)) return null; + const url = new URL(value); + if (url.origin !== RELEASE_ASSETS_ORIGIN) return null; + if (!url.pathname.startsWith(RELEASE_ASSETS_PATH)) return null; + return url.href; +} + +/** Download buttons link here; the thanks page starts the download itself. */ +export function thanksPageHref(fileUrl: string): string { + return `/download/thanks?${new URLSearchParams({ file: fileUrl })}`; +} + export const appStoreUrl = "https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"; export const playStoreUrl = "https://play.google.com/store/apps/details?id=sh.paseo"; export const webAppUrl = "https://app.paseo.sh"; @@ -44,11 +65,11 @@ export function getDesktopDownload( const urls = downloadUrls(release); switch (platform) { case "windows": - return { label: "Windows", href: urls.windowsExeX64, icon: WindowsIcon }; + return { label: "Windows", href: thanksPageHref(urls.windowsExeX64), icon: WindowsIcon }; case "linux": - return { label: "Linux", href: urls.linuxAppImage, icon: LinuxIcon }; + return { label: "Linux", href: thanksPageHref(urls.linuxAppImage), icon: LinuxIcon }; case "mac": - return { label: "Mac", href: urls.macAppleSilicon, icon: AppleIcon }; + return { label: "Mac", href: thanksPageHref(urls.macAppleSilicon), icon: AppleIcon }; } } diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index 11d3106617..9b2a842b0d 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -62,6 +62,7 @@ import { Route as AgentsRouteImport } from "./routes/agents"; import { Route as IndexRouteImport } from "./routes/index"; import { Route as DocsIndexRouteImport } from "./routes/docs/index"; import { Route as BlogIndexRouteImport } from "./routes/blog/index"; +import { Route as DownloadThanksRouteImport } from "./routes/download_.thanks"; import { Route as DocsSplatRouteImport } from "./routes/docs/$"; import { Route as BlogSplatRouteImport } from "./routes/blog/$"; import { Route as AlternativesSupersetRouteImport } from "./routes/alternatives/superset"; @@ -338,6 +339,11 @@ const BlogIndexRoute = BlogIndexRouteImport.update({ path: "/", getParentRoute: () => BlogRoute, } as any); +const DownloadThanksRoute = DownloadThanksRouteImport.update({ + id: "/download_/thanks", + path: "/download/thanks", + getParentRoute: () => rootRouteImport, +} as any); const DocsSplatRoute = DocsSplatRouteImport.update({ id: "/$", path: "/$", @@ -453,6 +459,7 @@ export interface FileRoutesByFullPath { "/alternatives/superset": typeof AlternativesSupersetRoute; "/blog/$": typeof BlogSplatRoute; "/docs/$": typeof DocsSplatRoute; + "/download/thanks": typeof DownloadThanksRoute; "/blog/": typeof BlogIndexRoute; "/docs/": typeof DocsIndexRoute; } @@ -516,6 +523,7 @@ export interface FileRoutesByTo { "/alternatives/superset": typeof AlternativesSupersetRoute; "/blog/$": typeof BlogSplatRoute; "/docs/$": typeof DocsSplatRoute; + "/download/thanks": typeof DownloadThanksRoute; "/blog": typeof BlogIndexRoute; "/docs": typeof DocsIndexRoute; } @@ -582,6 +590,7 @@ export interface FileRoutesById { "/alternatives/superset": typeof AlternativesSupersetRoute; "/blog/$": typeof BlogSplatRoute; "/docs/$": typeof DocsSplatRoute; + "/download_/thanks": typeof DownloadThanksRoute; "/blog/": typeof BlogIndexRoute; "/docs/": typeof DocsIndexRoute; } @@ -649,6 +658,7 @@ export interface FileRouteTypes { | "/alternatives/superset" | "/blog/$" | "/docs/$" + | "/download/thanks" | "/blog/" | "/docs/"; fileRoutesByTo: FileRoutesByTo; @@ -712,6 +722,7 @@ export interface FileRouteTypes { | "/alternatives/superset" | "/blog/$" | "/docs/$" + | "/download/thanks" | "/blog" | "/docs"; id: @@ -777,6 +788,7 @@ export interface FileRouteTypes { | "/alternatives/superset" | "/blog/$" | "/docs/$" + | "/download_/thanks" | "/blog/" | "/docs/"; fileRoutesById: FileRoutesById; @@ -841,6 +853,7 @@ export interface RootRouteChildren { AlternativesOpencodeDesktopRoute: typeof AlternativesOpencodeDesktopRoute; AlternativesOrcaRoute: typeof AlternativesOrcaRoute; AlternativesSupersetRoute: typeof AlternativesSupersetRoute; + DownloadThanksRoute: typeof DownloadThanksRoute; } declare module "@tanstack/react-router" { @@ -1216,6 +1229,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof BlogIndexRouteImport; parentRoute: typeof BlogRoute; }; + "/download_/thanks": { + id: "/download_/thanks"; + path: "/download/thanks"; + fullPath: "/download/thanks"; + preLoaderRoute: typeof DownloadThanksRouteImport; + parentRoute: typeof rootRouteImport; + }; "/docs/$": { id: "/docs/$"; path: "/$"; @@ -1373,6 +1393,7 @@ const rootRouteChildren: RootRouteChildren = { AlternativesOpencodeDesktopRoute: AlternativesOpencodeDesktopRoute, AlternativesOrcaRoute: AlternativesOrcaRoute, AlternativesSupersetRoute: AlternativesSupersetRoute, + DownloadThanksRoute: DownloadThanksRoute, }; export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/website/src/routes/download.tsx b/packages/website/src/routes/download.tsx index f2623e37a4..b7f7331d2f 100644 --- a/packages/website/src/routes/download.tsx +++ b/packages/website/src/routes/download.tsx @@ -8,6 +8,7 @@ import { downloadUrls, appStoreUrl, playStoreUrl, + thanksPageHref, webAppUrl, AppleIcon, AndroidIcon, @@ -88,8 +89,8 @@ function Download() {
- - + + Requires macOS 13 or newer
@@ -104,18 +105,20 @@ function Download() { - {urls.windowsExeArm64 && } + {urls.windowsExeArm64 && ( + + )} - - - + + + @@ -132,7 +135,7 @@ function Download() { {!onBeta && } - + @@ -304,8 +307,7 @@ function DownloadPill({ return ( {label} diff --git a/packages/website/src/routes/download_.thanks.tsx b/packages/website/src/routes/download_.thanks.tsx new file mode 100644 index 0000000000..8bbd99a121 --- /dev/null +++ b/packages/website/src/routes/download_.thanks.tsx @@ -0,0 +1,95 @@ +import { useEffect } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { DiscordIcon, GitHubIcon, RedditIcon } from "~/components/brand-icons"; +import { SiteShell } from "~/components/site-shell"; +import { SponsorSection } from "~/components/sponsorship"; +import { parseReleaseAssetUrl } from "~/downloads"; +import { pageMeta } from "~/meta"; +import "~/styles.css"; + +interface ThanksSearch { + file?: string; +} + +export const Route = createFileRoute("/download_/thanks")({ + validateSearch: (search: Record): ThanksSearch => { + const file = parseReleaseAssetUrl(search.file); + return file ? { file } : {}; + }, + head: () => + pageMeta( + "Thanks for downloading Paseo", + "Your Paseo download is starting. Join the community on Discord, GitHub, and Reddit, or sponsor Paseo.", + "/download/thanks", + ), + component: DownloadThanks, +}); + +const COMMUNITY = [ + { + name: "Discord", + icon: , + body: "Get help and share feedback.", + href: "https://discord.gg/jz8T2uahpH", + }, + { + name: "GitHub", + icon: , + body: "Star, report issues, contribute.", + href: "https://github.com/getpaseo/paseo", + }, + { + name: "Reddit", + icon: , + body: "Workflows and questions.", + href: "https://www.reddit.com/r/PaseoAI/", + }, +]; + +function DownloadThanks() { + const { file } = Route.useSearch(); + + useEffect(() => { + if (file) window.location.assign(file); + }, [file]); + + return ( + +

Thanks for downloading Paseo

+

+ Get help, share feedback, and follow along. +

+ {file && ( +

+ Download didn't start?{" "} + + Try again + +

+ )} + +
+
+ {COMMUNITY.map((item) => ( + + {item.icon} + + + {item.name} + + {item.body} + + + ))} +
+ +
+ + ); +}