Files
DorkNet/ci/downloads/index.html
T
Alexa 6e5b011059 ci: publish client builds to a downloads page under player-facing names
Each run now stages what it built as DorkNet-<release>-PC-Full.zip,
-PC-Patch.zip, -Quest.xapk and -Quest-Installer.zip, writes their sizes and
SHA-256 to a manifest, and uploads them with a static page to an S3 bucket
served read-only (ci/downloads). The build scripts keep their own names,
which the launcher depends on; only the published copies are renamed.

PUBLISH_DOWNLOADS (on by default) turns the upload off per run. Jenkins
archives the same renamed files instead of the raw build output.
2026-10-25 11:53:23 +02:00

244 lines
12 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>DorkNet Downloads</title>
<link rel="icon" href="dorknet.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!--
The DorkNet downloads page. Published by ci/downloads/publish.py next to
manifest.json, which lists the files; this page only renders it. Colours
follow DorkNet.Server/site (tailwind.config.js).
-->
<style>
:root {
--bg: #0a0b0e; --surface: #131418; --surface-2: #1b1d22; --border: #23252b; --border-2: #34363d;
--text: #ececef; --muted: #a9abb4; --faint: #7e818d;
--brand: #0b95c7; --brand-hi: #22cfee; --brand-soft: rgba(11, 149, 199, .14); --on-brand: #fff;
--ok: #3ecf8e; --danger: #ef5b6b;
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: #f7f7f8; --surface: #ffffff; --surface-2: #f7f7f8; --border: #ececef; --border-2: #d3d4d9;
--text: #131418; --muted: #43464f; --faint: #5d606b;
--brand: #087aa8; --brand-hi: #0b95c7; --brand-soft: rgba(11, 149, 199, .10);
--ok: #1f9d68; --danger: #c93a4b;
color-scheme: light;
}
}
:root[data-theme="light"] {
--bg: #f7f7f8; --surface: #ffffff; --surface-2: #f7f7f8; --border: #ececef; --border-2: #d3d4d9;
--text: #131418; --muted: #43464f; --faint: #5d606b;
--brand: #087aa8; --brand-hi: #0b95c7; --brand-soft: rgba(11, 149, 199, .10);
--ok: #1f9d68; --danger: #c93a4b;
color-scheme: light;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 15px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
a { color: var(--brand-hi); }
code, .mono { font-family: "JetBrains Mono", SFMono-Regular, Menlo, monospace; font-size: .86em; }
.wrap { max-width: 980px; margin: 0 auto; padding: 0 16px; }
header { border-bottom: 1px solid var(--border); background: var(--surface); }
header .wrap { display: flex; align-items: center; gap: 12px; height: 60px; }
header img { width: 30px; height: 30px; }
header .name { font-weight: 700; letter-spacing: -.01em; }
header .name span { color: var(--faint); font-weight: 500; }
header .spacer { flex: 1; }
header a.signout { color: var(--muted); font-size: 13px; text-decoration: none; }
header a.signout:hover { color: var(--text); }
.intro { padding: 36px 0 8px; }
h1 { font-size: 28px; line-height: 1.2; letter-spacing: -.02em; margin: 0 0 6px; }
.lede { color: var(--muted); margin: 0; max-width: 60ch; }
.release { padding: 20px 0 8px; }
.release-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; margin-bottom: 14px; }
.release-head h2 { font-size: 20px; margin: 0; letter-spacing: -.01em; }
.release-head .sub { color: var(--faint); font-size: 13px; }
.platform { margin: 0 0 22px; }
.platform h3 {
display: flex; align-items: center; gap: 8px; font-size: 13px; text-transform: uppercase;
letter-spacing: .06em; color: var(--muted); margin: 0 0 10px; font-weight: 600;
}
.platform h3 svg { width: 16px; height: 16px; }
.platform .hint { color: var(--faint); font-size: 13px; margin: -4px 0 10px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); gap: 12px; }
.card {
display: flex; flex-direction: column; gap: 10px; padding: 18px;
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
}
.card.primary { border-color: color-mix(in srgb, var(--brand) 55%, var(--border)); box-shadow: 0 0 0 3px var(--brand-soft); }
.card .top { display: flex; align-items: center; gap: 8px; }
.card h4 { margin: 0; font-size: 16px; }
.tag { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--brand-soft); color: var(--brand-hi); }
.card p { margin: 0; color: var(--muted); font-size: 14px; }
.card .file { color: var(--faint); word-break: break-all; }
.meta { display: flex; flex-wrap: wrap; gap: 4px 14px; color: var(--faint); font-size: 12.5px; }
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: auto; padding-top: 4px; }
.btn {
display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; border-radius: 8px;
font: 600 14px/1 inherit; text-decoration: none; cursor: pointer; border: 1px solid transparent;
}
.btn svg { width: 16px; height: 16px; }
.btn-primary { background: var(--brand); color: var(--on-brand); }
.btn-primary:hover { background: var(--brand-hi); }
.btn-secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); }
.btn-secondary:hover { border-color: var(--faint); }
.btn:focus-visible { outline: 2px solid var(--brand-hi); outline-offset: 2px; }
.hash {
display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 8px; border-radius: 6px;
background: transparent; border: 1px dashed var(--border-2); color: var(--faint); cursor: pointer; font: inherit; font-size: 12px;
}
.hash:hover { color: var(--text); }
.hash.copied { color: var(--ok); border-color: var(--ok); }
.note { color: var(--faint); font-size: 13px; padding: 8px 0 40px; border-top: 1px solid var(--border); margin-top: 12px; }
.note p { margin: 12px 0 0; }
.state { padding: 48px 0; color: var(--muted); }
.state.error { color: var(--danger); }
</style>
</head>
<body>
<header>
<div class="wrap">
<img src="dorknet.svg" alt="">
<div class="name">DorkNet <span>Downloads</span></div>
<div class="spacer"></div>
<a class="signout" href="/cdn-cgi/access/logout">Sign out</a>
</div>
</header>
<main class="wrap">
<section class="intro">
<h1>Get Rec Room for DorkNet</h1>
<p class="lede">Pick your platform. Every file is built from the DorkNet source by our build server; the
SHA-256 next to each one lets you check your download.</p>
</section>
<div id="releases"><p class="state">Loading builds…</p></div>
<section class="note">
<p>These builds contain Rec Room's own game files. They're for the DorkNet community only: please don't
re-share them.</p>
</section>
</main>
<script>
(() => {
const ICONS = {
pc: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>',
quest: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3h-3l-2-2.5h-2L9 17H6a3 3 0 0 1-3-3z"/></svg>',
download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M7 10l5 5 5-5M5 21h14"/></svg>',
copy: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>',
};
const PLATFORMS = [
{ id: 'pc', title: 'PC', hint: 'Windows, with or without a VR headset.' },
{ id: 'quest', title: 'Meta Quest', hint: 'Turn on developer mode for your headset and allow USB debugging when asked.' },
];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const size = (b) => b >= 2 ** 30 ? `${(b / 2 ** 30).toFixed(2)} GB`
: b >= 2 ** 20 ? `${(b / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(b / 2 ** 10))} KB`;
const date = (iso) => iso ? new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '';
// What each file is for, in players' terms. Keyed by the file ids publish.py writes.
function describe(f, rel, site) {
const questPage = site ? `<a href="${esc(site)}/quest" target="_blank" rel="noopener">Quest install page</a>` : 'Quest install page of the DorkNet website';
switch (f.id) {
case 'pc-full': return {
title: 'Full game', tag: 'Start here', primary: true,
body: `Rec Room ${esc(rel.title)} with DorkNet already set up. Unzip it anywhere and run
${rel.pc_exe ? `<code>${esc(rel.pc_exe)}</code>` : 'the game'}.`,
};
case 'pc-patch': return {
title: 'Patch only',
body: `Just the DorkNet part (MelonLoader and the DorkNet mod), for a copy of the game you already have.
The DorkNet launcher installs this for you. Skip it if you got the full game.`,
};
case 'quest': return {
title: 'Quest app', tag: 'Start here', primary: true,
body: `Plug in your headset and install from the ${questPage} (Chrome or Edge), or drop the file onto SideQuest.`,
};
case 'quest-installer': return {
title: 'Install scripts',
body: `Prefer the command line? Unzip these next to the Quest app and run <code>install.cmd</code> (Windows)
or <code>install.sh</code> (macOS / Linux). Needs adb.`,
};
default: return { title: f.name, body: '' };
}
}
function card(f, rel, site) {
const d = describe(f, rel, site);
const build = f.build ? `<span>Build ${esc(f.build)}</span>` : '';
return `
<article class="card${d.primary ? ' primary' : ''}">
<div class="top"><h4>${esc(d.title)}</h4>${d.tag ? `<span class="tag">${esc(d.tag)}</span>` : ''}</div>
<p>${d.body}</p>
<div class="meta"><span class="mono file">${esc(f.name)}</span></div>
<div class="meta"><span>${size(f.size)}</span><span>Updated ${esc(date(f.built_at))}</span>${build}</div>
<div class="actions">
<a class="btn ${d.primary ? 'btn-primary' : 'btn-secondary'}" href="${esc(f.path)}" download>${ICONS.download}Download</a>
<button class="hash mono" type="button" data-sha="${esc(f.sha256)}" title="Copy SHA-256: ${esc(f.sha256)}">${ICONS.copy}SHA-256 ${esc(f.sha256.slice(0, 12))}…</button>
</div>
</article>`;
}
function render(m) {
const root = document.getElementById('releases');
const releases = (m.releases || []).filter((r) => (r.files || []).length);
if (!releases.length) { root.innerHTML = '<p class="state">No builds have been published yet.</p>'; return; }
root.innerHTML = releases.map((rel) => {
const sections = PLATFORMS.map((p) => {
const files = rel.files.filter((f) => f.platform === p.id);
if (!files.length) return '';
return `
<div class="platform">
<h3>${ICONS[p.id]}${esc(p.title)}</h3>
<p class="hint">${esc(p.hint)}</p>
<div class="grid">${files.map((f) => card(f, rel, m.site_url)).join('')}</div>
</div>`;
}).join('');
const game = rel.game_version ? `Game build of ${esc(date(rel.game_version + 'T12:00:00Z'))}` : '';
return `
<section class="release">
<div class="release-head"><h2>Rec Room ${esc(rel.title)}</h2><span class="sub">${game}</span></div>
${sections}
</section>`;
}).join('');
}
document.addEventListener('click', async (e) => {
const b = e.target.closest('.hash');
if (!b) return;
try {
await navigator.clipboard.writeText(b.dataset.sha);
b.classList.add('copied');
setTimeout(() => b.classList.remove('copied'), 1500);
} catch { /* clipboard blocked: the full hash is in the tooltip */ }
});
fetch('manifest.json', { cache: 'no-store' })
.then((r) => { if (r.status === 404) return {}; if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(render)
.catch((err) => {
document.getElementById('releases').innerHTML =
`<p class="state error">Couldn't load the list of builds (${esc(err.message)}). Try reloading the page.</p>`;
});
})();
</script>
</body>
</html>