From 1bc84b46ffc68dbaffdb1d4572915de2439c7539 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:43:22 +0800 Subject: [PATCH] Show download sizes next to the installers and binaries The Lite install section lists each installer's size beside the version and checksum (the disk image; both Windows installers; both AppImages), the home and Lite hero download button adds the size of the file it picked for the visitor to its version line, and the Core page lists each binary's size beside its link. Sizes come from the latest release's asset list, read at build time with the download links, and are shown in decimal megabytes as Finder shows them. Without the release (rate limit, offline build) no size is shown. Items in the install section's version line no longer break in the middle on a narrow screen; the line wraps between them. Co-Authored-By: Claude Opus 5.5 --- src/components/LiteDownload.astro | 29 +++++++++++----- src/components/pages/CorePage.astro | 6 ++-- src/components/pages/LitePage.astro | 52 +++++++++++++++++++++-------- src/lib/github.ts | 23 +++++++++++-- 4 files changed, 83 insertions(+), 27 deletions(-) diff --git a/src/components/LiteDownload.astro b/src/components/LiteDownload.astro index 9b5e87b..66966be 100644 --- a/src/components/LiteDownload.astro +++ b/src/components/LiteDownload.astro @@ -2,13 +2,13 @@ // One button that downloads Lite for the visitor's machine, so getting the app // takes one click instead of home → Lite page → install section → button. // -// The download URLs come from the latest release's asset list at build time -// (the file names carry the version). Without script, or for a machine there is +// The download URLs and sizes come from the latest release's asset list at +// build time (the file names carry the version). Without script, or for a machine there is // no build for (a phone, an Intel Mac), the button leads to the install // section instead, which lists every option. So does a Linux machine while the // latest release has no AppImage for it. import { getLang, localePath } from "~/i18n"; -import { getLatestLiteRelease } from "~/lib/github"; +import { formatSize, getLatestLiteRelease } from "~/lib/github"; interface Props { /** Filled for the page's main action; outlined where it sits beside other actions. */ @@ -19,10 +19,15 @@ const lang = getLang(Astro); const zh = lang === "zh-CN"; const release = await getLatestLiteRelease(); +const assetName = (suffix: string) => Object.keys(release?.assets ?? {}).find((n) => n.endsWith(suffix)); const asset = (suffix: string) => { - const name = Object.keys(release?.assets ?? {}).find((n) => n.endsWith(suffix)); + const name = assetName(suffix); return name ? release!.assets[name] : ""; }; +const size = (suffix: string) => { + const name = assetName(suffix); + return formatSize(name ? release!.sizes[name] : null) ?? ""; +}; const version = release?.tag.replace(/^v/, "") ?? ""; const install = `${localePath(lang, "/lite")}#install`; @@ -46,6 +51,11 @@ const look = data-arm64={asset("-arm64-setup.exe")} data-x86_64={asset("-x86_64.AppImage")} data-aarch64={asset("-aarch64.AppImage")} + data-mac-size={size("-arm64.dmg")} + data-x64-size={size("-x64-setup.exe")} + data-arm64-size={size("-arm64-setup.exe")} + data-x86_64-size={size("-x86_64.AppImage")} + data-aarch64-size={size("-aarch64.AppImage")} data-label-mac={t.mac} data-label-win={t.win} data-label-linux={t.linux} @@ -79,25 +89,26 @@ const look = const mac = !windows && ((ua && ua.platform === "macOS") || (/Macintosh/.test(s) && navigator.maxTouchPoints < 2)); const linux = !windows && !mac && !/Android/.test(s) && ((ua && ua.platform === "Linux") || /Linux/.test(s)); if (!windows && !mac && !linux) return; - const set = (el, url, label, arch) => { + const set = (el, url, label, arch, size) => { if (!url) return; const a = el.querySelector("[data-dl-link]"); const note = el.querySelector("[data-dl-note]"); a.href = url; a.textContent = label; if (el.dataset.note) { - note.textContent = `${el.dataset.note} · ${arch}`; + note.textContent = [el.dataset.note, arch, size].filter(Boolean).join(" · "); note.hidden = false; } }; const apply = (arch) => { for (const el of document.querySelectorAll("[data-lite-dl]")) { if (windows) { - set(el, arch === "arm64" ? el.dataset.arm64 : el.dataset.x64, el.dataset.labelWin, arch === "arm64" ? "ARM64" : "x64"); + const a = arch === "arm64" ? "arm64" : "x64"; + set(el, el.dataset[a], el.dataset.labelWin, a === "arm64" ? "ARM64" : "x64", el.dataset[`${a}Size`]); } else if (linux) { - set(el, el.dataset[arch], el.dataset.labelLinux, arch); + set(el, el.dataset[arch], el.dataset.labelLinux, arch, el.dataset[`${arch}Size`]); } else if (arch !== "x86") { - set(el, el.dataset.mac, el.dataset.labelMac, "Apple silicon"); + set(el, el.dataset.mac, el.dataset.labelMac, "Apple silicon", el.dataset.macSize); } } }; diff --git a/src/components/pages/CorePage.astro b/src/components/pages/CorePage.astro index 0dc7d1a..ff4e54a 100644 --- a/src/components/pages/CorePage.astro +++ b/src/components/pages/CorePage.astro @@ -9,7 +9,7 @@ import CopyCommand from "~/components/CopyCommand.astro"; import NeonMotion from "~/components/NeonMotion.astro"; import { getLang, localePath } from "~/i18n"; import { coreCopy } from "~/i18n/pages/core"; -import { getLatestCoreRelease, getReleases } from "~/lib/github"; +import { formatSize, getLatestCoreRelease, getReleases } from "~/lib/github"; import { coreLd } from "~/lib/structured-data"; const lang = getLang(Astro); @@ -33,11 +33,12 @@ const asServiceUser = "sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch tw // (twcore upgrade and the install script depend on them as well). Links point // at the latest release read at build time, or at GitHub's redirect to the // latest release when GitHub could not be reached; the version then comes from -// the committed list of releases (src/data/releases.json). +// the committed list of releases (src/data/releases.json) and no sizes are shown. const release = await getLatestCoreRelease(); const tag = release?.tag ?? (await getReleases()).find((r) => r.product === "core")?.tag; const version = tag?.replace(/^v/, ""); const download = (file: string) => release?.assets[file] ?? `${repo}/releases/latest/download/${file}`; +const size = (file: string) => formatSize(release?.sizes[file]); const binaries: { label: string; file: string; archive?: string }[] = [ { label: c.install.platforms.mac, file: "twcore-aarch64-apple-darwin" }, { label: c.install.platforms.winX64, file: "twcore-x86_64-pc-windows-msvc.exe" }, @@ -214,6 +215,7 @@ const fileLink = "break-all text-[var(--color-brand-1)] transition-colors hover: {b.label} {b.file} + {size(b.file) && {size(b.file)}} sha256 {b.archive && tar.gz} diff --git a/src/components/pages/LitePage.astro b/src/components/pages/LitePage.astro index 869fd88..d285d03 100644 --- a/src/components/pages/LitePage.astro +++ b/src/components/pages/LitePage.astro @@ -15,7 +15,7 @@ import SiteHeader from "~/components/SiteHeader.astro"; import SiteFooter from "~/components/SiteFooter.astro"; import { getLang, localePath, t } from "~/i18n"; import { liteCopy } from "~/i18n/pages/lite"; -import { getLatestLiteRelease } from "~/lib/github"; +import { formatSize, getLatestLiteRelease } from "~/lib/github"; import { liteLd } from "~/lib/structured-data"; const lang = getLang(Astro); @@ -29,30 +29,48 @@ const repo = "https://github.com/ThinkWatchProject/ThinkWatch-Lite"; const releases = `${repo}/releases`; const latestPage = `${releases}/latest`; -// Windows installers of the latest release. The file names carry the version, -// so they come from the release's asset list, looked up at build time; without -// it, the buttons lead to the latest release page instead. +// The installers of the latest release. The file names carry the version, so +// they come from the release's asset list, looked up at build time, along with +// their sizes; without it, the buttons lead to the latest release page instead +// and no size is shown. const liteRelease = await getLatestLiteRelease(); +const assetName = (suffix: string) => Object.keys(liteRelease?.assets ?? {}).find((n) => n.endsWith(suffix)); const assetUrl = (suffix: string) => { - const name = Object.keys(liteRelease?.assets ?? {}).find((n) => n.endsWith(suffix)); + const name = assetName(suffix); return name ? liteRelease!.assets[name] : null; }; +const assetSize = (suffix: string) => { + const name = assetName(suffix); + return formatSize(name ? liteRelease!.sizes[name] : null); +}; const winArchs = ["x64", "arm64"] as const; const win = Object.fromEntries( winArchs.map((arch) => [ arch, - { url: assetUrl(`-${arch}-setup.exe`) ?? latestPage, sha: assetUrl(`-${arch}-setup.exe.sha256`) }, + { + url: assetUrl(`-${arch}-setup.exe`) ?? latestPage, + sha: assetUrl(`-${arch}-setup.exe.sha256`), + size: assetSize(`-${arch}-setup.exe`), + }, ]), -) as Record<(typeof winArchs)[number], { url: string; sha: string | null }>; +) as Record<(typeof winArchs)[number], { url: string; sha: string | null; size: string | null }>; const linuxArchs = ["x86_64", "aarch64"] as const; const linux = Object.fromEntries( linuxArchs.map((arch) => [ arch, - { url: assetUrl(`-${arch}.AppImage`) ?? latestPage, sha: assetUrl(`-${arch}.AppImage.sha256`) }, + { + url: assetUrl(`-${arch}.AppImage`) ?? latestPage, + sha: assetUrl(`-${arch}.AppImage.sha256`), + size: assetSize(`-${arch}.AppImage`), + }, ]), -) as Record<(typeof linuxArchs)[number], { url: string; sha: string | null }>; +) as Record<(typeof linuxArchs)[number], { url: string; sha: string | null; size: string | null }>; const installScript = `${latestPage}/download/install.sh`; -const dmg = { url: assetUrl("-arm64.dmg") ?? latestPage, sha: assetUrl("-arm64.dmg.sha256") }; +const dmg = { + url: assetUrl("-arm64.dmg") ?? latestPage, + sha: assetUrl("-arm64.dmg.sha256"), + size: assetSize("-arm64.dmg"), +}; const liteVersion = liteRelease?.tag.replace(/^v/, "") ?? null; const installDocsHref = localePath(lang, "/docs/lite/install"); const sourceDocsHref = localePath(lang, "/docs/lite/run-from-source"); @@ -424,7 +442,8 @@ const archLabel = { x64: "x64", arm64: "ARM64" } as const; {c.install.mac.dmgDownload} {liteVersion && (

