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
This commit is contained in:
Mohamed Boudra
2026-10-02 15:35:45 +01:00
committed by GitHub
parent 1d0df5c0b7
commit 6a794b6572
8 changed files with 250 additions and 14 deletions
@@ -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);
});
@@ -66,3 +66,17 @@ export function BuyMeACoffeeIcon(props: React.SVGProps<SVGSVGElement>) {
</svg>
);
}
export function RedditIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z" />
</svg>
);
}
@@ -994,7 +994,7 @@ function DesktopDownloadButton({ platform }: { platform: DesktopPlatform }) {
const download = getDesktopDownload(useRelease(), platform);
const Icon = download.icon;
return (
<a href={download.href} target="_blank" rel="noopener noreferrer" className={PRIMARY_CTA_CLASS}>
<a href={download.href} className={PRIMARY_CTA_CLASS}>
<Icon className="h-4 w-4" />
Download for {download.label}
</a>
+36
View File
@@ -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();
});
});
+24 -3
View File
@@ -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 };
}
}
+21
View File
@@ -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)
+12 -10
View File
@@ -8,6 +8,7 @@ import {
downloadUrls,
appStoreUrl,
playStoreUrl,
thanksPageHref,
webAppUrl,
AppleIcon,
AndroidIcon,
@@ -88,8 +89,8 @@ function Download() {
<PlatformRow icon={AppleIcon} label="macOS">
<div className="flex flex-col items-start gap-2 sm:items-end">
<PillGroup>
<DownloadPill href={urls.macAppleSilicon} label="Apple Silicon" />
<DownloadPill href={urls.macIntel} label="Intel" />
<DownloadPill href={thanksPageHref(urls.macAppleSilicon)} label="Apple Silicon" />
<DownloadPill href={thanksPageHref(urls.macIntel)} label="Intel" />
</PillGroup>
<span className="text-xs text-muted-foreground">Requires macOS 13 or newer</span>
</div>
@@ -104,18 +105,20 @@ function Download() {
<PlatformRow icon={WindowsIcon} label="Windows">
<PillGroup>
<DownloadPill
href={urls.windowsExeX64}
href={thanksPageHref(urls.windowsExeX64)}
label={urls.windowsExeArm64 ? "Intel / x64" : "Download"}
/>
{urls.windowsExeArm64 && <DownloadPill href={urls.windowsExeArm64} label="ARM64" />}
{urls.windowsExeArm64 && (
<DownloadPill href={thanksPageHref(urls.windowsExeArm64)} label="ARM64" />
)}
</PillGroup>
</PlatformRow>
<PlatformRow icon={LinuxIcon} label="Linux">
<PillGroup>
<DownloadPill href={urls.linuxAppImage} label="AppImage" />
<DownloadPill href={urls.linuxDeb} label="DEB" />
<DownloadPill href={urls.linuxRpm} label="RPM" />
<DownloadPill href={thanksPageHref(urls.linuxAppImage)} label="AppImage" />
<DownloadPill href={thanksPageHref(urls.linuxDeb)} label="DEB" />
<DownloadPill href={thanksPageHref(urls.linuxRpm)} label="RPM" />
</PillGroup>
</PlatformRow>
</div>
@@ -132,7 +135,7 @@ function Download() {
<PlatformRow icon={AndroidIcon} label="Android">
<PillGroup>
{!onBeta && <DownloadPill href={playStoreUrl} label="Play Store" external />}
<DownloadPill href={urls.androidApk} label="APK" />
<DownloadPill href={thanksPageHref(urls.androidApk)} label="APK" />
</PillGroup>
</PlatformRow>
@@ -304,8 +307,7 @@ function DownloadPill({
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="inline-flex items-center justify-center rounded-full bg-foreground px-4 py-1.5 text-sm font-medium text-background hover:bg-foreground/85 transition-colors"
>
{label}
@@ -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<string, unknown>): 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: <DiscordIcon className="h-6 w-6" />,
body: "Get help and share feedback.",
href: "https://discord.gg/jz8T2uahpH",
},
{
name: "GitHub",
icon: <GitHubIcon className="h-6 w-6" />,
body: "Star, report issues, contribute.",
href: "https://github.com/getpaseo/paseo",
},
{
name: "Reddit",
icon: <RedditIcon className="h-6 w-6" />,
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 (
<SiteShell width="default">
<h1 className="text-3xl font-medium tracking-tight mb-4">Thanks for downloading Paseo</h1>
<p className="text-lg text-white/70 leading-relaxed max-w-2xl">
Get help, share feedback, and follow along.
</p>
{file && (
<p className="text-white/40 text-sm mt-3">
Download didn&apos;t start?{" "}
<a href={file} className="underline hover:text-white/80">
Try again
</a>
</p>
)}
<div className="space-y-20 mt-16">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{COMMUNITY.map((item) => (
<a
key={item.name}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4 transition-colors hover:border-white/20 hover:bg-white/[0.05]"
>
<span className="flex text-white/80">{item.icon}</span>
<span className="min-w-0">
<span className="block font-medium [text-box:trim-start_cap_alphabetic]">
{item.name}
</span>
<span className="block text-xs text-muted-foreground">{item.body}</span>
</span>
</a>
))}
</div>
<SponsorSection />
</div>
</SiteShell>
);
}