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 ( + +
+ + Skip to content + +
+ +
+
+

+ {downloads.version ? `Launcher ${downloads.version}` : "Launcher"} + {day ? ` · ${day}` : ""} +

+

+ Get the launcher +

+

+ Free and open source. Install it, sign in, and your friends are one click away. +

+
+ +
+
+ +
+

+ All downloads +

+

+ Every file goes straight to the release on GitHub. +

+ {!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. +

+ )} +
+ +
+
+
+

Check what you got

+

+ Every release lists SHA-256 checksums for its files. Updates inside the launcher are signed, so it only installs what we published. +

+

+ + SHA256SUMS.txt + + + Release notes + +

+
+
+

Read the code

+

+ The launcher is open source. Build it yourself, or see exactly what it does on your computer. +

+

+ + Launcher on GitHub + +

+
+
+
+ +
+
+

New in 0.3.5

+

+ Six looks, three of them new +

+

+ Pick one in Settings under Appearance, or on the first start. Ember, Tide and Daylight move; the pictures here stand still. +

+
+ +
+
    + {NEW.map((n) => ( +
  • +

    {n.title}

    +

    {n.text}

    +
  • + ))} +
+

Share play in the party room is on its way.

+
+
+ +
+
+

+ After the install +

+
    + {STEPS.map((s, i) => ( +
  1. + 0{i + 1} +

    {s.title}

    +

    {s.text}

    +
  2. + ))} +
+ +

+ 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 ( + + ); +} 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. +

+ + See all downloads + +
+ ); + } + + const row = rows.find((r) => r.os === os && r.main)!; + const size = megabytes(row.file.size); + const label = os === "windows" ? "Download for Windows" : os === "mac" ? "Download for Mac" : "Download for Linux"; + + return ( +
+
+ + + {label} + + + Other systems and files + +
+

+ {row.name} + {size ? ( + <> + {" · "} + {size} + + ) : null} + {downloads.version ? ( + <> + {" · version "} + {downloads.version} + + ) : null} +

+
+ ); +} diff --git a/apps/web/src/components/landing/download-button.tsx b/apps/web/src/components/landing/download-button.tsx index 5cf160e..23ab805 100644 --- a/apps/web/src/components/landing/download-button.tsx +++ b/apps/web/src/components/landing/download-button.tsx @@ -5,6 +5,7 @@ import { Menu } from "@anyplay/ui/menu"; import { ArrowDownTrayIcon, ArrowUpRightIcon, ChevronDownIcon } from "@heroicons/react/20/solid"; import { type ComponentType, useSyncExternalStore } from "react"; import { AppleLogo, LinuxLogo, WindowsLogo } from "./os-logos"; +import { LINKS } from "./links"; import type { Downloads } from "./release"; type Os = "windows" | "linux" | "mac" | "other"; @@ -13,8 +14,10 @@ type Os = "windows" | "linux" | "mac" | "other"; function detectOs(): Os { const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; const p = (nav.userAgentData?.platform || nav.userAgent || "").toLowerCase(); + // Phones and tablets get no launcher: the menu still lists every system for them. + if (/iphone|ipad|android/.test(p)) return "other"; if (p.includes("win")) return "windows"; - if (p.includes("mac") || p.includes("iphone") || p.includes("ipad")) return "mac"; + if (p.includes("mac")) return "mac"; if (p.includes("linux") || p.includes("x11") || p.includes("cros")) return "linux"; return "other"; } @@ -30,7 +33,7 @@ const noSubscription = () => () => {}; const LABEL: Record = { windows: "Download for Windows", linux: "Download for Linux", - mac: "Download", + mac: "Download for Mac", other: "Download", }; @@ -47,8 +50,7 @@ function megabytes(bytes?: number) { /** * The download button: it opens a menu with every system, the visitor's own marked, and each entry - * downloads the file straight away. Mac is listed as coming, so a Mac visitor learns that here - * instead of after a download. + * downloads the file straight away. */ export function DownloadMenu({ downloads, @@ -65,6 +67,7 @@ export function DownloadMenu({ { os: "windows", logo: WindowsLogo, name: "Windows", detail: "Installer, 64-bit", file: downloads.windows }, { os: "linux", logo: LinuxLogo, name: "Linux", detail: "AppImage, x86-64", file: downloads.appImage }, { os: "linux-tar", logo: LinuxLogo, name: "Linux", detail: "tar.gz archive, x86-64", file: downloads.tarball }, + { os: "mac", logo: AppleLogo, name: "macOS", detail: "Apple silicon, macOS 11 or newer", file: downloads.mac }, ] as const; return ( @@ -108,25 +111,19 @@ export function DownloadMenu({ ); })} - - - - - - macOS - Not available yet - - Soon - + + All downloads and checksums + + - Release notes and checksums + Release notes diff --git a/apps/web/src/components/landing/footer.tsx b/apps/web/src/components/landing/footer.tsx new file mode 100644 index 0000000..9b87bc7 --- /dev/null +++ b/apps/web/src/components/landing/footer.tsx @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { readSite } from "@/lib/site"; +import { Wordmark } from "./header"; +import { LINKS } from "./links"; + +export function Footer() { + const { discord } = readSite(); + return ( +
+
+
+ +

+ Open source on GitHub. Not affiliated with any console maker. +

+
+ + + + +
+
+ ); +} + +function FooterColumn({ title, links }: { title: string; links: [string, string][] }) { + return ( +
+

{title}

+ +
+ ); +} 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