- {c.install.version} {liteVersion} + {c.install.version} {liteVersion} + {dmg.size && {" · "}{dmg.size}} {dmg.sha && {" · "}{c.install.sha}}

)} @@ -453,7 +472,8 @@ const archLabel = { x64: "x64", arm64: "ARM64" } as const; {liteVersion && (

- {c.install.version} {liteVersion} + {c.install.version} {liteVersion} + {winArchs.map((arch) => win[arch].size && {" · "}{archLabel[arch]} {win[arch].size})} {winArchs.map((arch) => win[arch].sha && {" · "}{c.install.sha} ({archLabel[arch]}), )} @@ -494,7 +514,8 @@ const archLabel = { x64: "x64", arm64: "ARM64" } as const; {liteVersion && linuxArchs.some((arch) => linux[arch].sha) && (

- {c.install.version} {liteVersion} + {c.install.version} {liteVersion} + {linuxArchs.map((arch) => linux[arch].size && {" · "}{arch} {linux[arch].size})} {linuxArchs.map((arch) => linux[arch].sha && {" · "}{c.install.sha} ({arch}), )} @@ -2087,6 +2108,11 @@ const archLabel = { x64: "x64", arm64: "ARM64" } as const; .install-meta[hidden] { display: none; } + /* The line wraps between its items (version, sizes, checksums), not inside one */ + .install-meta > span, + .install-meta > a { + white-space: nowrap; + } .install-meta a { color: var(--color-brand-1); transition: color 150ms; diff --git a/src/lib/github.ts b/src/lib/github.ts index a3b03af..268693b 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -83,6 +83,8 @@ export interface ProductRelease { tag: string; /** Asset file name → its download URL */ assets: Record; + /** Asset file name → its size in bytes */ + sizes: Record; } const latestCache = new Map>(); @@ -94,13 +96,17 @@ function getLatestProductRelease(repo: string): Promise { try { const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { headers: githubHeaders() }); if (!res.ok) return null; - const data: { tag_name?: string; assets?: { name?: string; browser_download_url?: string }[] } = - await res.json(); + const data: { + tag_name?: string; + assets?: { name?: string; browser_download_url?: string; size?: number }[]; + } = await res.json(); const assets: Record = {}; + const sizes: Record = {}; for (const a of data.assets ?? []) { if (a.name && a.browser_download_url) assets[a.name] = a.browser_download_url; + if (a.name && typeof a.size === "number") sizes[a.name] = a.size; } - return data.tag_name ? { tag: data.tag_name, assets } : null; + return data.tag_name ? { tag: data.tag_name, assets, sizes } : null; } catch { return null; } @@ -111,6 +117,17 @@ function getLatestProductRelease(repo: string): Promise { } export const getLatestLiteRelease = () => getLatestProductRelease(productRepos.lite); + +/** + * A download's size as shown next to it, in decimal megabytes ("17.2 MB"), the + * unit Finder and most download pages use. Null in, null out, so a release + * without the file simply shows no size. + */ +export function formatSize(bytes: number | null | undefined): string | null { + if (typeof bytes !== "number") return null; + const mb = bytes / 1_000_000; + return `${mb < 1 ? mb.toFixed(2) : mb < 100 ? mb.toFixed(1) : Math.round(mb)} MB`; +} export const getLatestCoreRelease = () => getLatestProductRelease(productRepos.core); // Every published release of the three products, newest first, for the