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
438 changes: 437 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
],
"scripts": {
"typecheck": "tsc -p packages/scene && tsc -p packages/gameplay && tsc -p packages/voxel && tsc -p games && tsc -p tools && tsc -p tests && tsc -p renderer/web/www",
"test": "node --test packages/*/test/*.test.ts tools/*/test/*.test.ts",
"test": "node --test packages/*/test/*.test.ts tools/*/test/*.test.ts site/*.test.ts",
"scenes": "node tools/build-scenes.ts",
"test:render": "node tools/test-render.ts",
"test:layer-b": "node tools/test-layer-b.ts",
Expand All @@ -27,7 +27,8 @@
"qrcode-generator": "^2.0.4",
"sharp": "^0.35.5",
"three": "^0.187.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"wrangler": "4.160.1"
},
"allowScripts": {
"esbuild": true
Expand Down
15 changes: 15 additions & 0 deletions site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ disclosure. **Keep it that terse**: a card that grows a paragraph stops matching

| Card | From `latest.json` | Under the button |
|---|---|---|
| ThreeRT for Windows | `windows`: the per-user EXE installer (x64) | unsigned preview notice, portable ZIP, SHA-256 file and installer digest |
| ThreeRT for macOS | `macos`: the disk image | SHA-256 |
| ThreeRT for iPhone | `ios.url`: TestFlight or the App Store | the link as a QR code in the card's corner |
| ThreeRT for Steam Deck | `linux`: the tarball (x86_64; Linux is in the requirement line) | the two commands that install it, in a block with a Copy button (`tar xzf ~/Downloads/<file>`, `ThreeRT/install.sh`: what the tarball's README says, with the place a browser saves to), one line on what they do, SHA-256 |
Expand Down Expand Up @@ -286,3 +287,17 @@ offline references and every number on the page are still the M5 run's. Kept fro
were not plainly better: `hello/sun-room-rt.webp` (its walls read more mottled on 2d4e88a) and all of Zero-Day
(sharper on 2d4e88a, but more orange fireflies in its dark floor and walls).
Amazon Lumberyard Bistro and Zero-Day are from NVIDIA's ORCA (CC BY 4.0); the credits are in the page's footer.

### Windows preview releases

Windows is distributed directly as an unsigned preview; a Microsoft Store account or paid signing service is not required for this channel. SmartScreen may warn on first use. The page states this without asking people to disable protection. Windows 10 2004+ or Windows 11 x64 and a DirectX 12 GPU with ray queries are required. The installer runs per-user and updates an existing install; the ZIP is a portable alternative.

`latest.json.windows` has the installer `file`, `size`, `sha256`, `version`, `date`, `arch`, `minOS`, `signed: false`, and `channel: "preview"`. Its `portable` and `checksums` objects each have `file`, `size`, and `sha256`. All three files are checked, uploaded to R2, and served as attachments with HEAD and range support. The shared release script carries them over when another platform releases.

From the client checkout, after Windows installation and runtime acceptance:

```sh
bun tools/release.ts --no-macos --windows-file dist/ThreeRT-<version>-windows-x64-setup.exe --site <site-checkout>/site
```

The matching ZIP must be beside the installer. The release script computes the checksums and preserves other platforms and the live macOS appcast. In this checkout run `bun test site/worker.test.ts`, `bun site/tools/build.ts --require-release`, `bunx wrangler deploy --dry-run --config site/wrangler.jsonc`, then `bun site/tools/deploy.ts`. Wrangler is pinned in package-lock.json. Before publishing from a separate checkout, refresh the release drop from the current live manifest so another platform's newer release is retained.
32 changes: 28 additions & 4 deletions site/download/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Download ThreeRT — macOS, iPhone, Steam Deck and Android</title>
<meta name="description" content="ThreeRT for macOS, iPhone and iPad, Steam Deck and Linux, and Android: the free clients that open ThreeRT apps and games, from threert.app or your own dev server, and path-trace them on your own GPU.">
<title>Download ThreeRT — Windows, macOS, iPhone, Steam Deck and Android</title>
<meta name="description" content="ThreeRT for Windows, macOS, iPhone and iPad, Steam Deck and Linux, and Android: free clients that open ThreeRT apps and games from threert.app or your own dev server and path-trace them on your GPU.">
<link rel="icon" href="../brand/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="../css/threert.css">
<style>
/* Four cards, two a row. A card's three parts (what it is, three lines, the button) sit on rows the two cards of a
/* Client cards, two a row. A card's three parts (what it is, three lines, the button) sit on rows the two cards of a
row share, so the buttons are level whatever the lines above them wrap to. */
.dl { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.dl .card { display: grid; grid-template-rows: auto 1fr auto; row-gap: 22px; }
Expand Down Expand Up @@ -85,6 +85,21 @@ <h1 class="h-1">The client traces it.<br><span class="mute">On your own GPU.</sp
</div>
</div>

<div class="card" id="windows">
<div class="top">
<div class="glyph" aria-hidden="true"><svg width="26" height="26" viewBox="0 0 26 26" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><path d="M3 4h9v9H3zM15 4h8v9h-8zM3 16h9v7H3zM15 16h8v7h-8z"/></svg></div>
<div><h2>ThreeRT for Windows</h2><span class="req">Windows&nbsp;10&nbsp;(2004)+ or&nbsp;11 · x64 · DirectX&nbsp;12</span></div>
</div>
<ul class="ticks">
<li>Apps and games from <a href="https://threert.app/" rel="noopener">threert.app</a>, in a native Windows client</li>
<li>For GPUs with DirectX 12 ray queries</li>
<li>Includes the <code>threert</code> command line; installs for your account</li>
</ul>
<div class="act" id="windowsAct">
<p class="fine">Looking for the build on this host…</p>
</div>
</div>

<div class="card" id="ios">
<div class="top">
<div class="glyph" aria-hidden="true"><svg width="26" height="26" viewBox="0 0 26 26" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"><rect x="7.5" y="2.5" width="11" height="21" rx="2.6"/><path d="M11.5 5.2h3"/></svg></div>
Expand Down Expand Up @@ -197,7 +212,7 @@ <h3>No ray tracing? <span class="ray-text">Still the same game.</span></h3>
// The visitor's own client first, and lit; the one for a device like it beside it. Before anything is fetched, so
// nothing moves afterwards.
const here = platform();
const order = { ios: ["ios", "mac", "deck", "android"], linux: ["deck", "android", "mac", "ios"], android: ["android", "deck", "mac", "ios"] }[here];
const order = { windows: ["windows", "mac", "deck", "android", "ios"], ios: ["ios", "mac", "windows", "deck", "android"], linux: ["deck", "windows", "android", "mac", "ios"], android: ["android", "deck", "windows", "mac", "ios"] }[here];
if (order) {
$(".dl").append(...order.map((id) => document.getElementById(id)));
for (const card of $(".dl").children) card.classList.toggle("lit", card.id === order[0]);
Expand All @@ -220,6 +235,15 @@ <h3>No ray tracing? <span class="ray-text">Still the same game.</span></h3>
const m = r?.macos;
$("#macAct").innerHTML = m?.file ? row(clientUrl(m), "Download for macOS", version(m), megabytes(m.size)) + sha(m) : none("macOS");

const w = r?.windows;
if (w?.file) {
$("#windowsAct").innerHTML = row(clientUrl(w), "Download for Windows", `${version(w)} · Preview`, megabytes(w.size)) +
`<p class="fine">Open the installer. To update, run the newer installer.</p>` +
(w.signed === false ? `<p class="fine">Unsigned preview. Windows SmartScreen may warn that this app is unrecognized. Verify the download source before proceeding.</p>` : "") +
`<div class="row">${w.portable?.file ? `<a href="${esc(clientUrl(w.portable))}" download>Portable ZIP · ${megabytes(w.portable.size)}</a>` : ""}
${w.checksums?.file ? `<a href="${esc(clientUrl(w.checksums))}" download>SHA-256 checksums</a>` : ""}</div>` + sha(w);
} else $("#windowsAct").innerHTML = none("Windows");

// iPhone: a link once the beta is open; until then, say so.
const i = r?.ios;
if (i?.url) {
Expand Down
12 changes: 7 additions & 5 deletions site/js/site.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ export const ROOT = new URL("..", import.meta.url);
export const GITHUB = "https://github.com/meshy-dev/ThreeRT";

/** The clients there are, as a visitor knows the device each is for. */
export const CLIENTS = { macos: "macOS", ios: "iPhone", linux: "Steam Deck", android: "Android" };
export const CLIENTS = { macos: "macOS", windows: "Windows", ios: "iPhone", linux: "Steam Deck", android: "Android" };

/**
* "macos" | "ios" | "android" | "linux" | "other": the client a visitor's device takes. An iPad asks for the desktop
* "macos" | "windows" | "ios" | "android" | "linux" | "other": the client a visitor's device takes. An iPad asks for the desktop
* site as a Mac with a touch screen. An Android browser says Linux too, so it is asked first. "linux" is x86_64
* alone, what the tarball is built for: a Steam Deck's browsers say "X11; Linux x86_64", in Desktop Mode and in Game
* Mode alike.
*/
export function platform(ua = navigator.userAgent, touchPoints = navigator.maxTouchPoints) {
if (/iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && touchPoints > 1)) return "ios";
if (/Android/.test(ua)) return "android";
if (/Windows NT/.test(ua)) return "windows";
if (/Macintosh|Mac OS X/.test(ua)) return "macos";
if (/Linux x86_64/.test(ua)) return "linux";
return "other";
Expand All @@ -31,7 +32,7 @@ export function platform(ua = navigator.userAgent, touchPoints = navigator.maxTo
*/
export function downloadButtons(root = document) {
const p = platform();
const text = { macos: "Download for macOS", ios: "Get ThreeRT for iPhone", linux: "Download for Steam Deck", android: "Download for Android", other: "Download" }[p];
const text = { macos: "Download for macOS", windows: "Download for Windows", ios: "Get ThreeRT for iPhone", linux: "Download for Steam Deck", android: "Download for Android", other: "Download" }[p];
for (const a of root.querySelectorAll("a[data-dl]")) {
const label = a.querySelector("span") ?? a;
label.textContent = text;
Expand All @@ -51,10 +52,11 @@ async function json(rel) {
}

/**
* The clients released on this host, or null: {version, date, macos, ios, linux, android}, an entry a client, each
* The clients released on this host, or null: {version, date, macos, ios, linux, android, windows}, an entry a client, each
* with its own `version` (clients are released on different days; an entry without one has the top-level version):
* macos {file, url, size, sha256, minOS, arch, signed, notarized}, ios {url, channel, minOS},
* linux {file, url, size, sha256, arch, needs}, android {file, url, size, sha256, arch, minOS, signed}.
* linux {file, url, size, sha256, arch, needs}, android {file, url, size, sha256, arch, minOS, signed},
* windows {file, size, sha256, arch, minOS, signed, channel, portable, checksums}.
* A client without an entry is not published here.
*/
export const release = () => json("releases/latest.json");
Expand Down
7 changes: 6 additions & 1 deletion site/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { existsSync, statSync } from "node:fs";
import { networkInterfaces } from "node:os";
import { extname, join, normalize, resolve } from "node:path";
import { VENDOR } from "./tools/vendor.ts";
import { clientFile } from "./worker.ts";

const root = resolve(import.meta.dir);
const args = process.argv.slice(2);
Expand Down Expand Up @@ -60,7 +61,11 @@ Bun.serve({
// Packages are fetched by native clients and by pages on other origins (a playground, a game's own site).
if (rel.startsWith("play/")) headers["access-control-allow-origin"] = "*";
// The clients' files are downloads, as the Worker serves them.
if (/^releases\/[^/]+\.(dmg|tar\.gz|apk)$/.test(rel)) headers["content-disposition"] = `attachment; filename="${rel.slice("releases/".length)}"`;
const client = clientFile(`/${rel}`);
if (client) {
headers["content-type"] = client.type;
headers["content-disposition"] = `attachment; filename="${client.key}"`;
}
return new Response(Bun.file(file), { headers });
},
});
Expand Down
9 changes: 6 additions & 3 deletions site/tools/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
// from R2.
import { cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { createHash } from "node:crypto";
import { VENDOR } from "./vendor.ts";
import { releaseArtifacts } from "./releases.ts";

const site = resolve(import.meta.dir, "..");
const args = process.argv.slice(2);
Expand All @@ -33,7 +35,7 @@ for (const [rel, file] of Object.entries(VENDOR)) {

// The clients' release.
/** A client's file: never a static asset here (worker.ts serves it from R2), whatever its size. */
const CLIENT_FILE = /\.(dmg|tar\.gz|apk)$/;
const CLIENT_FILE = /(?:\.(?:dmg|tar\.gz|apk|exe|zip)|-SHA256SUMS\.txt)$/;
const latest = join(site, "releases/latest.json");
if (existsSync(latest)) {
const r = JSON.parse(readFileSync(latest, "utf8"));
Expand All @@ -43,8 +45,7 @@ if (existsSync(latest)) {
// plain file host without that limit.
const withImage = args.includes("--with-image");
const said: string[] = [];
for (const [key, name, required] of [["macos", "macOS", true], ["linux", "Linux", false], ["android", "Android", false]] as const) {
const e = r[key];
for (const { key, name, required, entry: e } of releaseArtifacts(r)) {
if (!e && !required) {
said.push(`${name} not published`);
continue;
Expand All @@ -54,6 +55,8 @@ if (existsSync(latest)) {
if (!/^https:\/\//.test(e.url)) problems.push(`site/releases/latest.json: ${key}.url is not an https URL (${e.url})`);
} else if (!file || !existsSync(file)) problems.push(`site/releases/latest.json names ${e?.file ?? `no ${name} file`}, which is not in site/releases/`);
else if (statSync(file).size !== e.size) problems.push(`site/releases/${e.file} is ${statSync(file).size} bytes, latest.json says ${e.size}`);
else if (!/^[a-f0-9]{64}$/.test(e.sha256 ?? "") || createHash("sha256").update(readFileSync(file)).digest("hex") !== e.sha256)
problems.push(`site/releases/${e.file}: SHA-256 does not match latest.json`);
said.push(`${name} ${e?.version ?? r.version} ${e?.url ?? e?.file ?? "no file"}`);
}
// A public build opens like any app: never one that asks the user to get past Gatekeeper.
Expand Down
14 changes: 7 additions & 7 deletions site/tools/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
// bun site/tools/deploy.ts needs `wrangler login` as a member of the account in site/wrangler.jsonc
// The build is strict: it stops when site/releases/ or site/play/ is missing or incomplete, so a deploy never puts up
// a Download button with nothing behind it (site/README.md: where the two directories come from).
// Every file latest.json names (the macOS disk image, the Linux tarball, the Android APK) goes to the R2 bucket
// first (site/wrangler.jsonc: `threert-releases`, created once with `bunx wrangler@4 r2 bucket create
// Every file latest.json names (including the Windows installer, portable ZIP and checksums) goes to the R2 bucket
// first (site/wrangler.jsonc: `threert-releases`, created once with `bunx wrangler r2 bucket create
// threert-releases`), so the page never links a file that is not there yet. A file the bucket already has is not
// sent again: the live site is asked for it (HEAD /releases/<file>: the Worker answers with the object's size and
// ETag), and the same size, and the same MD5 where the ETag is one, means it is there.
Expand All @@ -13,6 +13,7 @@ import { createHash } from "node:crypto";
import { existsSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { clientFile } from "../worker.ts";
import { releaseArtifacts } from "./releases.ts";

const site = resolve(import.meta.dir, "..");
const config = resolve(site, "wrangler.jsonc");
Expand All @@ -34,19 +35,18 @@ async function hosted(name: string, file: string): Promise<boolean> {

await $`bun ${resolve(site, "tools/build.ts")} --require-release`;
const latest = JSON.parse(readFileSync(join(site, "releases/latest.json"), "utf8"));
for (const key of ["macos", "linux", "android"]) {
const entry = latest[key];
for (const { entry } of releaseArtifacts(latest)) {
// (An entry with a `url` is hosted elsewhere.)
if (!entry?.file || entry.url) continue;
const file = join(site, "releases", entry.file);
if (!existsSync(file)) throw new Error(`${file} is missing`);
// The type the Worker serves it with; a name it would not serve stops here.
const type = clientFile(`/releases/${entry.file}`)?.type;
if (!type) throw new Error(`${entry.file}: not a file the Worker serves (/releases/<name>.dmg, .tar.gz or .apk)`);
if (!type) throw new Error(`${entry.file}: not a download the Worker serves`);
if (await hosted(entry.file, file)) {
console.log(`${entry.file}: already in R2, not sent again`);
continue;
}
await $`bunx wrangler@4 r2 object put ${`threert-releases/${entry.file}`} --file ${file} --content-type ${type} --remote --config ${config}`;
await $`bunx wrangler r2 object put ${`threert-releases/${entry.file}`} --file ${file} --content-type ${type} --remote --config ${config}`;
}
await $`bunx wrangler@4 deploy --config ${config}`;
await $`bunx wrangler deploy --config ${config}`;
12 changes: 12 additions & 0 deletions site/tools/releases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** Every downloadable artifact, including the Windows portable build and checksum file. */
export function releaseArtifacts(latest: Record<string, any>) {
const entries = [
{ key: "macos", name: "macOS", required: true, entry: latest.macos },
{ key: "linux", name: "Linux", required: false, entry: latest.linux },
{ key: "android", name: "Android", required: false, entry: latest.android },
{ key: "windows", name: "Windows", required: false, entry: latest.windows },
];
if (latest.windows) for (const part of ["portable", "checksums"])
entries.push({ key: `windows.${part}`, name: `Windows ${part}`, required: true, entry: latest.windows[part] });
return entries;
}
Loading