From 867bd016bbac63973c63aaddf67cc6a0052bcb13 Mon Sep 17 00:00:00 2001
From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com>
Date: Tue, 27 Oct 2026 20:26:46 +0100
Subject: [PATCH 1/2] Download page: one button for your system, every release
file with its size, the six looks, what 0.3.5 brings; Mac is a real download
---
apps/web/e2e/download.spec.ts | 35 ++++
apps/web/eslint.config.mjs | 2 +-
apps/web/src/app/download/page.tsx | 21 ++
apps/web/src/components/account/pages.tsx | 8 +-
.../src/components/download/download-page.tsx | 187 ++++++++++++++++++
.../web/src/components/download/file-list.tsx | 58 ++++++
.../web/src/components/download/files.test.ts | 18 ++
apps/web/src/components/download/files.ts | 51 +++++
apps/web/src/components/download/looks.tsx | 43 ++++
apps/web/src/components/download/primary.tsx | 64 ++++++
.../components/landing/download-button.tsx | 33 ++--
apps/web/src/components/landing/footer.tsx | 71 +++++++
apps/web/src/components/landing/header.tsx | 6 +-
apps/web/src/components/landing/landing.tsx | 74 +------
apps/web/src/components/landing/links.ts | 2 +
apps/web/src/components/landing/pills.ts | 10 +
.../src/components/landing/release.test.ts | 50 +++++
apps/web/src/components/landing/release.ts | 80 +++++---
18 files changed, 690 insertions(+), 123 deletions(-)
create mode 100644 apps/web/e2e/download.spec.ts
create mode 100644 apps/web/src/app/download/page.tsx
create mode 100644 apps/web/src/components/download/download-page.tsx
create mode 100644 apps/web/src/components/download/file-list.tsx
create mode 100644 apps/web/src/components/download/files.test.ts
create mode 100644 apps/web/src/components/download/files.ts
create mode 100644 apps/web/src/components/download/looks.tsx
create mode 100644 apps/web/src/components/download/primary.tsx
create mode 100644 apps/web/src/components/landing/footer.tsx
create mode 100644 apps/web/src/components/landing/pills.ts
create mode 100644 apps/web/src/components/landing/release.test.ts
diff --git a/apps/web/e2e/download.spec.ts b/apps/web/e2e/download.spec.ts
new file mode 100644
index 0000000..ff74288
--- /dev/null
+++ b/apps/web/e2e/download.spec.ts
@@ -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();
+});
diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs
index c28ca7a..8a0cb85 100644
--- a/apps/web/eslint.config.mjs
+++ b/apps/web/eslint.config.mjs
@@ -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,
},
]);
diff --git a/apps/web/src/app/download/page.tsx b/apps/web/src/app/download/page.tsx
new file mode 100644
index 0000000..ed51b24
--- /dev/null
+++ b/apps/web/src/app/download/page.tsx
@@ -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 ;
+}
diff --git a/apps/web/src/components/account/pages.tsx b/apps/web/src/components/account/pages.tsx
index 79fc3e0..c759905 100644
--- a/apps/web/src/components/account/pages.tsx
+++ b/apps/web/src/components/account/pages.tsx
@@ -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";
@@ -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 = { everyone: "Everyone", friends: "Friends", nobody: "Only you" };
diff --git a/apps/web/src/components/download/download-page.tsx b/apps/web/src/components/download/download-page.tsx
new file mode 100644
index 0000000..b567644
--- /dev/null
+++ b/apps/web/src/components/download/download-page.tsx
@@ -0,0 +1,187 @@
+// 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 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);
+
+ return (
+
+
+ AnyPlay does not include or provide games. You play the games you own, run through AnyPS5, a compatibility layer and never an emulator.
+ See how games run.
+
+
+
+
+
+
+
+
+ );
+}
+
+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";
diff --git a/apps/web/src/components/download/file-list.tsx b/apps/web/src/components/download/file-list.tsx
new file mode 100644
index 0000000..64678dc
--- /dev/null
+++ b/apps/web/src/components/download/file-list.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/web/src/components/download/files.test.ts b/apps/web/src/components/download/files.test.ts
new file mode 100644
index 0000000..8ad24f1
--- /dev/null
+++ b/apps/web/src/components/download/files.test.ts
@@ -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();
+ });
+});
diff --git a/apps/web/src/components/download/files.ts b/apps/web/src/components/download/files.ts
new file mode 100644
index 0000000..c423733
--- /dev/null
+++ b/apps/web/src/components/download/files.ts
@@ -0,0 +1,51 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import type { ComponentType } from "react";
+import { AppleLogo, LinuxLogo, WindowsLogo } from "../landing/os-logos";
+import type { Download, Downloads } from "../landing/release";
+
+export type Os = "windows" | "linux" | "mac";
+
+export type FileRow = {
+ id: string;
+ os: Os;
+ logo: ComponentType<{ className?: string }>;
+ name: string;
+ detail: string;
+ file: Download;
+ /** The one a visitor on this system is sent to first. */
+ main?: boolean;
+ note?: string;
+};
+
+/** Every file of a release. The page puts the visitor's own system first. */
+export function fileRows(d: Downloads): FileRow[] {
+ return [
+ { id: "windows", os: "windows", logo: WindowsLogo, name: "Windows installer", detail: "64-bit. No administrator rights needed.", file: d.windows, main: true },
+ { id: "windows-portable", os: "windows", logo: WindowsLogo, name: "Windows, one file", detail: "64-bit. Runs without installing.", file: d.windowsPortable },
+ {
+ id: "mac",
+ os: "mac",
+ logo: AppleLogo,
+ name: "macOS",
+ detail: "Apple silicon, macOS 11 or newer.",
+ file: d.mac,
+ main: true,
+ note: "If macOS says the developer cannot be verified, open System Settings > Privacy & Security and choose Open Anyway.",
+ },
+ { id: "appimage", os: "linux", logo: LinuxLogo, name: "Linux AppImage", detail: "x86-64. Make it executable and run it.", file: d.appImage, main: true },
+ { id: "flatpak", os: "linux", logo: LinuxLogo, name: "Linux Flatpak", detail: "x86-64.", file: d.flatpak },
+ { id: "tarball", os: "linux", logo: LinuxLogo, name: "Linux archive", detail: "x86-64. A tar.gz to unpack anywhere.", file: d.tarball },
+ ];
+}
+
+export function megabytes(bytes?: number) {
+ return bytes ? `${(bytes / 1_000_000).toFixed(1)} MB` : undefined;
+}
+
+/** "27 Oct 2026" from an ISO day, in UTC so the server and the browser agree. */
+export function dayLabel(iso?: string) {
+ if (!iso) return undefined;
+ const d = new Date(`${iso}T00:00:00Z`);
+ if (Number.isNaN(d.getTime())) return undefined;
+ return d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" });
+}
diff --git a/apps/web/src/components/download/looks.tsx b/apps/web/src/components/download/looks.tsx
new file mode 100644
index 0000000..f6b58d7
--- /dev/null
+++ b/apps/web/src/components/download/looks.tsx
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+/**
+ * The launcher's six looks, painted from the same three tones the launcher's background shader
+ * blends (deep, middle, bright; ui/theme.rs, converted to hex). A still gradient stands for each:
+ * the page stays calm, and the words say which of them move.
+ */
+type Look = { name: string; text: string; isNew?: boolean; bg: string };
+
+const tone = (deep: string, middle: string, bright: string, glow = "78% 0%") =>
+ `radial-gradient(110% 90% at ${glow}, ${bright} 0%, transparent 58%), linear-gradient(165deg, ${middle}, ${deep})`;
+
+export const LOOKS: Look[] = [
+ { name: "Cosmic", text: "Deep space blue.", bg: tone("#030617", "#0a266b", "#6ba3ff") },
+ { name: "Aurora", text: "Dark water, mint light.", bg: tone("#02080b", "#054d4d", "#8fffc7") },
+ { name: "Sunset", text: "Plum into amber.", bg: tone("#0f030a", "#851c38", "#ffa354") },
+ { name: "Ember", text: "A low hearth in charcoal and copper. Embers rise and fade.", isNew: true, bg: tone("#080302", "#401608", "#ff8f2b", "50% 115%") },
+ { name: "Tide", text: "Deep petrol water with light from above.", isNew: true, bg: tone("#010509", "#03304d", "#7aedff") },
+ { name: "Daylight", text: "The first light look. Soft sky, dark text, drifting clouds.", isNew: true, bg: tone("#a3bfe3", "#d4e3f2", "#fff2d1", "50% 115%") },
+];
+
+export function Looks() {
+ return (
+
+ {LOOKS.map((l) => (
+
+
+
+ {l.name}
+ {l.isNew ? (
+ New in 0.3.5
+ ) : null}
+
+
{l.text}
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/download/primary.tsx b/apps/web/src/components/download/primary.tsx
new file mode 100644
index 0000000..3628a04
--- /dev/null
+++ b/apps/web/src/components/download/primary.tsx
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+"use client";
+
+import { ArrowDownTrayIcon } from "@heroicons/react/20/solid";
+import { useOs } from "../landing/download-button";
+import { pillGlass, pillLight } from "../landing/pills";
+import type { Downloads } from "../landing/release";
+import { fileRows, megabytes } from "./files";
+
+/**
+ * The one button of the page, for the visitor's own system. The first render answers for Windows
+ * (the server cannot know), and a Mac or Linux visitor sees it change once, at once. A phone or a
+ * tablet gets no button: the launcher is for computers, and saying so beats a download that cannot run.
+ */
+export function PrimaryDownload({ downloads }: { downloads: Downloads }) {
+ const os = useOs();
+ const rows = fileRows(downloads);
+
+ if (os === "other") {
+ return (
+
+
+ The launcher runs on a computer. Open this page there, or pick the system you will use below.
+
+ );
+}
diff --git a/apps/web/src/components/landing/header.tsx b/apps/web/src/components/landing/header.tsx
index 7c9f1ef..0c0a908 100644
--- a/apps/web/src/components/landing/header.tsx
+++ b/apps/web/src/components/landing/header.tsx
@@ -5,6 +5,7 @@ import { Menu } from "@anyplay/ui/menu";
import { useMotionValueEvent, useScroll } from "@anyplay/ui/motion";
import { Bars3Icon } from "@heroicons/react/20/solid";
import Link from "next/link";
+import { usePathname } from "next/navigation";
import { useState } from "react";
import { useSite } from "../site-context";
import { DownloadMenu } from "./download-button";
@@ -31,6 +32,7 @@ export function Header({ pill, downloads }: { pill: string; downloads: Downloads
const { discord } = useSite();
const { scrollY } = useScroll();
const [scrolled, setScrolled] = useState(false);
+ const onDownload = usePathname() === LINKS.downloadPage;
useMotionValueEvent(scrollY, "change", (y) => setScrolled(y > 24));
return (
@@ -44,7 +46,7 @@ export function Header({ pill, downloads }: { pill: string; downloads: Downloads
{!downloads.live && (
GitHub did not answer just now, so sizes and the version are missing. These buttons open the latest release, where every file is listed.
@@ -123,15 +126,15 @@ export async function DownloadPage() {
-
New in 0.3.5
+ {current ?
New in {RELEASE_OF_THESE_NOTES}
: null}
- Six looks, three of them new
+ {current ? "Six looks, three of them new" : "Six looks"}
Pick one in Settings under Appearance, or on the first start. Ember, Tide and Daylight move; the pictures here stand still.
@@ -170,7 +172,7 @@ export async function DownloadPage() {
)}
- AnyPlay does not include or provide games. You play the games you own, run through AnyPS5, a compatibility layer and never an emulator.
+ AnyPlay does not include or provide games. You play the games you own, run through AnyPS5, a compatibility layer, never an emulator.
See how games run.