From 6a794b6572ee47434db2254c62e4db57b80c94ac Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 2 Oct 2026 15:35:45 +0100 Subject: [PATCH] Show a thanks page with community and sponsor links after downloading (#5914) * feat(website): add a thanks page after downloading Desktop and APK download buttons on /download and the homepage now open /download/thanks, which starts the download and points people to Discord, GitHub, Reddit, and sponsoring while it runs. The button carries the asset name rather than the file URL, so the page resolves the URL from the release data itself and cannot be pointed at an arbitrary link. * fix(website): start the exact file the visitor clicked on the thanks page The thanks page looked the file up again from an asset key and channel, so it could start a different release than the button showed (a release published in between, or a beta link without a beta). Download links now carry the release file URL itself; the thanks page accepts only URLs under this repo's GitHub releases, after URL parsing normalizes `..` segments. The e2e test waits for the file request instead of a download event, because WebKit reports no download event for a stubbed response. * test(website): assert the thanks-page download event outside WebKit * test(website): split the thanks-page download check out of the shared test --- packages/website/e2e/download-thanks.spec.ts | 47 +++++++++ .../website/src/components/brand-icons.tsx | 14 +++ .../website/src/components/landing-page.tsx | 2 +- packages/website/src/downloads.test.ts | 36 +++++++ packages/website/src/downloads.tsx | 27 +++++- packages/website/src/routeTree.gen.ts | 21 ++++ packages/website/src/routes/download.tsx | 22 +++-- .../website/src/routes/download_.thanks.tsx | 95 +++++++++++++++++++ 8 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 packages/website/e2e/download-thanks.spec.ts create mode 100644 packages/website/src/downloads.test.ts create mode 100644 packages/website/src/routes/download_.thanks.tsx 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} + + + ))} +
+ +
+ + ); +}