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
29 changes: 20 additions & 9 deletions src/components/LiteDownload.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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`;

Expand All @@ -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}
Expand Down Expand Up @@ -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);
}
}
};
Expand Down
6 changes: 4 additions & 2 deletions src/components/pages/CorePage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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" },
Expand Down Expand Up @@ -214,6 +215,7 @@ const fileLink = "break-all text-[var(--color-brand-1)] transition-colors hover:
<span class="shrink-0 text-[15px] font-medium">{b.label}</span>
<span class="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1 font-mono text-[13px] sm:justify-end">
<a href={download(b.file)} class={fileLink}>{b.file}</a>
{size(b.file) && <span class="whitespace-nowrap text-[var(--color-muted)]">{size(b.file)}</span>}
<a href={download(`${b.file}.sha256`)} class={fileLink}>sha256</a>
{b.archive && <a href={download(b.archive)} class={fileLink}>tar.gz</a>}
</span>
Expand Down
52 changes: 39 additions & 13 deletions src/components/pages/LitePage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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");
Expand Down Expand Up @@ -424,7 +442,8 @@ const archLabel = { x64: "x64", arm64: "ARM64" } as const;
<a href={dmg.url} class="dl-button">{c.install.mac.dmgDownload}</a>
{liteVersion && (
<p class="install-meta">
{c.install.version} {liteVersion}
<span>{c.install.version} {liteVersion}</span>
{dmg.size && <Fragment>{" · "}<span>{dmg.size}</span></Fragment>}
{dmg.sha && <Fragment>{" · "}<a href={dmg.sha}>{c.install.sha}</a></Fragment>}
</p>
)}
Expand Down Expand Up @@ -453,7 +472,8 @@ const archLabel = { x64: "x64", arm64: "ARM64" } as const;
</div>
{liteVersion && (
<p class="install-meta">
{c.install.version} {liteVersion}
<span>{c.install.version} {liteVersion}</span>
{winArchs.map((arch) => win[arch].size && <Fragment>{" · "}<span>{archLabel[arch]} {win[arch].size}</span></Fragment>)}
{winArchs.map((arch) =>
win[arch].sha && <Fragment>{" · "}<a href={win[arch].sha}>{c.install.sha} ({archLabel[arch]})</a></Fragment>,
)}
Expand Down Expand Up @@ -494,7 +514,8 @@ const archLabel = { x64: "x64", arm64: "ARM64" } as const;
</div>
{liteVersion && linuxArchs.some((arch) => linux[arch].sha) && (
<p class="install-meta">
{c.install.version} {liteVersion}
<span>{c.install.version} {liteVersion}</span>
{linuxArchs.map((arch) => linux[arch].size && <Fragment>{" · "}<span>{arch} {linux[arch].size}</span></Fragment>)}
{linuxArchs.map((arch) =>
linux[arch].sha && <Fragment>{" · "}<a href={linux[arch].sha}>{c.install.sha} ({arch})</a></Fragment>,
)}
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 20 additions & 3 deletions src/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export interface ProductRelease {
tag: string;
/** Asset file name → its download URL */
assets: Record<string, string>;
/** Asset file name → its size in bytes */
sizes: Record<string, number>;
}

const latestCache = new Map<string, Promise<ProductRelease | null>>();
Expand All @@ -94,13 +96,17 @@ function getLatestProductRelease(repo: string): Promise<ProductRelease | null> {
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<string, string> = {};
const sizes: Record<string, number> = {};
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;
}
Expand All @@ -111,6 +117,17 @@ function getLatestProductRelease(repo: string): Promise<ProductRelease | null> {
}

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
Expand Down
Loading