Compare commits

..

2 Commits

Author SHA1 Message Date
Alexa eb73f3c484 fix: serve downloads only on the site host and select the clicked Quest build
Review follow-ups:
- The downloads routes now answer only on the apex (the site's host) and 404
  on every other accepted host, so the game's open API/CDN subdomains can't
  serve the builds.
- Install on Quest passes its own file to the Quest page instead of always
  picking the newest release.
- Waiting for the bucket's headers is limited to 30 s; the body still streams
  without a limit.
- Dispose the upstream request message; use LINQ in FindFile.
2026-10-25 11:06:43 +02:00
Alexa cc7f77b7de feat(site): Downloads page and Quest install from the latest build
When the server is pointed at the bucket CI publishes builds to
(DorkNet:Downloads:BaseUrl), the site lists them on a new Downloads page and
the Quest page offers "Latest build" next to "Choose file".

The server reads manifest.json and the files over HTTP on the internal network
and streams them through with Range support (DownloadsController); only files
the manifest lists are served. The xapk reader now reads from a byte source,
either the picked File or the server URL via Range requests, so the browser
installer streams the APK and OBB to the headset without downloading the
package first. Unset, both features stay hidden.
2026-10-25 10:55:43 +02:00
20 changed files with 771 additions and 569 deletions
@@ -0,0 +1,131 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
using DorkNet.Server.Controllers.Site;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace DorkNet.Server.Tests;
/// <summary>The site's downloads proxy: off without config, only on the site's
/// own host, serves only files the manifest lists, and passes Range through so
/// the browser Quest installer can read an .xapk in place.</summary>
public sealed class DownloadsControllerTests
{
private const string Apex = "dorknet.example.com";
private const string Manifest = """
{"releases":[{"id":"2023.03","title":"March 2023","files":[
{"id":"quest","platform":"quest","name":"DorkNet-2023.03-Quest.xapk","path":"2023.03/DorkNet-2023.03-Quest.xapk","size":10,"sha256":"x"}]}]}
""";
[Fact]
public async Task Without_a_base_url_the_manifest_is_404()
{
var (controller, _) = Create(baseUrl: null, _ => throw new InvalidOperationException("no request expected"));
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
}
[Theory]
[InlineData("api.dorknet.example.com")]
[InlineData("cdn.dorknet.example.com")]
public async Task Other_hosts_get_404_without_touching_the_bucket(string host)
{
var (controller, ctx) = Create("http://bucket.internal:3902", _ => throw new InvalidOperationException("no request expected"));
ctx.Request.Host = new HostString(host);
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
}
[Fact]
public void Only_listed_paths_are_found()
{
var manifest = (JsonObject)JsonNode.Parse(Manifest)!;
Assert.NotNull(DownloadsService.FindFile(manifest, "2023.03/DorkNet-2023.03-Quest.xapk"));
Assert.Null(DownloadsService.FindFile(manifest, "manifest.json"));
Assert.Null(DownloadsService.FindFile(manifest, "2023.03/../manifest.json"));
}
[Fact]
public async Task Unlisted_file_is_404_and_never_fetched()
{
var requested = new List<string>();
var (controller, ctx) = Create("http://bucket.internal:3902", req =>
{
requested.Add(req.RequestUri!.AbsolutePath);
return Json(Manifest);
});
await controller.GetFile("index.html", CancellationToken.None);
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
Assert.Equal(["/manifest.json"], requested);
}
[Fact]
public async Task Range_is_forwarded_and_the_partial_response_streamed()
{
HttpRequestMessage? fileRequest = null;
var (controller, ctx) = Create("http://bucket.internal:3902/", req =>
{
if (req.RequestUri!.AbsolutePath == "/manifest.json") return Json(Manifest);
fileRequest = req;
var res = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent("abcd"u8.ToArray()) };
res.Content.Headers.ContentRange = new ContentRangeHeaderValue(2, 5, 10);
return res;
}, host: "downloads.example.com");
ctx.Request.Headers.Range = "bytes=2-5";
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
Assert.NotNull(fileRequest);
Assert.Equal("/2023.03/DorkNet-2023.03-Quest.xapk", fileRequest!.RequestUri!.AbsolutePath);
Assert.Equal("bytes=2-5", fileRequest.Headers.Range!.ToString());
Assert.Equal("downloads.example.com", fileRequest.Headers.Host);
Assert.Equal(StatusCodes.Status206PartialContent, ctx.Response.StatusCode);
Assert.Equal("bytes 2-5/10", ctx.Response.Headers.ContentRange.ToString());
Assert.Equal("bytes", ctx.Response.Headers.AcceptRanges.ToString());
Assert.Equal("abcd", Encoding.ASCII.GetString(((MemoryStream)ctx.Response.Body).ToArray()));
}
private static (DownloadsController, HttpContext) Create(
string? baseUrl, Func<HttpRequestMessage, HttpResponseMessage> handler, string? host = null)
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["DorkNet:Downloads:BaseUrl"] = baseUrl,
["DorkNet:Downloads:Host"] = host,
}).Build();
var service = new DownloadsService(config, new StubFactory(handler), NullLogger<DownloadsService>.Instance);
var ctx = new DefaultHttpContext();
ctx.Request.Host = new HostString(Apex);
ctx.Response.Body = new MemoryStream();
var controller = new DownloadsController(service, new DomainConfig(Apex))
{
ControllerContext = new ControllerContext { HttpContext = ctx },
};
return (controller, ctx);
}
private static HttpResponseMessage Json(string body) =>
new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private sealed class StubFactory(Func<HttpRequestMessage, HttpResponseMessage> handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(new StubHandler(handler));
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> Task.FromResult(handler(request));
}
}
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Site;
/// <summary>
/// The client builds CI publishes, for the site's Downloads page and the
/// Quest page's "latest build" install. Files are streamed from the downloads
/// bucket (<see cref="DownloadsService"/>) with Range support, so the browser
/// installer can read an .xapk's zip directory and entries without fetching
/// the whole file. Only files the manifest lists are served.
///
/// The builds contain Rec Room's own game files, so the routes answer only on
/// the site's own host (the apex) and 404 on every other host the server
/// accepts: the game's API/CDN subdomains are typically left open for the
/// client, while the site sits behind an access proxy (e.g. Cloudflare Access).
/// </summary>
[ApiController]
[AllowAnonymous]
[Route("api/site/v1/downloads")]
public class DownloadsController(DownloadsService downloads, DomainConfig domain) : ControllerBase
{
private bool Available => downloads.IsConfigured
&& string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase);
/// <summary>GET /api/site/v1/downloads — the manifest as published
/// (snake_case keys), or 404 when downloads aren't set up or the request
/// isn't for the site's host.</summary>
[HttpGet]
public async Task<IActionResult> GetManifest(CancellationToken ct)
{
if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." });
var manifest = await downloads.GetManifestAsync(ct);
Response.Headers.CacheControl = "no-cache";
return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() })
.ToJsonString(), "application/json");
}
/// <summary>GET /api/site/v1/downloads/files/{path} — one listed file,
/// streamed; honours Range (206).</summary>
[HttpGet("files/{**path}")]
public async Task GetFile(string path, CancellationToken ct)
{
if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
var manifest = await downloads.GetManifestAsync(ct);
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
using var upstream = await downloads.GetFileAsync(path, Request.Headers.Range.ToString(), ct);
Response.StatusCode = (int)upstream.StatusCode;
if (!upstream.IsSuccessStatusCode) return;
var content = upstream.Content.Headers;
if (content.ContentLength is long length) Response.ContentLength = length;
if (content.ContentRange is not null) Response.Headers.ContentRange = content.ContentRange.ToString();
Response.Headers.AcceptRanges = "bytes";
if (upstream.Headers.ETag is not null) Response.Headers.ETag = upstream.Headers.ETag.ToString();
Response.Headers.CacheControl = "private, no-cache";
Response.ContentType = "application/octet-stream";
var name = (string?)entry["name"] ?? Path.GetFileName(path);
Response.Headers.ContentDisposition = new System.Net.Mime.ContentDisposition { FileName = name }.ToString();
await using var body = await upstream.Content.ReadAsStreamAsync(ct);
await body.CopyToAsync(Response.Body, 1 << 16, ct);
}
}
@@ -0,0 +1,97 @@
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
namespace DorkNet.Server.Services;
/// <summary>
/// Reads the client builds CI publishes (<c>ci/downloads/publish.py</c>): a
/// <c>manifest.json</c> plus the files it lists, in a bucket served over plain
/// HTTP (e.g. Garage's web endpoint on the internal network). The site's
/// Downloads and Quest pages fetch them through the server, so the bucket
/// itself never has to be reachable from outside.
///
/// Configured with <c>DorkNet:Downloads:BaseUrl</c> (the folder holding
/// <c>manifest.json</c>) and optionally <c>DorkNet:Downloads:Host</c> (the
/// Host header to send, for web endpoints that pick the bucket by host name).
/// Without a base URL <see cref="IsConfigured"/> is false and the site hides
/// both features.
/// </summary>
public sealed class DownloadsService
{
public const string HttpClientName = "downloads";
// How long the bucket may take to start answering. The body then streams
// with no limit (the client has none; the caller's abort token ends it).
private static readonly TimeSpan HeaderTimeout = TimeSpan.FromSeconds(30);
private readonly IHttpClientFactory _httpClientFactory;
private readonly Uri? _baseUri;
private readonly string? _host;
public DownloadsService(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<DownloadsService> log)
{
_httpClientFactory = httpClientFactory;
var baseUrl = config["DorkNet:Downloads:BaseUrl"]?.Trim();
if (!string.IsNullOrEmpty(baseUrl) && Uri.TryCreate(baseUrl.TrimEnd('/') + "/", UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
_baseUri = uri;
}
var host = config["DorkNet:Downloads:Host"]?.Trim();
_host = string.IsNullOrEmpty(host) ? null : host;
if (_baseUri is null)
log.LogInformation("[downloads] no DorkNet:Downloads:BaseUrl; the Downloads page and Quest latest-build install are off");
else
log.LogInformation("[downloads] reading builds from {BaseUrl}{Host}", _baseUri, _host is null ? "" : $" (Host: {_host})");
}
public bool IsConfigured => _baseUri is not null;
/// <summary>The published manifest, or null when none has been published yet.</summary>
public async Task<JsonObject?> GetManifestAsync(CancellationToken ct)
{
using var res = await SendAsync("manifest.json", range: null, ct);
if (res.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
res.EnsureSuccessStatusCode();
return JsonNode.Parse(await res.Content.ReadAsStringAsync(ct)) as JsonObject;
}
/// <summary>A manifest entry by its bucket path, or null when the manifest
/// doesn't list it. Only listed files are ever served.</summary>
public static JsonObject? FindFile(JsonObject manifest, string path)
{
if (manifest["releases"] is not JsonArray releases) return null;
return releases.OfType<JsonObject>()
.SelectMany(r => r["files"] is JsonArray files ? files.OfType<JsonObject>() : [])
.FirstOrDefault(f => string.Equals((string?)f["path"], path, StringComparison.Ordinal));
}
/// <summary>GET a file, forwarding the caller's Range header. The caller
/// owns the response and streams its content.</summary>
public Task<HttpResponseMessage> GetFileAsync(string path, string? range, CancellationToken ct)
=> SendAsync(path, range, ct);
private async Task<HttpResponseMessage> SendAsync(string path, string? range, CancellationToken ct)
{
if (_baseUri is null) throw new InvalidOperationException("Downloads are not configured.");
var url = new Uri(_baseUri, string.Join('/', path.Split('/').Select(Uri.EscapeDataString)));
using var req = new HttpRequestMessage(HttpMethod.Get, url);
if (_host is not null) req.Headers.Host = _host;
if (!string.IsNullOrEmpty(range) && RangeHeaderValue.TryParse(range, out var r)) req.Headers.Range = r;
var client = _httpClientFactory.CreateClient(HttpClientName);
// The timeout covers only getting the headers: with ResponseHeadersRead
// the token is released once they arrive, and disposing the source
// here stops its timer before the body is streamed.
using var headers = CancellationTokenSource.CreateLinkedTokenSource(ct);
headers.CancelAfter(HeaderTimeout);
try
{
return await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, headers.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new TimeoutException($"The downloads bucket didn't answer within {HeaderTimeout.TotalSeconds:0} s.");
}
}
}
@@ -238,6 +238,9 @@ public static class RequestTracingExtensions
return false; return false;
var path = request.Path.Value ?? ""; var path = request.Path.Value ?? "";
// Client builds are streamed through (several GB): never buffer them.
if (path.StartsWith("/api/site/v1/downloads/files/", StringComparison.OrdinalIgnoreCase))
return false;
var ext = Path.GetExtension(path); var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext)) if (string.IsNullOrEmpty(ext))
return true; return true;
@@ -217,6 +217,13 @@ public static class ServiceCollectionExtensions
// so a deployment with no GitHub credentials behaves normally. // so a deployment with no GitHub credentials behaves normally.
services.AddSingleton<IGitHubIssues, GitHubIssueService>(); services.AddSingleton<IGitHubIssues, GitHubIssueService>();
services.AddHostedService<TestCaseIssueReconciler>(); services.AddHostedService<TestCaseIssueReconciler>();
// CI-published client builds for the site's Downloads / Quest pages.
// Off (IsConfigured=false) without DorkNet:Downloads:BaseUrl. No client
// timeout: a multi-GB file streams for longer than the default 100 s.
// DownloadsService limits the wait for headers itself, and the
// request's own abort token ends the stream.
services.AddHttpClient(DownloadsService.HttpClientName, c => c.Timeout = Timeout.InfiniteTimeSpan);
services.AddSingleton<DownloadsService>();
// Scoped (per-request) — these services hold DbContext references and // Scoped (per-request) — these services hold DbContext references and
// must share the request's scope to participate in the request's // must share the request's scope to participate in the request's
// transaction lifecycle. Pre-PR-3 they were Singleton with in-process // transaction lifecycle. Pre-PR-3 they were Singleton with in-process
+2
View File
@@ -8,6 +8,7 @@ import { Players } from './pages/Players';
import { PlayerProfile } from './pages/PlayerProfile'; import { PlayerProfile } from './pages/PlayerProfile';
import { Rooms } from './pages/Rooms'; import { Rooms } from './pages/Rooms';
import { Join } from './pages/Join'; import { Join } from './pages/Join';
import { Downloads } from './pages/Downloads';
import { NotFound } from './pages/NotFound'; import { NotFound } from './pages/NotFound';
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest. // The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest.
@@ -27,6 +28,7 @@ export function App() {
<Route path="/rooms" element={<Rooms />} /> <Route path="/rooms" element={<Rooms />} />
<Route path="/join" element={<Join />} /> <Route path="/join" element={<Join />} />
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} /> <Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
<Route path="/downloads" element={<Downloads />} />
{/* Legacy paths from the vanilla-JS feed.localhost site */} {/* Legacy paths from the vanilla-JS feed.localhost site */}
<Route path="/p/:id" element={<RedirectPhoto />} /> <Route path="/p/:id" element={<RedirectPhoto />} />
<Route path="/u/:id" element={<RedirectPlayer />} /> <Route path="/u/:id" element={<RedirectPlayer />} />
+11 -2
View File
@@ -1,6 +1,7 @@
import { NavLink, Link, useLocation, useNavigate } from 'react-router'; import { NavLink, Link, useLocation, useNavigate } from 'react-router';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { BrandMark } from './BrandMark'; import { BrandMark } from './BrandMark';
import { getDownloads } from '../lib/downloads';
const NAV = [ const NAV = [
{ to: '/', label: 'Home', exact: true }, { to: '/', label: 'Home', exact: true },
@@ -10,6 +11,8 @@ const NAV = [
{ to: '/join', label: 'Join' }, { to: '/join', label: 'Join' },
{ to: '/quest', label: 'Quest' }, { to: '/quest', label: 'Quest' },
]; ];
// Shown only when the server offers downloads (DorkNet:Downloads:BaseUrl).
const DOWNLOADS = { to: '/downloads', label: 'Downloads', exact: false };
export function Header() { export function Header() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -17,6 +20,12 @@ export function Header() {
const nav = useNavigate(); const nav = useNavigate();
const loc = useLocation(); const loc = useLocation();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [hasDownloads, setHasDownloads] = useState(false);
const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV;
useEffect(() => {
getDownloads().then((m) => setHasDownloads(m !== null)).catch(() => {});
}, []);
// Close the mobile drawer on route change so tapping a nav item // Close the mobile drawer on route change so tapping a nav item
// doesn't leave a half-open overlay covering the page. // doesn't leave a half-open overlay covering the page.
@@ -37,7 +46,7 @@ export function Header() {
</Link> </Link>
<nav className="hidden md:flex items-center gap-1 ml-2"> <nav className="hidden md:flex items-center gap-1 ml-2">
{NAV.map(n => ( {navItems.map(n => (
<NavLink <NavLink
key={n.to} key={n.to}
to={n.to} to={n.to}
@@ -85,7 +94,7 @@ export function Header() {
/> />
</form> </form>
<nav className="flex flex-col gap-1"> <nav className="flex flex-col gap-1">
{NAV.map(n => ( {navItems.map(n => (
<NavLink <NavLink
key={n.to} key={n.to}
to={n.to} to={n.to}
+59
View File
@@ -0,0 +1,59 @@
// The client builds CI publishes (ci/downloads/publish.py), served through
// the server's DownloadsController. The manifest keeps the snake_case keys
// publish.py writes.
import { ApiError, get } from './api';
export interface DownloadFile {
id: string; // pc-full | pc-patch | quest | quest-installer
platform: 'pc' | 'quest' | string;
name: string;
path: string;
size: number;
sha256: string;
build?: string;
commit?: string;
built_at?: string;
}
export interface DownloadRelease {
id: string;
title: string;
game_version?: string;
pc_exe?: string;
files: DownloadFile[];
}
export interface DownloadsManifest {
releases: DownloadRelease[];
updated?: string;
}
let cached: Promise<DownloadsManifest | null> | null = null;
/** The manifest, or null when this server doesn't offer downloads. Fetched
* once per page load; pass `fresh` to re-read it. */
export function getDownloads(fresh = false): Promise<DownloadsManifest | null> {
if (!cached || fresh) {
cached = get<DownloadsManifest>('/downloads').catch((e) => {
if (e instanceof ApiError && e.status === 404) return null;
cached = null;
throw e;
});
}
return cached;
}
export const fileUrl = (f: DownloadFile) =>
`/api/site/v1/downloads/files/${f.path.split('/').map(encodeURIComponent).join('/')}`;
export interface QuestBuild { release: DownloadRelease; file: DownloadFile }
/** A published Quest package: the one at `path` when given, otherwise the
* newest release's. Null when there's no match. */
export function questBuild(m: DownloadsManifest | null, path?: string | null): QuestBuild | null {
for (const release of m?.releases ?? []) {
const file = release.files.find((f) => f.id === 'quest' && (!path || f.path === path));
if (file) return { release, file };
}
return null;
}
+2 -2
View File
@@ -95,7 +95,7 @@ export async function installXapk(
let done = 0; let done = 0;
const total = xapk.apk.size; const total = xapk.apk.size;
onProgress({ phase: 'app', label: 'Installing the app', done, total }); onProgress({ phase: 'app', label: 'Installing the app', done, total });
const stream = counted(await entryStream(xapk.file, xapk.apk), (n) => { const stream = counted(await entryStream(xapk.source, xapk.apk), (n) => {
done += n; done += n;
onProgress({ phase: 'app', label: 'Installing the app', done, total }); onProgress({ phase: 'app', label: 'Installing the app', done, total });
}); });
@@ -124,7 +124,7 @@ export async function installXapk(
let done = 0; let done = 0;
onProgress({ phase: 'data', label: 'Copying game data', done, total }); onProgress({ phase: 'data', label: 'Copying game data', done, total });
const stream = counted(await entryStream(xapk.file, entry), (n) => { const stream = counted(await entryStream(xapk.source, entry), (n) => {
done += n; done += n;
onProgress({ phase: 'data', label: 'Copying game data', done, total }); onProgress({ phase: 'data', label: 'Copying game data', done, total });
}); });
+67 -17
View File
@@ -1,9 +1,59 @@
// Read a DorkNet .xapk (zip) straight from a File the player picked, without // Read a DorkNet .xapk (zip) without loading it into memory, either from a
// loading it into memory. DorkNet.Quest/scripts/build.py stores the APK and // File the player picked or from a URL that answers HTTP Range requests (the
// the OBB uncompressed, so those entries are plain byte ranges of the file and // server's latest published build). DorkNet.Quest/scripts/build.py stores the
// stream out with Blob.slice(); manifest.json is deflated and goes through // APK and the OBB uncompressed, so those entries are plain byte ranges that
// stream straight through; manifest.json is deflated and goes through
// DecompressionStream. Handles zip64 so packages over 4 GB still open. // DecompressionStream. Handles zip64 so packages over 4 GB still open.
/** Random access to the package's bytes; `end` is exclusive. */
export interface ByteSource {
name: string;
size: number;
read(start: number, end: number): Promise<ArrayBuffer>;
stream(start: number, end: number): Promise<ReadableStream<Uint8Array<ArrayBuffer>>>;
}
export function fileSource(file: File): ByteSource {
return {
name: file.name,
size: file.size,
read: (start, end) => file.slice(start, end).arrayBuffer(),
stream: async (start, end) => file.slice(start, end).stream(),
};
}
/** A package on the server. `size` comes from the downloads manifest; every
* read is one Range request. */
export function urlSource(url: string, name: string, size: number): ByteSource {
const get = async (start: number, end: number): Promise<Response> => {
let res: Response;
try {
res = await fetch(url, { headers: { Range: `bytes=${start}-${end - 1}` }, cache: 'no-store' });
} catch {
// A cross-origin redirect (an expired sign-in at an access proxy) also lands here.
throw new Error("Couldn't reach the server. Reload the page (you may need to sign in again) and try again.");
}
if (res.status !== 206) {
throw new Error(res.status === 200
? "The server doesn't support partial downloads, so the package can't be read in place."
: `Downloading the package failed (HTTP ${res.status}).`);
}
return res;
};
const empty = () => new ReadableStream<Uint8Array<ArrayBuffer>>({ start: (c) => c.close() });
return {
name,
size,
read: async (start, end) => (end <= start ? new ArrayBuffer(0) : (await get(start, end)).arrayBuffer()),
stream: async (start, end) => {
if (end <= start) return empty();
const body = (await get(start, end)).body;
if (!body) throw new Error('The server sent an empty response.');
return body;
},
};
}
export interface XapkEntry { export interface XapkEntry {
name: string; name: string;
method: number; // 0 = stored, 8 = deflate method: number; // 0 = stored, 8 = deflate
@@ -26,7 +76,7 @@ export interface XapkManifest {
} }
export interface Xapk { export interface Xapk {
file: File; source: ByteSource;
manifest: XapkManifest; manifest: XapkManifest;
entries: Map<string, XapkEntry>; entries: Map<string, XapkEntry>;
apk: XapkEntry; apk: XapkEntry;
@@ -39,18 +89,18 @@ const ZIP64_EOCD = 0x06064b50;
const CENTRAL = 0x02014b50; const CENTRAL = 0x02014b50;
const LOCAL = 0x04034b50; const LOCAL = 0x04034b50;
async function bytes(file: Blob, start: number, end: number): Promise<DataView> { async function bytes(src: ByteSource, start: number, end: number): Promise<DataView> {
return new DataView(await file.slice(start, end).arrayBuffer()); return new DataView(await src.read(start, end));
} }
function u64(v: DataView, at: number): number { function u64(v: DataView, at: number): number {
return Number(v.getBigUint64(at, true)); return Number(v.getBigUint64(at, true));
} }
export async function openXapk(file: File): Promise<Xapk> { export async function openXapk(src: ByteSource): Promise<Xapk> {
// End of central directory: last 22 bytes plus up to 64 KiB of comment. // End of central directory: last 22 bytes plus up to 64 KiB of comment.
const tailStart = Math.max(0, file.size - (22 + 0xffff + 20)); const tailStart = Math.max(0, src.size - (22 + 0xffff + 20));
const tail = await bytes(file, tailStart, file.size); const tail = await bytes(src, tailStart, src.size);
let eocd = -1; let eocd = -1;
for (let i = tail.byteLength - 22; i >= 0; i--) { for (let i = tail.byteLength - 22; i >= 0; i--) {
if (tail.getUint32(i, true) === EOCD) { eocd = i; break; } if (tail.getUint32(i, true) === EOCD) { eocd = i; break; }
@@ -65,14 +115,14 @@ export async function openXapk(file: File): Promise<Xapk> {
const loc = eocd - 20; const loc = eocd - 20;
if (loc < 0 || tail.getUint32(loc, true) !== ZIP64_LOCATOR) throw new Error('Unsupported zip64 layout.'); if (loc < 0 || tail.getUint32(loc, true) !== ZIP64_LOCATOR) throw new Error('Unsupported zip64 layout.');
const z64At = u64(tail, loc + 8); const z64At = u64(tail, loc + 8);
const z64 = await bytes(file, z64At, z64At + 56); const z64 = await bytes(src, z64At, z64At + 56);
if (z64.getUint32(0, true) !== ZIP64_EOCD) throw new Error('Unsupported zip64 layout.'); if (z64.getUint32(0, true) !== ZIP64_EOCD) throw new Error('Unsupported zip64 layout.');
count = u64(z64, 32); count = u64(z64, 32);
cdSize = u64(z64, 40); cdSize = u64(z64, 40);
cdOffset = u64(z64, 48); cdOffset = u64(z64, 48);
} }
const cd = await bytes(file, cdOffset, cdOffset + cdSize); const cd = await bytes(src, cdOffset, cdOffset + cdSize);
const entries = new Map<string, XapkEntry>(); const entries = new Map<string, XapkEntry>();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let p = 0; let p = 0;
@@ -109,7 +159,7 @@ export async function openXapk(file: File): Promise<Xapk> {
const manifestEntry = entries.get('manifest.json'); const manifestEntry = entries.get('manifest.json');
if (!manifestEntry) throw new Error('Not an .xapk: manifest.json is missing.'); if (!manifestEntry) throw new Error('Not an .xapk: manifest.json is missing.');
const manifest = JSON.parse(await new Response(await entryStream(file, manifestEntry)).text()) as XapkManifest; const manifest = JSON.parse(await new Response(await entryStream(src, manifestEntry)).text()) as XapkManifest;
if (!manifest.package_name) throw new Error('manifest.json has no package_name.'); if (!manifest.package_name) throw new Error('manifest.json has no package_name.');
const apk = entries.get(`${manifest.package_name}.apk`) const apk = entries.get(`${manifest.package_name}.apk`)
@@ -122,15 +172,15 @@ export async function openXapk(file: File): Promise<Xapk> {
return { entry, installPath: x.install_path }; return { entry, installPath: x.install_path };
}); });
return { file, manifest, entries, apk, expansions }; return { source: src, manifest, entries, apk, expansions };
} }
/** The entry's uncompressed bytes as a stream. */ /** The entry's uncompressed bytes as a stream. */
export async function entryStream(file: File, entry: XapkEntry): Promise<ReadableStream<Uint8Array>> { export async function entryStream(src: ByteSource, entry: XapkEntry): Promise<ReadableStream<Uint8Array<ArrayBuffer>>> {
const local = await bytes(file, entry.localHeaderOffset, entry.localHeaderOffset + 30); const local = await bytes(src, entry.localHeaderOffset, entry.localHeaderOffset + 30);
if (local.getUint32(0, true) !== LOCAL) throw new Error(`Corrupt entry ${entry.name}.`); if (local.getUint32(0, true) !== LOCAL) throw new Error(`Corrupt entry ${entry.name}.`);
const start = entry.localHeaderOffset + 30 + local.getUint16(26, true) + local.getUint16(28, true); const start = entry.localHeaderOffset + 30 + local.getUint16(26, true) + local.getUint16(28, true);
const raw = file.slice(start, start + entry.compressedSize).stream(); const raw = await src.stream(start, start + entry.compressedSize);
if (entry.method === 0) return raw; if (entry.method === 0) return raw;
if (entry.method === 8) return raw.pipeThrough(new DecompressionStream('deflate-raw')); if (entry.method === 8) return raw.pipeThrough(new DecompressionStream('deflate-raw'));
throw new Error(`Unsupported compression in ${entry.name}.`); throw new Error(`Unsupported compression in ${entry.name}.`);
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { fileUrl, getDownloads, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
const fmtSize = (n: number) =>
n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 2 ** 10))} KB`;
const fmtDate = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(+d) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
};
const PLATFORMS = [
{ id: 'pc', title: 'PC', hint: 'Windows, with or without a VR headset.' },
{ id: 'quest', title: 'Meta Quest', hint: 'Needs developer mode on your headset and a USB cable.' },
];
// What each file is for, in players' terms. Keyed by the ids publish.py writes.
function describe(f: DownloadFile, rel: DownloadRelease): { title: string; primary?: boolean; body: React.ReactNode } {
switch (f.id) {
case 'pc-full':
return {
title: 'Full game', primary: true,
body: <>Rec Room {rel.title} with DorkNet already set up. Unzip it anywhere and
run {rel.pc_exe ? <code className="text-ink-200">{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', primary: true,
body: <>Install it straight from this site: plug in your headset and use the <Link className="text-brand-300 hover:text-brand-200" to="/quest">Quest
page</Link> (Chrome or Edge). Or download it for SideQuest.</>,
};
case 'quest-installer':
return {
title: 'Install scripts',
body: <>Prefer the command line? Unzip these next to the Quest app and run <code className="text-ink-200">install.cmd</code> (Windows)
or <code className="text-ink-200">install.sh</code> (macOS / Linux). Needs adb.</>,
};
default:
return { title: f.name, body: null };
}
}
function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) {
const d = describe(f, rel);
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(f.sha256);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* clipboard blocked: the full hash is in the tooltip */ }
};
return (
<article className={`card !p-5 flex flex-col gap-3 ${d.primary ? 'border-brand-500/50 ring-2 ring-brand-500/15' : ''}`}>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-ink-50">{d.title}</h3>
{d.primary && <span className="badge bg-brand-500/15 text-brand-200">Start here</span>}
</div>
<p className="text-sm text-ink-300">{d.body}</p>
<div className="font-mono text-xs text-ink-400 break-all">{f.name}</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-ink-400">
<span>{fmtSize(f.size)}</span>
{f.built_at && <span>Updated {fmtDate(f.built_at)}</span>}
{f.build && <span>Build {f.build}</span>}
</div>
<div className="mt-auto flex flex-wrap items-center gap-2 pt-1">
{f.id === 'quest' ? (
<>
<Link className="btn-primary" to={`/quest?file=${encodeURIComponent(f.path)}`}>Install on Quest</Link>
<a className="btn-secondary" href={fileUrl(f)} download={f.name}>Download</a>
</>
) : (
<a className={d.primary ? 'btn-primary' : 'btn-secondary'} href={fileUrl(f)} download={f.name}>Download</a>
)}
<button
type="button"
onClick={copy}
title={`Copy SHA-256: ${f.sha256}`}
className={`rounded-md border border-dashed px-2 py-1 font-mono text-xs transition-colors ${
copied ? 'border-success text-success' : 'border-ink-700 text-ink-400 hover:text-ink-100'}`}
>
{copied ? 'Copied' : `SHA-256 ${f.sha256.slice(0, 12)}…`}
</button>
</div>
</article>
);
}
export function Downloads() {
const [m, setM] = useState<DownloadsManifest | null | undefined>(undefined);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
getDownloads(true).then(setM).catch((e) => setErr((e as Error).message));
}, []);
const releases = (m?.releases ?? []).filter((r) => r.files.length);
return (
<div className="max-w-4xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-semibold text-ink-50">Downloads</h1>
<p className="text-sm text-ink-400">
Rec Room for DorkNet, built from the DorkNet source by the build server. The SHA-256 next to each file
lets you check your download.
</p>
</div>
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">Couldn't load the builds: {err}</div>}
{m === undefined && !err && <p className="text-sm text-ink-400">Loading builds…</p>}
{m === null && <p className="text-sm text-ink-400">This server doesn't offer downloads.</p>}
{m && !releases.length && <p className="text-sm text-ink-400">No builds have been published yet.</p>}
{releases.map((rel) => (
<section key={rel.id} className="space-y-5">
<div className="flex flex-wrap items-baseline gap-x-3">
<h2 className="text-lg font-semibold text-ink-50">Rec Room {rel.title}</h2>
{rel.game_version && <span className="text-xs text-ink-400">Game build of {fmtDate(`${rel.game_version}T12:00:00Z`)}</span>}
</div>
{PLATFORMS.map((p) => {
const files = rel.files.filter((f) => f.platform === p.id);
if (!files.length) return null;
return (
<div key={p.id} className="space-y-2">
<div>
<h3 className="label">{p.title}</h3>
<p className="text-xs text-ink-400">{p.hint}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{files.map((f) => <FileCard key={f.id} f={f} rel={rel} />)}
</div>
</div>
);
})}
</section>
))}
{releases.length > 0 && (
<p className="border-t border-ink-800 pt-4 text-xs text-ink-400">
These builds contain Rec Room's own game files. They're for this community only: please don't re-share them.
</p>
)}
</div>
);
}
+48 -12
View File
@@ -1,6 +1,8 @@
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { openXapk, type Xapk } from '../lib/xapk'; import { useSearchParams } from 'react-router';
import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk';
import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall'; import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall';
import { fileUrl, getDownloads, questBuild, type QuestBuild } from '../lib/downloads';
const fmtSize = (n: number) => const fmtSize = (n: number) =>
n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`; n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`;
@@ -43,15 +45,36 @@ export function Quest() {
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
// The server's latest published Quest build, if it offers downloads.
const [latest, setLatest] = useState<QuestBuild | null>(null);
const [params] = useSearchParams();
const pickFile = async (f: File | undefined) => { const open = async (load: () => Promise<Xapk>) => {
if (!f) return;
setErr(null); setDone(false); setProgress({}); setBusy('file'); setErr(null); setDone(false); setProgress({}); setBusy('file');
try { setXapk(await openXapk(f)); } try { setXapk(await load()); }
catch (e) { setXapk(null); setErr((e as Error).message); } catch (e) { setXapk(null); setErr((e as Error).message); }
finally { setBusy(null); } finally { setBusy(null); }
}; };
const pickFile = async (f: File | undefined) => {
if (f) await open(() => openXapk(fileSource(f)));
};
const pickBuild = async (b: QuestBuild | null) => {
if (b) await open(() => openXapk(urlSource(fileUrl(b.file), b.file.name, b.file.size)));
};
useEffect(() => {
getDownloads().then((m) => {
setLatest(questBuild(m));
// Arriving from a Quest card's "Install on Quest" on the Downloads page:
// that card's own file, which may not be the newest release's.
const wanted = params.get('file');
if (wanted) void pickBuild(questBuild(m, wanted));
}).catch(() => { /* no downloads: only the file picker */ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const connect = async () => { const connect = async () => {
setErr(null); setBusy('connect'); setErr(null); setBusy('connect');
try { setHeadset(await connectHeadset()); } try { setHeadset(await connectHeadset()); }
@@ -108,19 +131,32 @@ export function Quest() {
</div> </div>
)} )}
<Step n={1} title="Choose the .xapk file" done={!!xapk}> <Step n={1} title={latest ? 'Pick the build' : 'Choose the .xapk file'} done={!!xapk}>
{latest ? (
<p className="text-sm text-ink-400">
Install the latest build straight from this server: nothing to download first, it streams to the
headset during the install. Or pick a <code className="text-ink-200">.xapk</code> you already have.
</p>
) : (
<p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p> <p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p>
)}
<input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} /> <input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} />
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<button className="btn-secondary" disabled={busy !== null} onClick={() => fileRef.current?.click()}> {latest && (
{busy === 'file' ? 'Reading…' : xapk ? 'Choose a different file' : 'Choose file'} <button className="btn-primary" disabled={busy !== null} onClick={() => pickBuild(latest)}>
{busy === 'file' ? 'Reading…' : `Latest build: ${latest.release.title}${latest.file.build ? ` (build ${latest.file.build})` : ''}`}
</button> </button>
{xapk && (
<span className="text-sm text-ink-300">
<span className="text-ink-100 font-medium">{name}</span> {xapk.manifest.version_name} · {fmtSize(totalSize)}
</span>
)} )}
<button className="btn-secondary" disabled={busy !== null} onClick={() => fileRef.current?.click()}>
{busy === 'file' && !latest ? 'Reading…' : xapk && !latest ? 'Choose a different file' : 'Choose file'}
</button>
</div> </div>
{xapk && (
<p className="text-sm text-ink-300">
Selected: <span className="text-ink-100 font-medium">{name}</span> {xapk.manifest.version_name} · {fmtSize(totalSize)}
<span className="text-ink-500"> · {xapk.source.name}</span>
</p>
)}
</Step> </Step>
<Step n={2} title="Connect your headset" done={!!headset}> <Step n={2} title="Connect your headset" done={!!headset}>
Vendored
+14 -48
View File
@@ -1,20 +1,17 @@
// DorkNet client builds — PC (patcher zip + full client) and Quest (.xapk). // DorkNet client builds — PC (patcher zip + full client) and Quest (.xapk).
// //
// Runs on the "dorknet-builder" agent from ci/jenkins (an inbound agent with // Runs on the Jenkins image from ci/jenkins (the stock image plus Python with
// Python + LIEF, .NET, the Android NDK/build-tools, apktool, restic and rclone). // LIEF, .NET, the Android NDK/build-tools, apktool, restic and rclone).
// //
// Stock builds come out of the restic build archive on the FTP. Where it lives // Stock builds come out of the restic build archive on the FTP. Where it lives
// is server configuration, not part of the repo: the FTP host, login and the // is server configuration, not part of the repo: the FTP host, login and the
// restic password are Jenkins credentials, and the repository folders come // restic password are Jenkins credentials, and the repository folders come
// from the agent's environment (ARCHIVE_PC_REPO / ARCHIVE_QUEST_REPO), each // from the Jenkins container's environment (ARCHIVE_PC_REPO /
// overridable per run. A build is picked by snapshot tags. // ARCHIVE_QUEST_REPO), each overridable per run. A build is picked by
// snapshot tags.
// //
// On a push, only the platforms whose code changed are rebuilt. A manual run // On a push, only the platforms whose code changed are rebuilt. A manual run
// picks the platforms with PLATFORMS. See ci/jenkins/README.md. // picks the platforms with PLATFORMS. See ci/jenkins/README.md.
//
// Finished builds are renamed for players and published to the downloads bucket
// (ci/downloads) unless PUBLISH_DOWNLOADS is off. The build scripts keep their
// own file names, which the launcher depends on.
// The builds this branch targets. Any other archived build can be picked per // The builds this branch targets. Any other archived build can be picked per
// run with the *_SNAPSHOT_TAGS parameters. // run with the *_SNAPSHOT_TAGS parameters.
@@ -22,16 +19,11 @@ def PC_VERSION_KEY = 'march_2023_03_21'
def PC_DEFAULT_TAGS = 'manifest=7490748483298966814' // Steam 2023-03-21 (appVersion 20230317) def PC_DEFAULT_TAGS = 'manifest=7490748483298966814' // Steam 2023-03-21 (appVersion 20230317)
def QUEST_DEFAULT_TAGS = 'version-code=2128210' // Quest 20230317 def QUEST_DEFAULT_TAGS = 'version-code=2128210' // Quest 20230317
// How this branch's builds are listed on the site's Downloads page.
def RELEASE_ID = '2023.03'
def RELEASE_TITLE = 'March 2023'
def RELEASE_GAME_VERSION = '2023-03-21'
def buildPc = false def buildPc = false
def buildQuest = false def buildQuest = false
pipeline { pipeline {
agent { label 'dorknet-builder' } agent any
options { options {
timestamps() timestamps()
@@ -53,13 +45,11 @@ pipeline {
string(name: 'QUEST_SNAPSHOT_TAGS', defaultValue: '', string(name: 'QUEST_SNAPSHOT_TAGS', defaultValue: '',
description: "restic tags picking the stock Quest build, comma = AND. Empty: ${QUEST_DEFAULT_TAGS}") description: "restic tags picking the stock Quest build, comma = AND. Empty: ${QUEST_DEFAULT_TAGS}")
string(name: 'PC_REPO', defaultValue: '', string(name: 'PC_REPO', defaultValue: '',
description: 'PC restic repository folder on the FTP. Empty: ARCHIVE_PC_REPO set on the agent.') description: 'PC restic repository folder on the FTP. Empty: ARCHIVE_PC_REPO from the Jenkins environment.')
string(name: 'QUEST_REPO', defaultValue: '', string(name: 'QUEST_REPO', defaultValue: '',
description: 'Quest restic repository folder on the FTP. Empty: ARCHIVE_QUEST_REPO set on the agent.') description: 'Quest restic repository folder on the FTP. Empty: ARCHIVE_QUEST_REPO from the Jenkins environment.')
booleanParam(name: 'LIST_SNAPSHOTS', defaultValue: false, booleanParam(name: 'LIST_SNAPSHOTS', defaultValue: false,
description: 'Only list the snapshots matching the tags in both repositories (to find a build), then stop.') description: 'Only list the snapshots matching the tags in both repositories (to find a build), then stop.')
booleanParam(name: 'PUBLISH_DOWNLOADS', defaultValue: true,
description: 'Publish what this run built to the downloads bucket (listed on the site Downloads page). Push builds publish too.')
} }
stages { stages {
@@ -98,8 +88,8 @@ pipeline {
"PC_TAGS=${params.PC_SNAPSHOT_TAGS}", "QUEST_TAGS=${params.QUEST_SNAPSHOT_TAGS}"]) { "PC_TAGS=${params.PC_SNAPSHOT_TAGS}", "QUEST_TAGS=${params.QUEST_SNAPSHOT_TAGS}"]) {
sh ''' sh '''
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic" export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
pc="${PC_REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on the agent or PC_REPO on the run}}" pc="${PC_REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on Jenkins or PC_REPO on the run}}"
quest="${QUEST_REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on the agent or QUEST_REPO on the run}}" quest="${QUEST_REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on Jenkins or QUEST_REPO on the run}}"
echo "== PC: $pc"; archive-restore list "$pc" "$PC_TAGS" echo "== PC: $pc"; archive-restore list "$pc" "$PC_TAGS"
echo "== Quest: $quest"; archive-restore list "$quest" "$QUEST_TAGS" echo "== Quest: $quest"; archive-restore list "$quest" "$QUEST_TAGS"
''' '''
@@ -123,7 +113,7 @@ pipeline {
withEnv(["REPO_OVERRIDE=${params.PC_REPO}", "TAGS=${params.PC_SNAPSHOT_TAGS ?: PC_DEFAULT_TAGS}"]) { withEnv(["REPO_OVERRIDE=${params.PC_REPO}", "TAGS=${params.PC_SNAPSHOT_TAGS ?: PC_DEFAULT_TAGS}"]) {
sh(returnStdout: true, script: ''' sh(returnStdout: true, script: '''
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic" export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
repo="${REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on the agent or PC_REPO on the run}}" repo="${REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on Jenkins or PC_REPO on the run}}"
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/pc" --exclude /.DepotDownloader archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/pc" --exclude /.DepotDownloader
''').trim().readLines().last() ''').trim().readLines().last()
} }
@@ -151,7 +141,7 @@ pipeline {
withEnv(["REPO_OVERRIDE=${params.QUEST_REPO}", "TAGS=${params.QUEST_SNAPSHOT_TAGS ?: QUEST_DEFAULT_TAGS}"]) { withEnv(["REPO_OVERRIDE=${params.QUEST_REPO}", "TAGS=${params.QUEST_SNAPSHOT_TAGS ?: QUEST_DEFAULT_TAGS}"]) {
sh(returnStdout: true, script: ''' sh(returnStdout: true, script: '''
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic" export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
repo="${REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on the agent or QUEST_REPO on the run}}" repo="${REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on Jenkins or QUEST_REPO on the run}}"
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/quest" archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/quest"
''').trim().readLines().last() ''').trim().readLines().last()
} }
@@ -189,39 +179,15 @@ pipeline {
} }
} }
} }
stage('Downloads') {
when { expression { buildPc || buildQuest } }
steps {
sh """
python3 ci/downloads/publish.py stage --out out --release ${RELEASE_ID} \\
--title '${RELEASE_TITLE}' --game-version ${RELEASE_GAME_VERSION} --version-key ${PC_VERSION_KEY}
"""
script {
if (params.PUBLISH_DOWNLOADS) {
// The bucket's S3 endpoint and name come from the agent's
// environment (DOWNLOADS_S3_ENDPOINT / DOWNLOADS_BUCKET).
withCredentials([usernamePassword(credentialsId: 'downloads-s3',
usernameVariable: 'DOWNLOADS_S3_KEY_ID', passwordVariable: 'DOWNLOADS_S3_SECRET')]) {
sh 'python3 ci/downloads/publish.py upload --staged out/downloads'
}
} else {
echo 'PUBLISH_DOWNLOADS is off: the files are only archived.'
}
}
}
}
} }
post { post {
success { success {
// The renamed, player-facing files (and release.json): the same set the archiveArtifacts artifacts: 'out/**', allowEmptyArchive: true, fingerprint: true
// downloads page offers.
archiveArtifacts artifacts: 'out/downloads/**', allowEmptyArchive: true, fingerprint: true
} }
cleanup { cleanup {
// Runs after archiving. Keeps the restic cache and restored stock // Runs after archiving. Keeps the restic cache and restored stock
// builds ($DORKNET_CACHE on the agent, outside the workspace); drops // builds ($DORKNET_CACHE in jenkins_home, outside the workspace); drops
// the workspace's build output and scratch. // the workspace's build output and scratch.
cleanWs(deleteDirs: true, notFailBuild: true) cleanWs(deleteDirs: true, notFailBuild: true)
} }
-283
View File
@@ -1,283 +0,0 @@
#!/usr/bin/env python3
"""Publish DorkNet client builds to the downloads bucket.
stage the build output in out/ -> out/downloads/<release>/ under the
names players see, plus release.json describing those files
upload that folder -> the downloads bucket, then merge release.json into
the bucket's manifest.json
The DorkNet server reads the bucket and offers the files on its site (the
Downloads page, and Latest build on the Quest page); see docs/deploy.md.
The build scripts keep their own file names on purpose: the launcher fetches
dorknet-clientpatch-<VersionKey>.zip from GitHub Releases by that exact name.
Only the published copies are renamed:
dorknet-client-<key>.zip DorkNet-<release>-PC-Full.zip
dorknet-clientpatch-<key>.zip DorkNet-<release>-PC-Patch.zip
DorkNet-Quest-<ver>/DorkNet-<ver>.xapk DorkNet-<release>-Quest.xapk
DorkNet-Quest-<ver>/install.*, README DorkNet-<release>-Quest-Installer.zip
The server reads the bucket over plain HTTP on the internal network (e.g.
Garage's web endpoint); nothing needs to be public. upload talks to its S3
API with rclone; the endpoint, bucket and key come from the environment:
DOWNLOADS_S3_ENDPOINT S3 API URL, e.g. http://garage-<uuid>:3900
DOWNLOADS_BUCKET bucket name, e.g. dorknet-downloads
DOWNLOADS_S3_REGION optional, default garage
DOWNLOADS_S3_KEY_ID / DOWNLOADS_S3_SECRET
python3 ci/downloads/publish.py stage --out out --release 2023.03 \\
--title "March 2023" --game-version 2023-03-21 --version-key march_2023_03_21
python3 ci/downloads/publish.py upload --staged out/downloads
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import json
import os
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path
from typing import NoReturn
NO_CACHE = "Cache-Control: no-cache"
INSTALLER_NOTE = """Unzip these files into the same folder as {xapk}, then
run install.cmd (Windows) or ./install.sh (macOS / Linux).
"""
def fail(msg: str) -> NoReturn:
print(f"error: {msg}", file=sys.stderr)
sys.exit(1)
def step(msg: str) -> None:
print(f"==> {msg}", flush=True)
def human(n: int) -> str:
return f"{n / 2**20:,.1f} MB" if n >= 2**20 else f"{max(1, round(n / 2**10))} KB"
def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
while chunk := f.read(1 << 20):
h.update(chunk)
return h.hexdigest()
def link_or_copy(src: Path, dst: Path) -> None:
"""Hard-link where possible: the full client is several GB."""
dst.unlink(missing_ok=True)
try:
os.link(src, dst)
except OSError:
shutil.copy2(src, dst)
def game_exe(full_zip: Path) -> str | None:
"""The game's exe: the one with a matching <name>_Data folder at the root."""
with zipfile.ZipFile(full_zip) as z:
names = z.namelist()
tops = {n.split("/", 1)[0] for n in names}
exes = sorted(t for t in tops if t.lower().endswith(".exe") and f"{t[:-4]}_Data" in tops)
return exes[0] if exes else None
# ---- stage ----------------------------------------------------------------
def cmd_stage(a: argparse.Namespace) -> None:
out = Path(a.out)
dest = Path(a.dest) if a.dest else out / "downloads"
rel_dir = dest / a.release
if rel_dir.exists():
shutil.rmtree(rel_dir)
rel_dir.mkdir(parents=True)
prefix = f"DorkNet-{a.release}"
staged: list[tuple[str, str, Path]] = [] # (id, platform, staged file)
extra: dict[str, str] = {}
full = out / "pc" / f"dorknet-client-{a.version_key}.zip"
patch = out / "pc" / f"dorknet-clientpatch-{a.version_key}.zip"
if full.is_file():
dst = rel_dir / f"{prefix}-PC-Full.zip"
link_or_copy(full, dst)
staged.append(("pc-full", "pc", dst))
exe = game_exe(full)
if exe:
extra["pc_exe"] = exe
if patch.is_file():
dst = rel_dir / f"{prefix}-PC-Patch.zip"
link_or_copy(patch, dst)
staged.append(("pc-patch", "pc", dst))
packages = sorted((out / "quest").glob("DorkNet-Quest-*/"))
if len(packages) > 1:
fail(f"more than one Quest package folder in {out / 'quest'}: {', '.join(p.name for p in packages)}")
if packages:
pkg = packages[0]
xapks = sorted(pkg.glob("*.xapk"))
if len(xapks) != 1:
fail(f"expected one .xapk in {pkg}, found {len(xapks)}")
xapk = rel_dir / f"{prefix}-Quest.xapk"
link_or_copy(xapks[0], xapk)
staged.append(("quest", "quest", xapk))
installer = rel_dir / f"{prefix}-Quest-Installer.zip"
with zipfile.ZipFile(installer, "w", zipfile.ZIP_DEFLATED) as z:
for name in ("install.cmd", "install.ps1", "install.sh"):
src = pkg / name
if not src.is_file():
fail(f"{src} missing")
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
info.external_attr = (0o755 if name.endswith(".sh") else 0o644) << 16
info.compress_type = zipfile.ZIP_DEFLATED
z.writestr(info, src.read_bytes())
readme = pkg / "README.txt"
text = INSTALLER_NOTE.format(xapk=xapk.name) + (readme.read_text(encoding="utf-8") if readme.is_file() else "")
info = zipfile.ZipInfo("README.txt", date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
z.writestr(info, text)
staged.append(("quest-installer", "quest", installer))
if not staged:
fail(f"nothing to publish in {out}")
built_at = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()
files = []
for fid, platform, path in staged:
step(f"{path.name}: hashing")
files.append({
"id": fid,
"platform": platform,
"name": path.name,
"path": f"{a.release}/{path.name}",
"size": path.stat().st_size,
"sha256": sha256(path),
"build": a.build,
"commit": a.commit,
"built_at": built_at,
})
release = {"id": a.release, "title": a.title, "game_version": a.game_version, **extra, "files": files}
(dest / "release.json").write_text(json.dumps(release, indent=2) + "\n", encoding="utf-8")
print()
for f in files:
print(f" {f['name']} {human(f['size'])} {f['sha256'][:16]}…")
print(f"Staged {len(files)} file(s) in {rel_dir}")
# ---- upload ---------------------------------------------------------------
def rclone_env() -> dict[str, str]:
env = dict(os.environ)
for k in ("DOWNLOADS_S3_ENDPOINT", "DOWNLOADS_BUCKET", "DOWNLOADS_S3_KEY_ID", "DOWNLOADS_S3_SECRET"):
if not env.get(k):
fail(f"{k} is not set")
env.update({
"RCLONE_CONFIG_DL_TYPE": "s3",
"RCLONE_CONFIG_DL_PROVIDER": "Other",
"RCLONE_CONFIG_DL_ENDPOINT": env["DOWNLOADS_S3_ENDPOINT"],
"RCLONE_CONFIG_DL_REGION": env.get("DOWNLOADS_S3_REGION") or "garage",
"RCLONE_CONFIG_DL_ACCESS_KEY_ID": env["DOWNLOADS_S3_KEY_ID"],
"RCLONE_CONFIG_DL_SECRET_ACCESS_KEY": env["DOWNLOADS_S3_SECRET"],
"RCLONE_CONFIG_DL_FORCE_PATH_STYLE": "true",
"RCLONE_CONFIG_DL_NO_CHECK_BUCKET": "true",
})
return env
def rclone(env: dict[str, str], *args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
r = subprocess.run(["rclone", *args], env=env, input=stdin, capture_output=True, text=True)
if check and r.returncode != 0:
fail(f"rclone {args[0]} failed ({r.returncode}): {r.stderr.strip()}")
return r
def merge(manifest: dict, release: dict) -> dict:
"""Replace this release's files that were just built; keep the others
(a push build may rebuild only one platform)."""
releases = [r for r in manifest.get("releases", []) if r.get("id") != release["id"]]
old = next((r for r in manifest.get("releases", []) if r.get("id") == release["id"]), {})
new_ids = {f["id"] for f in release["files"]}
kept = [f for f in old.get("files", []) if f.get("id") not in new_ids]
merged = {**old, **release, "files": kept + release["files"]}
if "pc_exe" not in release and "pc_exe" in old:
merged["pc_exe"] = old["pc_exe"]
order = ["pc-full", "pc-patch", "quest", "quest-installer"]
merged["files"].sort(key=lambda f: order.index(f["id"]) if f["id"] in order else len(order))
releases.append(merged)
releases.sort(key=lambda r: r["id"], reverse=True)
out = {**manifest, "releases": releases,
"updated": datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()}
return out
def cmd_upload(a: argparse.Namespace) -> None:
staged = Path(a.staged)
release = json.loads((staged / "release.json").read_text(encoding="utf-8"))
env = rclone_env()
bucket = f"dl:{env['DOWNLOADS_BUCKET']}"
rel = release["id"]
# 1. the files, before the manifest that points at them
for f in release["files"]:
step(f"uploading {f['name']} ({human(f['size'])})")
rclone(env, "copyto", str(staged / f["path"]), f"{bucket}/{f['path']}",
"--header-upload", NO_CACHE, "--s3-upload-concurrency", "8", "--s3-chunk-size", "64M")
# 2. merge into the live manifest
r = rclone(env, "cat", f"{bucket}/manifest.json", check=False)
manifest = json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else {}
manifest = merge(manifest, release)
# 3. the manifest, last: it is what makes the new files visible
step("uploading manifest.json")
rclone(env, "rcat", f"{bucket}/manifest.json", "--header-upload", NO_CACHE,
"--header-upload", "Content-Type: application/json", stdin=json.dumps(manifest, indent=2) + "\n")
# 4. drop files of this release the manifest no longer lists (renames)
listed = {f["path"] for r in manifest["releases"] if r["id"] == rel for f in r["files"]}
r = rclone(env, "lsf", f"{bucket}/{rel}/", "--files-only", check=False)
for name in r.stdout.split():
if f"{rel}/{name}" not in listed:
step(f"removing stale {rel}/{name}")
rclone(env, "deletefile", f"{bucket}/{rel}/{name}")
print(f"Published {len(release['files'])} file(s) for {release['title']}.")
# ---- cli ------------------------------------------------------------------
def main() -> None:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("stage", help="out/ -> out/downloads/<release>/ + release.json")
s.add_argument("--out", default="out", help="the build output folder (default: out)")
s.add_argument("--dest", help="where to stage (default: <out>/downloads)")
s.add_argument("--release", required=True, help="release id used in file names and paths, e.g. 2023.03")
s.add_argument("--title", required=True, help='shown on the Downloads page, e.g. "March 2023"')
s.add_argument("--game-version", required=True, help="the stock build's date, e.g. 2023-03-21")
s.add_argument("--version-key", required=True, help="the PC build's version key, e.g. march_2023_03_21")
s.add_argument("--build", default=os.environ.get("BUILD_NUMBER", ""), help="CI build number (default: $BUILD_NUMBER)")
s.add_argument("--commit", default=os.environ.get("GIT_COMMIT", ""), help="source commit (default: $GIT_COMMIT)")
s.set_defaults(fn=cmd_stage)
u = sub.add_parser("upload", help="staged folder -> downloads bucket + manifest.json")
u.add_argument("--staged", default="out/downloads", help="the folder `stage` wrote (default: out/downloads)")
u.set_defaults(fn=cmd_upload)
a = p.parse_args()
a.fn(a)
if __name__ == "__main__":
main()
+12 -13
View File
@@ -1,7 +1,7 @@
# DorkNet build agent: a Jenkins inbound agent with everything the PC and # DorkNet Jenkins: the stock Jenkins image plus everything the PC and Quest
# Quest client builds need. It runs next to Jenkins on Coolify, connects to # client builds need, so the job (the Jenkinsfile at the repo root) runs right
# the controller over WebSocket, and runs the jobs labelled "dorknet-builder" # on the controller. Jobs, plugins and settings stay in the jenkins_home
# (the Jenkinsfile at the repo root). Jenkins itself stays on the stock image. # volume; only the image changes.
# #
# PC Python + .NET SDK tools/build_pc.py # PC Python + .NET SDK tools/build_pc.py
# Quest Python/LIEF + JDK + Android NDK, build-tools, apktool # Quest Python/LIEF + JDK + Android NDK, build-tools, apktool
@@ -9,13 +9,13 @@
# Builds restic + rclone stock builds restored from the restic # Builds restic + rclone stock builds restored from the restic
# archive on the FTP (archive-restore.sh) # archive on the FTP (archive-restore.sh)
# #
# Java 25: Jenkins supports it until Sep 2029 (Java 21 only until Sep 2027).
# Keep the agent's Java the same major version as the controller's.
#
# See ci/jenkins/README.md. # See ci/jenkins/README.md.
ARG AGENT_IMAGE=jenkins/inbound-agent:latest-jdk25 # Match this to the Jenkins image your Dokploy service runs today, so the
FROM ${AGENT_IMAGE} # upgrade keeps the same Jenkins version and Java: e.g.
# --build-arg JENKINS_IMAGE=jenkins/jenkins:2.492.3-lts-jdk17
ARG JENKINS_IMAGE=jenkins/jenkins:lts-jdk17
FROM ${JENKINS_IMAGE}
USER root USER root
@@ -73,10 +73,9 @@ RUN wget -q https://github.com/restic/restic/releases/download/v${RESTIC}/restic
COPY archive-restore.sh /usr/local/bin/archive-restore COPY archive-restore.sh /usr/local/bin/archive-restore
RUN chmod +x /usr/local/bin/archive-restore RUN chmod +x /usr/local/bin/archive-restore
# Restored stock builds and the restic cache. Mount a volume here so each # Restored stock builds and the restic cache live in the persistent
# multi-GB stock build is downloaded once, not after every restart. # jenkins_home volume, so each multi-GB build is downloaded once.
ENV DORKNET_CACHE=/home/jenkins/dorknet-cache ENV DORKNET_CACHE=/var/jenkins_home/dorknet-cache
RUN mkdir -p ${DORKNET_CACHE} && chown jenkins:jenkins ${DORKNET_CACHE}
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \ ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
DOTNET_NOLOGO=1 DOTNET_NOLOGO=1
+36 -134
View File
@@ -3,34 +3,21 @@
The `Jenkinsfile` at the repo root builds the DorkNet clients on your existing The `Jenkinsfile` at the repo root builds the DorkNet clients on your existing
Jenkins. Jenkins.
The builds run on a **build agent**, not on Jenkins itself: It runs right on the Jenkins container, so that container's image needs the
- **The agent:** a container built from `ci/jenkins/Dockerfile`, which is the build toolchain: Python with LIEF, the .NET SDK, the Android NDK and build-tools,
Jenkins inbound agent plus Python with LIEF, the .NET SDK, the Android NDK apktool, restic and rclone. `ci/jenkins/Dockerfile` is the stock
and build-tools, apktool, restic and rclone. It runs on Coolify next to Jenkins image with that toolchain added. Your jobs, plugins and settings live
Jenkins and connects to it over WebSocket on Docker's internal network. The in the `jenkins_home` volume, not in the image, so switching images leaves
job runs on it through the `dorknet-builder` label. them alone.
- **Jenkins itself:** stays on the stock image and runs no builds.
Each build is published to the **downloads bucket** under a name players can | Platform | Output (Jenkins artifacts) | How |
read, and the same files are kept as Jenkins artifacts
(`out/downloads/2023.03/`). The DorkNet server offers them on its site: the
**Downloads** page, and **Latest build** on the **Quest** page
(`docs/deploy.md` → Client downloads).
| Download | What it is | Built from |
|---|---|---| |---|---|---|
| `DorkNet-2023.03-PC-Full.zip` | the stock Steam build with the patcher applied and your config; ready to run | `tools/build_pc.py client` → `dorknet-client-march_2023_03_21.zip` | | PC | `out/pc/dorknet-clientpatch-march_2023_03_21.zip`: MelonLoader, mod, config template, Steam stub, `manifest.json`; no game files | `tools/build_pc.py patcher` |
| `DorkNet-2023.03-PC-Patch.zip` | MelonLoader, mod, config template, Steam stub, `manifest.json`; no game files | `tools/build_pc.py patcher` → `dorknet-clientpatch-march_2023_03_21.zip` | | PC | `out/pc/dorknet-client-march_2023_03_21.zip`: the stock Steam build with the patcher applied and your config; ready to run | `tools/build_pc.py client` |
| `DorkNet-2023.03-Quest.xapk` | patched APK + OBB, for the browser installer or SideQuest | `DorkNet.Quest/scripts/build.py build` → `DorkNet-Quest-<ver>/DorkNet-<ver>.xapk` | | Quest | `out/quest/dorknet-recroom-<code>.apk` and `out/quest/DorkNet-Quest-<ver>/` (`.xapk` + installers) | `DorkNet.Quest/scripts/build.py build` |
| `DorkNet-2023.03-Quest-Installer.zip` | `install.cmd` / `install.ps1` / `install.sh` + README, unzipped next to the `.xapk` | the same package folder |
The build scripts keep their own names: the launcher downloads The full PC client and the Quest packages contain game files, so keep them on
`dorknet-clientpatch-<VersionKey>.zip` from GitHub Releases by that exact Jenkins. Don't publish them anywhere public.
name. `ci/downloads/publish.py` renames only the published copies.
The full PC client and the Quest packages contain game files. Keep the bucket
internal and the site behind an access proxy; don't publish them anywhere
public.
## Where the stock builds come from ## Where the stock builds come from
@@ -50,8 +37,8 @@ The job defaults to the builds this branch targets: Steam manifest
build can be chosen per run. Tick **LIST_SNAPSHOTS** to print the snapshots build can be chosen per run. Tick **LIST_SNAPSHOTS** to print the snapshots
matching a filter without building anything. matching a filter without building anything.
A restored snapshot is cached on the agent's `dorknet_cache` volume, so each A restored snapshot is cached in `jenkins_home/dorknet-cache`, so each stock
stock build is downloaded once. build is downloaded once.
A Quest build needs a resolved hook table for its versionCode, committed under A Quest build needs a resolved hook table for its versionCode, committed under
`DorkNet.Quest/targets/`. Only `20230317-2128210.json` exists today. The build `DorkNet.Quest/targets/`. Only `20230317-2128210.json` exists today. The build
@@ -66,91 +53,36 @@ Resolve a table for another build locally with `build.py build --dump-cs`.
- `Jenkinsfile` and `ci/jenkins/` rebuild both. - `Jenkinsfile` and `ci/jenkins/` rebuild both.
- **Build with Parameters** runs a manual build: - **Build with Parameters** runs a manual build:
- `PLATFORMS` (both / pc / quest), and `PC_FULL_CLIENT` for the full PC client; - `PLATFORMS` (both / pc / quest), and `PC_FULL_CLIENT` for the full PC client;
- snapshot tags, and per-run repository overrides; - snapshot tags, and per-run repository overrides.
- `PUBLISH_DOWNLOADS` (on by default) to publish to the downloads bucket.
Push builds publish too, but only the platform they rebuilt: the manifest
keeps the other platform's last files.
## Setup ## Setup
### 1. Jenkins itself ### 1. Switch your Jenkins to this image
The controller is `ci/jenkins/docker-compose.yml`: stock Jenkins, no build On your Jenkins service in Dokploy:
tools. On Coolify, deploy it as a Docker Compose application from this
repository (base directory `/ci/jenkins`), or paste it into a Compose service.
- **Java 25.** The file defaults to `jenkins/jenkins:lts-jdk25`; set 1. **Build from this repo.**
`JENKINS_IMAGE` to pin a version, e.g. `jenkins/jenkins:2.587-jdk25`. - For an Application service: provider GitHub, branch `march-2023-03-21`,
Jenkins supports Java 25 until September 2029, Java 21 only until September build type Dockerfile, Dockerfile path `ci/jenkins/Dockerfile`, build
2027, and Java 17 not at all. Keep the agent on the same Java major version; context `ci/jenkins`.
its image defaults to Java 25. - For a Compose service: change its `jenkins` service to `build:` from
- **`restart: unless-stopped`** is in the file: a restart from the Jenkins UI `ci/jenkins` as in `ci/jenkins/docker-compose.yml`.
exits the container with code 5 and relies on Docker to start it again. - Either way, **keep the same volume on `/var/jenkins_home`**.
- **Turn on "Connect to predefined network"** (Advanced) so the agent can 2. **Match the Jenkins version.** Set the build argument `JENKINS_IMAGE` to the
reach Jenkins by its container name, `jenkins-<uuid>`. image the service runs today (e.g. `jenkins/jenkins:2.492.3-lts-jdk17`).
- **Run no builds on it:** **Manage Jenkins → Nodes → Built-In Node → The default is `jenkins/jenkins:lts-jdk17`.
Configure**, set **Number of executors** to `0`. 3. **Add the environment:**
Earlier versions of this file built the controller from `ci/jenkins/Dockerfile`,
which is now the agent's image. The service and data volume keep their names,
so an existing Jenkins deployed from this file keeps its jobs and credentials
when redeployed; it switches to the stock image, and its builds wait until
the agent (step 3) is connected.
That redeploy also upgrades Jenkins, and from Java 17 (the old default) to
Java 25, unless `JENKINS_IMAGE` pins your current image. Before it:
1. **Back up the data volume** (`jenkins_home`), e.g. with a Coolify volume
backup or `docker run --rm -v <volume>:/v -v "$PWD":/b alpine tar czf
/b/jenkins_home.tgz -C /v .`, so you can go back to the old image.
2. After it starts, open **Manage Jenkins**. Plugins that need a newer
version show up there as failed to load; update them under **Plugins →
Updates** and restart.
3. Run the job once (**LIST_SNAPSHOTS** is a quick check) before relying on it.
### 2. The agent node
**Manage Jenkins → Nodes → New Node**, a permanent agent:
| Setting | Value |
|---|---|
| Name | `dorknet-builder` |
| Labels | `dorknet-builder` |
| Remote root directory | `/home/jenkins/agent` |
| Launch method | Launch agent by connecting it to the controller, with **Use WebSocket** ticked |
| Number of executors | 1 |
Save, open the node, and copy its **secret**.
### 3. Deploy the agent on Coolify
1. **Create a Docker Compose application** from this repository: build pack
Docker Compose, base directory **`/ci/jenkins`**, compose location
**`/docker-compose.agent.yml`**. The base directory is the build context, so with
`/` the build would pick up the server's `Dockerfile` at the repo root. It
needs no domain. Under Advanced, turn on **Connect to predefined network**.
2. **Set its environment:**
| Variable | Value | | Variable | Value |
|---|---| |---|---|
| `JENKINS_URL` | Jenkins' **internal** address, `http://jenkins-<service uuid>:8080/`. Not the public URL, which is behind Cloudflare Access. |
| `JENKINS_SECRET` | the node secret from step 2 |
| `JENKINS_AGENT_NAME` | optional, default `dorknet-builder` |
| `ARCHIVE_PC_REPO` | Steam repository folder on the FTP, e.g. `Rec Room Versions/steam-v2` (plain text, not URL-encoded) | | `ARCHIVE_PC_REPO` | Steam repository folder on the FTP, e.g. `Rec Room Versions/steam-v2` (plain text, not URL-encoded) |
| `ARCHIVE_QUEST_REPO` | Quest repository folder, e.g. `Rec Room Versions/quest-v2` | | `ARCHIVE_QUEST_REPO` | Quest repository folder, e.g. `Rec Room Versions/quest-v2` |
| `ARCHIVE_FTP_TLS` | `explicit` (default), `implicit` or `none` | | `ARCHIVE_FTP_TLS` | `explicit` (default), `implicit` or `none` |
| `DOWNLOADS_S3_ENDPOINT` | the downloads bucket's S3 API, internal address, e.g. `http://garage-<service uuid>:3900` |
| `DOWNLOADS_BUCKET` | its name, e.g. `dorknet-downloads` |
3. **Deploy.** The first image build downloads the Android NDK (~1.5 GB) and 4. **Redeploy.** The first image build downloads the Android NDK (~1.5 GB) and
takes a while. The node shows as connected in Jenkins once the agent is up. takes a while.
Both Jenkins and the agent join the server's predefined Coolify network, so ### 2. Plugins
the agent reaches Jenkins by container name. Check the name under the Jenkins
service's containers before using it.
### 4. Plugins
The job needs these plugins: The job needs these plugins:
@@ -164,7 +96,7 @@ The job needs these plugins:
Most are part of the suggested set. Most are part of the suggested set.
### 5. Credentials ### 3. Credentials
Add these under **Manage Jenkins → Credentials**, with exactly these IDs: Add these under **Manage Jenkins → Credentials**, with exactly these IDs:
@@ -177,14 +109,13 @@ Add these under **Manage Jenkins → Credentials**, with exactly these IDs:
| `quest-keystore-password` | Secret text | its store/key password | | `quest-keystore-password` | Secret text | its store/key password |
| `clientmod-config` | Secret file | your `dorknet-clientmod.json` | | `clientmod-config` | Secret file | your `dorknet-clientmod.json` |
| `quest-config` | Secret file | your `dorknet-quest.cfg` | | `quest-config` | Secret file | your `dorknet-quest.cfg` |
| `downloads-s3` | Username with password | S3 key for the downloads bucket: access key ID / secret. Give it read and write on that bucket only. |
**Use the keystore your players' installs were signed with**, the **Use the keystore your players' installs were signed with**, the
`dorknet-debug.keystore` from your earlier local builds (password `dorknet`). A `dorknet-debug.keystore` from your earlier local builds (password `dorknet`). A
different key means every player has to uninstall before installing a CI different key means every player has to uninstall before installing a CI
build. build.
### 6. The job ### 4. The job
1. Create a **New Item → Pipeline** (e.g. `dorknet-client`). 1. Create a **New Item → Pipeline** (e.g. `dorknet-client`).
2. Under **Pipeline**, choose **Pipeline script from SCM**: 2. Under **Pipeline**, choose **Pipeline script from SCM**:
@@ -195,7 +126,7 @@ build.
4. Run it once with **Build** so Jenkins learns the parameters. Later runs 4. Run it once with **Build** so Jenkins learns the parameters. Later runs
offer **Build with Parameters**. offer **Build with Parameters**.
### 7. GitHub webhook ### 5. GitHub webhook
In the repository's **Settings → Webhooks**: In the repository's **Settings → Webhooks**:
@@ -203,36 +134,7 @@ In the repository's **Settings → Webhooks**:
- Content type: `application/json` - Content type: `application/json`
- Events: just the push event - Events: just the push event
### 8. The downloads bucket
`publish.py upload` puts the files and a `manifest.json` describing them in
an S3 bucket. The DorkNet server reads that bucket over HTTP on the internal
network and offers the files on its site (`docs/deploy.md` → Client
downloads), so the bucket needs no public route. With Garage (the S3 store on
the Coolify server):
1. **Bucket and key.** Create the bucket, turn on Garage's website access for
it (the server reads it through the web endpoint, port `3902`, which picks
the bucket by the request's Host header), and give Jenkins a key for that
bucket only:
```sh
garage bucket create dorknet-downloads
garage bucket website --allow dorknet-downloads
garage key create jenkins-downloads
garage bucket allow --read --write --key <key id> dorknet-downloads
```
Store the key as the `downloads-s3` credential.
2. **Server.** Point the DorkNet server at the web endpoint:
`DorkNet__Downloads__BaseUrl=http://garage-<service uuid>:3902` and
`DorkNet__Downloads__Host=dorknet-downloads`.
Each release keeps one copy of each file; a new build overwrites it. The
manifest is uploaded last, so the site only ever lists files that are fully
uploaded.
### Disk ### Disk
The agent's `dorknet_cache` volume holds the restored stock builds, several GB The restored stock builds are cached in `jenkins_home/dorknet-cache`, several
each. Jenkins keeps the artifacts of the last 3 runs, on its own volume. GB each. Jenkins keeps the artifacts of the last 3 runs.
-41
View File
@@ -1,41 +0,0 @@
# DorkNet build agent on Coolify. Deploy as its own Docker Compose application
# from this repository with base directory /ci/jenkins, compose location
# /docker-compose.agent.yml and "Connect to predefined network" on. It needs no
# domain: it dials out to Jenkins over WebSocket on Docker's internal network.
# Set the variables in the resource's Environment Variables; see
# ci/jenkins/README.md. The controller is docker-compose.yml.
services:
agent:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
# Reap the processes build tools leave behind (dotnet build servers,
# Gradle daemons, adb), as the inbound-agent image's docs ask for.
init: true
environment:
# Jenkins as seen from inside Docker (e.g. http://<jenkins container
# name>:8080), not the public URL, which is behind Cloudflare Access.
# Both are needed at runtime only; keep them out of the image build so
# the node secret never reaches a build argument.
JENKINS_URL: ${JENKINS_URL}
JENKINS_AGENT_NAME: ${JENKINS_AGENT_NAME:-dorknet-builder}
JENKINS_SECRET: ${JENKINS_SECRET}
JENKINS_WEB_SOCKET: "true"
JENKINS_AGENT_WORKDIR: /home/jenkins/agent
# Where the stock builds live in the archive (restic repository folders
# on the FTP, plain text). Overridable per run.
ARCHIVE_PC_REPO: ${ARCHIVE_PC_REPO:-}
ARCHIVE_QUEST_REPO: ${ARCHIVE_QUEST_REPO:-}
ARCHIVE_FTP_TLS: ${ARCHIVE_FTP_TLS:-explicit}
# The downloads bucket (S3 API, internal address) the builds are
# published to. See ci/downloads/publish.py.
DOWNLOADS_S3_ENDPOINT: ${DOWNLOADS_S3_ENDPOINT:-}
DOWNLOADS_BUCKET: ${DOWNLOADS_BUCKET:-}
volumes:
- dorknet_cache:/home/jenkins/dorknet-cache
- agent_work:/home/jenkins/agent
volumes:
dorknet_cache:
agent_work:
+20 -14
View File
@@ -1,23 +1,29 @@
# The Jenkins controller: stock Jenkins on Java 25. It runs no builds; the # Reference for the Jenkins service on Dokploy, built from this directory's
# client builds run on the agent (docker-compose.agent.yml). On Coolify, # Dockerfile (the stock Jenkins image plus the client build toolchain).
# deploy it as a Docker Compose application from this repository (base
# directory /ci/jenkins) or paste it into a Compose service, with "Connect to
# predefined network" on so the agent can reach it. See ci/jenkins/README.md.
# #
# Earlier versions of this file built the controller from ci/jenkins/Dockerfile, # Use it on your EXISTING Jenkins service rather than as a second Jenkins.
# which is now the agent's image. The service name and data volume are the # The data volume must be the one Jenkins uses today, or Jenkins starts empty.
# same, so redeploying an existing Jenkins from this file keeps its jobs and # Point `volumes:` at that volume (and its name under the top-level
# credentials; its builds then wait for the agent. # `volumes:`, marked `external: true`), or keep this service's name and volume
# if they already match. See ci/jenkins/README.md.
services: services:
jenkins: jenkins:
# Pin a version with e.g. jenkins/jenkins:2.587-jdk25. Keep the agent on build:
# the same Java major version (its image defaults to Java 25). context: .
image: ${JENKINS_IMAGE:-jenkins/jenkins:lts-jdk25} dockerfile: Dockerfile
# A restart from the Jenkins UI exits with code 5 and relies on Docker to args:
# start it again. # The image your Jenkins runs today, so the Jenkins version and Java
# stay the same.
JENKINS_IMAGE: ${JENKINS_IMAGE:-jenkins/jenkins:lts-jdk17}
restart: unless-stopped restart: unless-stopped
expose: expose:
- "8080" - "8080"
environment:
# Where the stock builds live in the archive (restic repository folders
# on the FTP), as plain text. Overridable per run.
ARCHIVE_PC_REPO: ${ARCHIVE_PC_REPO:-}
ARCHIVE_QUEST_REPO: ${ARCHIVE_QUEST_REPO:-}
ARCHIVE_FTP_TLS: ${ARCHIVE_FTP_TLS:-explicit}
volumes: volumes:
- jenkins_home:/var/jenkins_home - jenkins_home:/var/jenkins_home
+33
View File
@@ -412,6 +412,39 @@ for building and installing the patched APK.
--- ---
## Client downloads
Optional. When CI publishes the client builds to a bucket
(`ci/downloads/publish.py`, see `ci/jenkins/README.md`), the server can offer
them on the public site:
- a **Downloads** page (`/downloads`) listing every published file with its
size and SHA-256;
- **Latest build** on the **Quest** page, which installs the newest Quest
package straight from the server: the browser reads it with HTTP Range
requests, so nothing is downloaded first.
The server reads `manifest.json` and the files over plain HTTP, from inside
your network, and streams them to the browser. Only files the manifest lists
are served. The bucket itself never needs to be public.
```env
# Where manifest.json is: e.g. Garage's web endpoint on the internal network.
DorkNet__Downloads__BaseUrl=http://garage-<service uuid>:3902
# Optional Host header, for web endpoints that pick the bucket by host name
# (Garage serves the bucket whose name matches it).
DorkNet__Downloads__Host=<bucket name>
```
Unset, the site hides both features. The builds contain Rec Room's own game
files: only turn this on when the site is behind an access proxy (e.g.
Cloudflare Access) that admits just your community. The downloads routes
answer only on the apex host, where the site lives, and return 404 on every
other host the server accepts, so leaving the game's own subdomains open for
the client doesn't expose the files. Put the apex behind the proxy.
---
## Photon Cloud ## Photon Cloud
Use your own Photon AppIds. Rec Room's original Photon AppIds reject Use your own Photon AppIds. Rec Room's original Photon AppIds reject
+8 -1
View File
@@ -85,6 +85,13 @@ pitfall in their Quest client notes.
installer scripts; the app appears as **DorkNet** under Unknown Sources. installer scripts; the app appears as **DorkNet** under Unknown Sources.
Share the `.xapk` however you like: the page reads it from the player's own Share the `.xapk` however you like: the page reads it from the player's own
disk, so your server never hosts game files. disk, so your server never hosts game files.
5. Optionally, let players install the latest CI build without downloading it
first: point the server at the downloads bucket CI publishes to
(`DorkNet__Downloads__BaseUrl`, see `deploy.md` → Client downloads). The
Quest page then offers **Latest build** next to **Choose file** and streams
the package from the server during the install, and the site gets a
**Downloads** page. The builds contain game files, so only do this with
the site behind an access proxy.
No server component is Quest-specific beyond the placeholder-platform-id No server component is Quest-specific beyond the placeholder-platform-id
handling above. handling above and the optional downloads proxy.