Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/web/e2e/download.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

// The page asks GitHub for the latest release. Whether GitHub answers or not, every download must be
// a real link, so these checks hold in both cases.

test("the download page offers every system and has no accessibility violations", async ({ page }) => {
await page.goto("/download");
await expect(page.getByRole("heading", { level: 1, name: "Get the launcher" })).toBeVisible();
const files = page.locator("#files");
for (const name of ["Windows installer", "macOS", "Linux AppImage"]) {
await expect(files.getByRole("heading", { name })).toBeVisible();
}
const links = files.getByRole("link", { name: /^Download / });
expect(await links.count()).toBe(6);
for (const href of await links.evaluateAll((els) => els.map((e) => (e as HTMLAnchorElement).href))) {
expect(href).toMatch(/^https:\/\/github\.com\/anyplaynet\/launcher\/releases\//);
}
const { violations } = await new AxeBuilder({ page }).disableRules(["color-contrast"]).analyze();
expect(violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBe(0);
});

test("a phone is told the launcher is for computers instead of being offered a download", async ({ browser }) => {
const context = await browser.newContext({
viewport: { width: 375, height: 800 },
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148",
});
const page = await context.newPage();
await page.goto("/download");
await expect(page.getByText("The launcher runs on a computer.")).toBeVisible();
await expect(page.getByRole("link", { name: "Download for Windows" })).toHaveCount(0);
await context.close();
});
2 changes: 1 addition & 1 deletion apps/web/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default defineConfig([
rules: anyplayRules,
},
{
files: ["src/components/landing/**/*.{ts,tsx}", "src/components/auth/**/*.{ts,tsx}", "src/components/account/**/*.{ts,tsx}", "src/components/legal/**/*.{ts,tsx}", "src/components/site/status-screen.tsx", "src/components/site/error-screen.tsx"],
files: ["src/components/landing/**/*.{ts,tsx}", "src/components/download/**/*.{ts,tsx}", "src/components/auth/**/*.{ts,tsx}", "src/components/account/**/*.{ts,tsx}", "src/components/legal/**/*.{ts,tsx}", "src/components/site/status-screen.tsx", "src/components/site/error-screen.tsx"],
rules: marketingRules,
},
]);
21 changes: 21 additions & 0 deletions apps/web/src/app/download/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { Metadata } from "next";
import { connection } from "next/server";
import { DownloadPage } from "@/components/download/download-page";

const title = "Download the launcher";
const description =
"The AnyPlay launcher for Windows, Linux and Mac. Free and open source. Install it, sign in, and your friends are one click away.";

export const metadata: Metadata = {
title,
description,
openGraph: { type: "website", siteName: "AnyPlay", title: `${title} · AnyPlay`, description, url: "/download" },
};

export default async function Page() {
// Per request, so Next's inline scripts carry the nonce of the script policy (src/proxy.ts). The
// release lookup inside stays cached for 15 minutes.
await connection();
return <DownloadPage />;
}
8 changes: 2 additions & 6 deletions apps/web/src/components/account/pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { type FormEvent, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { CodeField, FormError, TextField } from "../auth/fields";
import { DownloadMenu } from "../landing/download-button";
import { FALLBACK } from "../landing/release";
import { DitherField } from "../landing/shaders";
import { useSite } from "../site-context";
import { LevelMeter } from "./level";
Expand Down Expand Up @@ -182,12 +183,7 @@ export function OverviewPage() {
);
}

const DOWNLOADS = {
windows: { href: "https://github.com/anyplaynet/launcher/releases/latest" },
appImage: { href: "https://github.com/anyplaynet/launcher/releases/latest" },
tarball: { href: "https://github.com/anyplaynet/launcher/releases/latest" },
page: "https://github.com/anyplaynet/launcher/releases/latest",
};
const DOWNLOADS = FALLBACK;

const AUDIENCE_WORD: Record<Audience, string> = { everyone: "Everyone", friends: "Friends", nobody: "Only you" };

Expand Down
189 changes: 189 additions & 0 deletions apps/web/src/components/download/download-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { readSite } from "@/lib/site";
import { Footer } from "../landing/footer";
import { Header } from "../landing/header";
import { LINKS } from "../landing/links";
import { pillGlass, pillSmall } from "../landing/pills";
import { LandingProviders } from "../landing/providers";
import { latestDownloads } from "../landing/release";
import { FileList } from "./file-list";
import { dayLabel } from "./files";
import { Looks } from "./looks";
import { PrimaryDownload } from "./primary";

/**
* The download page: the launcher's current release as it is on GitHub. One button for the visitor's
* system, every file with its size under it, how to check them, then what 0.3.5 brings and what
* happens after the install. Nothing here is a screenshot or a claim the release does not make.
*/

const RELEASE_OF_THESE_NOTES = "0.3.5";

const NEW = [
{
title: "Played with recently",
text: "Friends lists the people you shared a party or a game with in the last 30 days, so adding one takes a single step. Only you see it, you can turn it off or clear it any time, and people you block never show up.",
},
{
title: "A calmer language choice",
text: "Choosing a language is a plain list of names. The language you point at shows the page in it right away. The launcher speaks English, Türkçe, Deutsch, Español, Français and Português.",
},
{
title: "Still when you ask",
text: "With Animations set to Reduced, every look holds still and loading indicators pulse gently instead of spinning.",
},
] as const;

const STEPS = [
{ title: "Install and open the launcher", text: "From then on it offers updates by itself, on Windows, Linux and the Mac." },
{ title: "Sign in", text: "The launcher opens your browser. Make an account or sign in, and you are back in the launcher, online." },
{ title: "Add your friends", text: "Send a request by name. Once they accept, you see when they are online and what they play." },
] as const;

export async function DownloadPage() {
const { discord } = readSite();
const downloads = await latestDownloads();
const day = dayLabel(downloads.published);
// The "new" wording is written for 0.3.5; once a newer release is out it must not claim to be news.
const current = !downloads.live || downloads.version === RELEASE_OF_THESE_NOTES;

return (
<LandingProviders>
<div className="night min-h-dvh overflow-x-clip bg-bg text-ink">
<a
href="#main"
className="fixed top-3 left-3 z-50 -translate-y-20 rounded-full bg-[#f6f5f1] px-4 py-2 text-[14px] font-semibold text-[#161513] transition-transform duration-[var(--duration-quick)] focus-visible:translate-y-0"
>
Skip to content
</a>
<Header pill={pillSmall} downloads={downloads} />

<main id="main" tabIndex={-1} className="outline-none">
<section className="mx-auto w-full max-w-[1160px] px-[var(--gutter)] pt-[clamp(8rem,13vw,11rem)] pb-[clamp(3.5rem,7vw,6rem)]">
<p className="reveal font-mono text-[13px] tracking-[0.04em] text-brand-text uppercase">
{downloads.version ? `Launcher ${downloads.version}` : "Launcher"}
{day ? ` · ${day}` : ""}
</p>
<h1 className="display reveal mt-5 max-w-[14ch] text-[clamp(2.5rem,1.2rem+5vw,5rem)] leading-[1.04] text-balance" style={{ ["--d" as string]: "60ms" }}>
Get the launcher
</h1>
<p
className="reveal mt-6 max-w-[34rem] text-[clamp(1.0625rem,0.95rem+0.4vw,1.25rem)] leading-[1.6] text-ink-2 text-pretty"
style={{ ["--d" as string]: "120ms" }}
>
Free and open source. Install it, sign in, and your friends are one click away.
</p>
<div className="reveal mt-10" style={{ ["--d" as string]: "180ms" }}>
<PrimaryDownload downloads={downloads} />
</div>
</section>

<section id="files" aria-labelledby="files-title" className="mx-auto scroll-mt-28 w-full max-w-[1160px] px-[var(--gutter)] pb-[clamp(4rem,8vw,7rem)]">
<h2 id="files-title" className="display text-[clamp(1.75rem,1.2rem+2vw,2.75rem)] leading-[1.06]">
All downloads
</h2>
<p className="mt-4 max-w-[34rem] text-[17px] leading-[1.6] text-ink-2 text-pretty">
Every file goes straight to the release on GitHub.
</p>
{!downloads.live && (
<p
className="mt-6 max-w-[34rem] rounded-[16px] bg-surface-2 px-4 py-3 text-[14px] leading-[1.55] text-ink-2 shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08)] text-pretty"
>
GitHub did not answer just now, so sizes and the version are missing. These buttons open the latest release, where every file is listed.
</p>
)}
<div className="mt-8">
<FileList downloads={downloads} />
</div>
<div className="mt-8 grid gap-x-10 gap-y-6 sm:grid-cols-2">
<div>
<h3 className="text-[16px] font-semibold">Check what you got</h3>
<p className="mt-1.5 text-[14px] leading-[1.6] text-ink-2 text-pretty">
Every release lists SHA-256 checksums for its files. Updates inside the launcher are signed, so it only installs what we published.
</p>
<p className="mt-3 flex flex-wrap gap-x-6">
<a href={downloads.checksums.href} className={textLink}>
SHA256SUMS.txt
</a>
<a href={downloads.page} className={textLink}>
Release notes
</a>
</p>
</div>
<div>
<h3 className="text-[16px] font-semibold">Read the code</h3>
<p className="mt-1.5 text-[14px] leading-[1.6] text-ink-2 text-pretty">
The launcher is open source. Build it yourself, or see exactly what it does on your computer.
</p>
<p className="mt-3">
<a href={LINKS.launcherCode} className={textLink}>
Launcher on GitHub
</a>
</p>
</div>
</div>
</section>

<section aria-labelledby="new-title" className="border-t border-line">
<div className="mx-auto w-full max-w-[1160px] px-[var(--gutter)] py-[clamp(4rem,8vw,7rem)]">
{current ? <p className="font-mono text-[13px] tracking-[0.04em] text-brand-text uppercase">New in {RELEASE_OF_THESE_NOTES}</p> : null}
<h2 id="new-title" className="display mt-4 max-w-[16ch] text-[clamp(1.75rem,1.2rem+2.4vw,3.25rem)] leading-[1.06] text-balance">
{current ? "Six looks, three of them new" : "Six looks"}
</h2>
<p className="mt-4 max-w-[34rem] text-[17px] leading-[1.6] text-ink-2 text-pretty">
Pick one in Settings under Appearance, or on the first start. Ember, Tide and Daylight move; the pictures here stand still.
</p>
<div className="mt-10">
<Looks showNew={current} />
</div>
<ul className="mt-16 grid gap-x-10 gap-y-8 md:grid-cols-3">
{NEW.map((n) => (
<li key={n.title} className="border-t border-line-strong/40 pt-5">
<h3 className="text-[16px] font-semibold">{n.title}</h3>
<p className="mt-2 text-[14px] leading-[1.6] text-ink-2 text-pretty">{n.text}</p>
</li>
))}
</ul>
</div>
</section>

<section aria-labelledby="after-title" className="border-t border-line">
<div className="mx-auto w-full max-w-[1160px] px-[var(--gutter)] py-[clamp(4rem,8vw,7rem)]">
<h2 id="after-title" className="display max-w-[16ch] text-[clamp(1.75rem,1.2rem+2.4vw,3.25rem)] leading-[1.06] text-balance">
After the install
</h2>
<ol className="mt-10 grid gap-x-10 gap-y-8 md:grid-cols-3">
{STEPS.map((s, i) => (
<li key={s.title} className="border-t border-line-strong/40 pt-5">
<span className="tnum font-mono text-[13px] text-brand-text">0{i + 1}</span>
<h3 className="mt-2 text-[16px] font-semibold">{s.title}</h3>
<p className="mt-2 text-[14px] leading-[1.6] text-ink-2 text-pretty">{s.text}</p>
</li>
))}
</ol>
<div className="mt-10 flex flex-wrap gap-3">
<a href={LINKS.createAccount} className={pillGlass}>
Create an account
</a>
{discord && (
<a href={discord} className={pillGlass}>
Join the Discord
</a>
)}
</div>
<p className="mt-12 max-w-[40rem] text-[14px] leading-[1.6] text-ink-3 text-pretty">
AnyPlay does not include or provide games. You play the games you own, run through AnyPS5, a compatibility layer, never an emulator.
See <a href={LINKS.compatibility} className={textLink}>how games run</a>.
</p>
</div>
</section>
</main>

<Footer />
</div>
</LandingProviders>
);
}

const textLink =
"inline-flex min-h-11 items-center text-[14px] font-medium text-ink underline decoration-ink-3 underline-offset-4 [text-decoration-skip-ink:auto] transition-colors duration-[var(--duration-quick)] [@media(hover:hover)]:hover:decoration-ink";
58 changes: 58 additions & 0 deletions apps/web/src/components/download/file-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: AGPL-3.0-only
"use client";

import { ArrowDownTrayIcon } from "@heroicons/react/20/solid";
import { useOs } from "../landing/download-button";
import type { Downloads } from "../landing/release";
import { fileRows, megabytes } from "./files";

const button =
"inline-flex min-h-11 items-center justify-center gap-2 rounded-full bg-white/[0.06] px-5 text-[14px] font-semibold whitespace-nowrap text-ink shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12)] select-none transition-[background-color,scale] duration-[var(--duration-quick)] ease-[var(--ease-out)] active:scale-[0.96] active:duration-[var(--duration-press)] max-sm:w-full [@media(hover:hover)]:hover:bg-white/[0.1]";

/**
* Every file of the release as one ledger: what it is, how big, one button. The visitor's own
* system goes first and is marked in words. Each row is a plain link to the file on GitHub.
*/
export function FileList({ downloads }: { downloads: Downloads }) {
const os = useOs();
const rows = fileRows(downloads);
// The visitor's system first; the order inside a system and between the others stays as written.
const ordered = os === "other" ? rows : [...rows.filter((r) => r.os === os), ...rows.filter((r) => r.os !== os)];

return (
<ul className="divide-y divide-line overflow-hidden rounded-[24px] bg-surface shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08)]">
{ordered.map((r) => {
const size = megabytes(r.file.size);
return (
<li key={r.id} className="grid gap-4 p-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center sm:gap-6 sm:px-6 sm:py-5">
<div className="grid grid-cols-[40px_minmax(0,1fr)] gap-4">
<span className="grid size-10 place-items-center rounded-[12px] bg-white/[0.06] shadow-[inset_0_0_0_1px_rgb(255_255_255/0.06)]">
<r.logo aria-hidden className="size-[18px]" />
</span>
<div className="min-w-0">
<h3 className="flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[16px] leading-[1.3] font-semibold">
{r.name}
{r.os === os && r.main ? (
<span className="rounded-full bg-brand-wash px-2 py-0.5 text-[12px] leading-[1.3] font-semibold text-brand-text">Your system</span>
) : null}
</h3>
<p className="mt-1 text-[14px] leading-[1.5] text-ink-2 text-pretty">{r.detail}</p>
{r.note ? <p className="mt-2 max-w-[34rem] text-[14px] leading-[1.55] text-ink-3 text-pretty">{r.note}</p> : null}
</div>
</div>
<div className="flex items-center gap-4 sm:justify-end">
{size ? <span className="tnum font-mono text-[13px] text-ink-3 max-sm:hidden">{size}</span> : null}
<a href={r.file.href} download className={button}>
<ArrowDownTrayIcon aria-hidden className="size-4" />
<span>
Download<span className="sr-only"> {r.name}{size ? `, ${size}` : ""}</span>
</span>
{size ? <span aria-hidden className="tnum font-mono text-[12px] font-normal text-ink-3 sm:hidden">{size}</span> : null}
</a>
</div>
</li>
);
})}
</ul>
);
}
18 changes: 18 additions & 0 deletions apps/web/src/components/download/files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { describe, expect, it } from "vitest";
import { FALLBACK } from "../landing/release";
import { dayLabel, fileRows, megabytes } from "./files";

describe("download files", () => {
it("offers one main file per system", () => {
const mains = fileRows(FALLBACK).filter((r) => r.main).map((r) => r.os);
expect(mains.sort()).toEqual(["linux", "mac", "windows"]);
});
it("formats sizes and days, and says nothing when it does not know", () => {
expect(megabytes(20_261_577)).toBe("20.3 MB");
expect(megabytes(undefined)).toBeUndefined();
expect(dayLabel("2026-10-27")).toBe("27 Oct 2026");
expect(dayLabel("nonsense")).toBeUndefined();
expect(dayLabel(undefined)).toBeUndefined();
});
});
Loading
Loading