diff --git a/package-lock.json b/package-lock.json index dcc4547e..885ff84a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "react": "^18", "react-device-detect": "^2.2.3", "react-dom": "^18", - "recharts": "^2.12.7" + "recharts": "^2.12.7", + "semver": "^7.6.3" }, "devDependencies": { "@types/d3": "^7.4.3", @@ -26,6 +27,7 @@ "@types/node": "^22", "@types/react": "^18", "@types/react-dom": "^18", + "@types/semver": "^7.5.8", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "d3": "^7.9.0", @@ -1370,6 +1372,12 @@ "@types/react": "*" } }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -7733,8 +7741,6 @@ "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index ebfeb1c6..a2c31b61 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "react": "^18", "react-device-detect": "^2.2.3", "react-dom": "^18", - "recharts": "^2.12.7" + "recharts": "^2.12.7", + "semver": "^7.6.3" }, "devDependencies": { "@types/d3": "^7.4.3", @@ -27,6 +28,7 @@ "@types/node": "^22", "@types/react": "^18", "@types/react-dom": "^18", + "@types/semver": "^7.5.8", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "d3": "^7.9.0", diff --git a/src/app/downloads/config.tsx b/src/app/downloads/config.tsx index 58a75fe0..ff96eec7 100644 --- a/src/app/downloads/config.tsx +++ b/src/app/downloads/config.tsx @@ -14,9 +14,13 @@ import { SummaryStatistics } from "@/app/compatibility/avm2/report_utils"; export const repository = { owner: "ruffle-rs", repo: "ruffle" }; +export const maxMinor = 3; +export const maxMajor = 1; export const maxNightlies = 5; export const githubReleasesUrl = `https://github.com/${repository.owner}/${repository.repo}/releases`; +export const githubStableReleasesUrl = `${githubReleasesUrl}?q=prerelease:false`; +export const githubNightlyReleasesUrl = `${githubReleasesUrl}?q=prerelease:true`; export interface GithubRelease { id: number; @@ -24,6 +28,7 @@ export interface GithubRelease { prerelease: boolean; downloads: ReleaseDownloads; url: string; + tag: string; avm2_report_asset_id?: number; } diff --git a/src/app/downloads/github.tsx b/src/app/downloads/github.tsx index 97be2e5e..13c53d15 100644 --- a/src/app/downloads/github.tsx +++ b/src/app/downloads/github.tsx @@ -5,6 +5,8 @@ import { DownloadKey, FilenamePatterns, GithubRelease, + maxMajor, + maxMinor, maxNightlies, ReleaseDownloads, repository, @@ -12,6 +14,15 @@ import { import { Octokit } from "octokit"; import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; import { parse } from "node-html-parser"; +import semver from "semver/preload"; +import { components } from "@octokit/openapi-types"; + +const octokit = new Octokit({ authStrategy: createGithubAuth }); + +const requestCache = { + // Set cache to 30 min to prevent rate limiting during development + request: { next: { revalidate: 1800 } }, +}; function createGithubAuth() { if (process.env.GITHUB_TOKEN) { @@ -28,37 +39,65 @@ function throwBuildError() { throw new Error("Build failed"); } -export async function getLatestReleases(): Promise { - const octokit = new Octokit({ authStrategy: createGithubAuth }); +function mapRelease(release: components["schemas"]["release"]): GithubRelease { + const downloads: ReleaseDownloads = {}; + let avm2_report_asset_id: number | undefined = undefined; + for (const asset of release.assets) { + if (asset.name === "avm2_report.json") { + avm2_report_asset_id = asset.id; + } + for (const [key, pattern] of Object.entries(FilenamePatterns)) { + if (asset.name.indexOf(pattern) > -1) { + downloads[key as DownloadKey] = asset.browser_download_url; + } + } + } + + return { + id: release.id, + name: release.name || release.tag_name, + prerelease: release.prerelease, + url: release.html_url, + tag: release.tag_name, + downloads, + avm2_report_asset_id, + }; +} + +export async function getLatestRelease(): Promise { + try { + const response = await octokit.rest.repos.getLatestRelease({ + ...requestCache, + ...repository, + }); + return mapRelease(response.data); + } catch { + // There's no stable release, get the latest nightly. + } + + const releases = await octokit.rest.repos.listReleases({ + per_page: 1, + ...requestCache, + ...repository, + }); + return mapRelease(releases.data[0]); +} + +export async function getLatestNightlyReleases(): Promise { try { const releases = await octokit.rest.repos.listReleases({ - per_page: maxNightlies + 2, // more than we need to account for a possible draft release + possible full release - request: { next: { revalidate: 1800 } }, + // We have to take into account possible stable releases here + per_page: maxNightlies + 4, + ...requestCache, ...repository, }); const result = []; - let avm2_report_asset_id: number | undefined = undefined; for (const release of releases.data) { - const downloads: ReleaseDownloads = {}; - for (const asset of release.assets) { - if (asset.name === "avm2_report.json") { - avm2_report_asset_id = asset.id; - } - for (const [key, pattern] of Object.entries(FilenamePatterns)) { - if (asset.name.indexOf(pattern) > -1) { - downloads[key as DownloadKey] = asset.browser_download_url; - } - } + if (!release.prerelease) { + // Filter out stable releases + continue; } - - result.push({ - id: release.id, - name: release.name || release.tag_name, - prerelease: release.prerelease, - url: release.html_url, - downloads, - avm2_report_asset_id, - }); + result.push(mapRelease(release)); } return result; } catch (error) { @@ -67,14 +106,70 @@ export async function getLatestReleases(): Promise { } } +export async function getLatestStableReleases(): Promise { + let newestMajor = null; + + // Map representing releases from the current major version: + // `major.minor` -> `major.minor.patch` + // We want to ignore older patches and show last X minor versions. + const currentMajorReleases = new Map(); + + // Map representing releases from older major versions. + // `major` -> `major.minor.patch` + // We ignore here minor and patch versions, and + // gather the newest release per each major. + const olderMajors = new Map(); + + for ( + let page = 1; + currentMajorReleases.size < maxMinor || olderMajors.size < maxMajor - 1; + ++page + ) { + const request = await octokit.rest.repos.listReleases({ + // 100 per page disables cache as the result is >2MB + per_page: 80, + page: page, + ...requestCache, + ...repository, + }); + if (request.status != 200 || request.data.length == 0) { + break; + } + for (const data of request.data) { + if (data.prerelease) { + continue; + } + const release = mapRelease(data); + const version = release.tag.replace(/^v/, ""); + const major = semver.major(version); + const majorMinor = `${major}.${semver.minor(version)}`; + if (!newestMajor) { + newestMajor = major; + } + if (major === newestMajor) { + if (!currentMajorReleases.has(majorMinor)) { + currentMajorReleases.set(majorMinor, release); + } + } else { + if (!olderMajors.has(major)) { + olderMajors.set(major, release); + } + } + } + } + + return Array.from(currentMajorReleases.values()) + .slice(0, maxMinor) + .concat(Array.from(olderMajors.values()).slice(0, maxMajor - 1)); +} + export async function getWeeklyContributions(): Promise< RestEndpointMethodTypes["repos"]["getCommitActivityStats"]["response"] > { - const octokit = new Octokit({ authStrategy: createGithubAuth }); return octokit.rest.repos.getCommitActivityStats(repository); } export async function fetchReport(): Promise { - const releases = await getLatestReleases(); + const releases = await getLatestNightlyReleases(); const latest = releases.find( (release) => release.avm2_report_asset_id !== undefined, ); @@ -82,10 +177,8 @@ export async function fetchReport(): Promise { throwBuildError(); return; } - const octokit = new Octokit({ authStrategy: createGithubAuth }); const asset = await octokit.rest.repos.getReleaseAsset({ - owner: repository.owner, - repo: repository.repo, + ...repository, asset_id: latest.avm2_report_asset_id, headers: { accept: "application/octet-stream", @@ -100,10 +193,8 @@ export async function fetchReport(): Promise { } export async function getAVM1Progress(): Promise { - const octokit = new Octokit({ authStrategy: createGithubAuth }); const issues = await octokit.rest.issues.listForRepo({ - owner: repository.owner, - repo: repository.repo, + ...repository, labels: "avm1-tracking", state: "all", per_page: 65, diff --git a/src/app/downloads/page.tsx b/src/app/downloads/page.tsx index e2949ee7..3ea773f0 100644 --- a/src/app/downloads/page.tsx +++ b/src/app/downloads/page.tsx @@ -10,7 +10,7 @@ import { import classes from "./downloads.module.css"; import React from "react"; import { ExtensionList } from "@/app/downloads/extensions"; -import { NightlyList } from "@/app/downloads/nightlies"; +import { ReleaseList } from "@/app/downloads/releases"; import Link from "next/link"; import { desktopLinks, @@ -18,7 +18,10 @@ import { githubReleasesUrl, maxNightlies, } from "@/app/downloads/config"; -import { getLatestReleases } from "@/app/downloads/github"; +import { + getLatestNightlyReleases, + getLatestStableReleases, +} from "@/app/downloads/github"; function WebDownload({ latest }: { latest: GithubRelease | null }) { return ( @@ -94,19 +97,21 @@ function DesktopDownload({ latest }: { latest: GithubRelease | null }) { } export default async function Page() { - const releases = await getLatestReleases(); - const latest = releases.length > 0 ? releases[0] : null; - const nightlies = releases + const stableReleases = await getLatestStableReleases(); + const nightlies = (await getLatestNightlyReleases()) .filter((release) => release.prerelease) .slice(0, maxNightlies); + const latestStable = + stableReleases.length > 0 ? stableReleases[0] : nightlies[0]; return ( - - + + - + + ); diff --git a/src/app/downloads/nightlies.module.css b/src/app/downloads/releases.module.css similarity index 100% rename from src/app/downloads/nightlies.module.css rename to src/app/downloads/releases.module.css diff --git a/src/app/downloads/nightlies.tsx b/src/app/downloads/releases.tsx similarity index 62% rename from src/app/downloads/nightlies.tsx rename to src/app/downloads/releases.tsx index 8b789710..b95b2479 100644 --- a/src/app/downloads/nightlies.tsx +++ b/src/app/downloads/releases.tsx @@ -15,13 +15,14 @@ import { } from "@mantine/core"; import React from "react"; import Link from "next/link"; -import classes from "./nightlies.module.css"; +import classes from "./releases.module.css"; import { desktopLinks, type DownloadLink, extensionLinks, + githubNightlyReleasesUrl, type GithubRelease, - githubReleasesUrl, + githubStableReleasesUrl, webLinks, } from "@/app/downloads/config"; @@ -51,9 +52,9 @@ function DownloadLink({ ); } -function NightlyRow(release: GithubRelease) { - // The nightly prefix is a bit superfluous here - const name = release.name.replace(/^Nightly /, ""); +function ReleaseRow(release: GithubRelease) { + // The prefix is a bit superfluous here + const name = release.name.replace(/^Nightly /, "").replace(/^Release /, ""); return ( @@ -86,7 +87,7 @@ function NightlyRow(release: GithubRelease) { ); } -function NightlyCompactBox(release: GithubRelease) { +function ReleaseCompactBox(release: GithubRelease) { return ( <> @@ -114,24 +115,55 @@ function NightlyCompactBox(release: GithubRelease) { ); } -export function NightlyList({ nightlies }: { nightlies: GithubRelease[] }) { +function ReleaseIntro({ nightly }: { nightly: boolean }) { + if (!nightly) { + return ( + <> + Stable Releases + + If none of the above are suitable for you, you can manually download + one of the latest stable releases. Older versions are available on{" "} + + GitHub + + . + + + ); + } else { + return ( + <> + Nightly Releases + + If you want to try out the latest updates and cutting-edge features, + you can download the latest nightly release. These are automatically + built every day (approximately midnight UTC, unless there are no + changes on that day) and they offer early access to new enhancements, + bug fixes, and improvements before they're officially rolled out. + Older nightly releases are available on{" "} + + GitHub + + . + + + ); + } +} + +export function ReleaseList({ + releases, + nightly, +}: { + releases: GithubRelease[]; + nightly: boolean; +}) { + if (releases.length == 0) { + return <>; + } return ( - Nightly Releases - - If none of the above are suitable for you, you can manually download the - latest Nightly release. These are automatically built every day - (approximately midnight UTC), unless there are no changes on that day.{" "} - Older nightly releases are available on{" "} - - GitHub - - . - + - {nightlies.map((nightly) => ( - + {releases.map((release) => ( + ))}
{/*Compact mobile view, because a table is far too wide*/} - {nightlies.map((nightly) => ( - + {releases.map((release) => ( + ))}
diff --git a/src/app/page.tsx b/src/app/page.tsx index b188dd6d..16825ecd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -12,7 +12,7 @@ import { import Image from "next/image"; import { IconCheck } from "@tabler/icons-react"; import React from "react"; -import { getLatestReleases } from "@/app/downloads/github"; +import { getLatestRelease } from "@/app/downloads/github"; const InteractiveLogo = dynamic(() => import("../components/logo"), { ssr: false, @@ -23,9 +23,7 @@ const Installers = dynamic(() => import("./installers"), { }); export default async function Home() { - const releases = await getLatestReleases(); - const latest = releases.length > 0 ? releases[0] : null; - + const latest = await getLatestRelease(); return (