Merge remote-tracking branch 'origin/march-2023-03-21' into fix/2023-room-instances-publish-permissions

This commit is contained in:
Alexa
2026-10-25 11:59:05 +02:00
19 changed files with 844 additions and 110 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;
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);
if (string.IsNullOrEmpty(ext))
return true;
@@ -217,6 +217,13 @@ public static class ServiceCollectionExtensions
// so a deployment with no GitHub credentials behaves normally.
services.AddSingleton<IGitHubIssues, GitHubIssueService>();
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
// must share the request's scope to participate in the request's
// 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 { Rooms } from './pages/Rooms';
import { Join } from './pages/Join';
import { Downloads } from './pages/Downloads';
import { NotFound } from './pages/NotFound';
// 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="/join" element={<Join />} />
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
<Route path="/downloads" element={<Downloads />} />
{/* Legacy paths from the vanilla-JS feed.localhost site */}
<Route path="/p/:id" element={<RedirectPhoto />} />
<Route path="/u/:id" element={<RedirectPlayer />} />
+11 -2
View File
@@ -1,6 +1,7 @@
import { NavLink, Link, useLocation, useNavigate } from 'react-router';
import { useEffect, useRef, useState } from 'react';
import { BrandMark } from './BrandMark';
import { getDownloads } from '../lib/downloads';
const NAV = [
{ to: '/', label: 'Home', exact: true },
@@ -10,6 +11,8 @@ const NAV = [
{ to: '/join', label: 'Join' },
{ 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() {
const [open, setOpen] = useState(false);
@@ -17,6 +20,12 @@ export function Header() {
const nav = useNavigate();
const loc = useLocation();
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
// doesn't leave a half-open overlay covering the page.
@@ -37,7 +46,7 @@ export function Header() {
</Link>
<nav className="hidden md:flex items-center gap-1 ml-2">
{NAV.map(n => (
{navItems.map(n => (
<NavLink
key={n.to}
to={n.to}
@@ -85,7 +94,7 @@ export function Header() {
/>
</form>
<nav className="flex flex-col gap-1">
{NAV.map(n => (
{navItems.map(n => (
<NavLink
key={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;
const total = xapk.apk.size;
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;
onProgress({ phase: 'app', label: 'Installing the app', done, total });
});
@@ -124,7 +124,7 @@ export async function installXapk(
let done = 0;
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;
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
// loading it into memory. DorkNet.Quest/scripts/build.py stores the APK and
// the OBB uncompressed, so those entries are plain byte ranges of the file and
// stream out with Blob.slice(); manifest.json is deflated and goes through
// Read a DorkNet .xapk (zip) without loading it into memory, either from a
// File the player picked or from a URL that answers HTTP Range requests (the
// server's latest published build). DorkNet.Quest/scripts/build.py stores the
// 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.
/** 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 {
name: string;
method: number; // 0 = stored, 8 = deflate
@@ -26,7 +76,7 @@ export interface XapkManifest {
}
export interface Xapk {
file: File;
source: ByteSource;
manifest: XapkManifest;
entries: Map<string, XapkEntry>;
apk: XapkEntry;
@@ -39,18 +89,18 @@ const ZIP64_EOCD = 0x06064b50;
const CENTRAL = 0x02014b50;
const LOCAL = 0x04034b50;
async function bytes(file: Blob, start: number, end: number): Promise<DataView> {
return new DataView(await file.slice(start, end).arrayBuffer());
async function bytes(src: ByteSource, start: number, end: number): Promise<DataView> {
return new DataView(await src.read(start, end));
}
function u64(v: DataView, at: number): number {
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.
const tailStart = Math.max(0, file.size - (22 + 0xffff + 20));
const tail = await bytes(file, tailStart, file.size);
const tailStart = Math.max(0, src.size - (22 + 0xffff + 20));
const tail = await bytes(src, tailStart, src.size);
let eocd = -1;
for (let i = tail.byteLength - 22; i >= 0; i--) {
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;
if (loc < 0 || tail.getUint32(loc, true) !== ZIP64_LOCATOR) throw new Error('Unsupported zip64 layout.');
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.');
count = u64(z64, 32);
cdSize = u64(z64, 40);
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 decoder = new TextDecoder();
let p = 0;
@@ -109,7 +159,7 @@ export async function openXapk(file: File): Promise<Xapk> {
const manifestEntry = entries.get('manifest.json');
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.');
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 { file, manifest, entries, apk, expansions };
return { source: src, manifest, entries, apk, expansions };
}
/** The entry's uncompressed bytes as a stream. */
export async function entryStream(file: File, entry: XapkEntry): Promise<ReadableStream<Uint8Array>> {
const local = await bytes(file, entry.localHeaderOffset, entry.localHeaderOffset + 30);
export async function entryStream(src: ByteSource, entry: XapkEntry): Promise<ReadableStream<Uint8Array<ArrayBuffer>>> {
const local = await bytes(src, entry.localHeaderOffset, entry.localHeaderOffset + 30);
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 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 === 8) return raw.pipeThrough(new DecompressionStream('deflate-raw'));
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 { openXapk, type Xapk } from '../lib/xapk';
import { useEffect, useRef, useState } from 'react';
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 { fileUrl, getDownloads, questBuild, type QuestBuild } from '../lib/downloads';
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`;
@@ -43,15 +45,36 @@ export function Quest() {
const [err, setErr] = useState<string | null>(null);
const [done, setDone] = useState(false);
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) => {
if (!f) return;
const open = async (load: () => Promise<Xapk>) => {
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); }
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 () => {
setErr(null); setBusy('connect');
try { setHeadset(await connectHeadset()); }
@@ -108,19 +131,32 @@ export function Quest() {
</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>
)}
<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">
<button className="btn-secondary" disabled={busy !== null} onClick={() => fileRef.current?.click()}>
{busy === 'file' ? 'Reading…' : xapk ? 'Choose a different file' : 'Choose file'}
{latest && (
<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>
{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>
{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 n={2} title="Connect your headset" done={!!headset}>
Vendored
+12 -13
View File
@@ -1,14 +1,13 @@
// DorkNet client builds — PC (patcher zip + full client) and Quest (.xapk).
//
// Runs on the Jenkins image from ci/jenkins (the stock image plus Python with
// LIEF, .NET, the Android NDK/build-tools, apktool, restic and rclone).
// Runs on the "dorknet-builder" agent from ci/jenkins (an inbound agent with
// Python + 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
// is server configuration, not part of the repo: the FTP host, login and the
// restic password are Jenkins credentials, and the repository folders come
// from the Jenkins container's environment (ARCHIVE_PC_REPO /
// ARCHIVE_QUEST_REPO), each overridable per run. A build is picked by
// snapshot tags.
// from the agent's environment (ARCHIVE_PC_REPO / 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
// picks the platforms with PLATFORMS. See ci/jenkins/README.md.
@@ -23,7 +22,7 @@ def buildPc = false
def buildQuest = false
pipeline {
agent any
agent { label 'dorknet-builder' }
options {
timestamps()
@@ -45,9 +44,9 @@ pipeline {
string(name: 'QUEST_SNAPSHOT_TAGS', defaultValue: '',
description: "restic tags picking the stock Quest build, comma = AND. Empty: ${QUEST_DEFAULT_TAGS}")
string(name: 'PC_REPO', defaultValue: '',
description: 'PC restic repository folder on the FTP. Empty: ARCHIVE_PC_REPO from the Jenkins environment.')
description: 'PC restic repository folder on the FTP. Empty: ARCHIVE_PC_REPO set on the agent.')
string(name: 'QUEST_REPO', defaultValue: '',
description: 'Quest restic repository folder on the FTP. Empty: ARCHIVE_QUEST_REPO from the Jenkins environment.')
description: 'Quest restic repository folder on the FTP. Empty: ARCHIVE_QUEST_REPO set on the agent.')
booleanParam(name: 'LIST_SNAPSHOTS', defaultValue: false,
description: 'Only list the snapshots matching the tags in both repositories (to find a build), then stop.')
}
@@ -88,8 +87,8 @@ pipeline {
"PC_TAGS=${params.PC_SNAPSHOT_TAGS}", "QUEST_TAGS=${params.QUEST_SNAPSHOT_TAGS}"]) {
sh '''
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
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 Jenkins or QUEST_REPO on the run}}"
pc="${PC_REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on the agent 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}}"
echo "== PC: $pc"; archive-restore list "$pc" "$PC_TAGS"
echo "== Quest: $quest"; archive-restore list "$quest" "$QUEST_TAGS"
'''
@@ -113,7 +112,7 @@ pipeline {
withEnv(["REPO_OVERRIDE=${params.PC_REPO}", "TAGS=${params.PC_SNAPSHOT_TAGS ?: PC_DEFAULT_TAGS}"]) {
sh(returnStdout: true, script: '''
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
repo="${REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on Jenkins or PC_REPO on the run}}"
repo="${REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on the agent or PC_REPO on the run}}"
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/pc" --exclude /.DepotDownloader
''').trim().readLines().last()
}
@@ -141,7 +140,7 @@ pipeline {
withEnv(["REPO_OVERRIDE=${params.QUEST_REPO}", "TAGS=${params.QUEST_SNAPSHOT_TAGS ?: QUEST_DEFAULT_TAGS}"]) {
sh(returnStdout: true, script: '''
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
repo="${REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on Jenkins or QUEST_REPO on the run}}"
repo="${REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on the agent or QUEST_REPO on the run}}"
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/quest"
''').trim().readLines().last()
}
@@ -187,7 +186,7 @@ pipeline {
}
cleanup {
// Runs after archiving. Keeps the restic cache and restored stock
// builds ($DORKNET_CACHE in jenkins_home, outside the workspace); drops
// builds ($DORKNET_CACHE on the agent, outside the workspace); drops
// the workspace's build output and scratch.
cleanWs(deleteDirs: true, notFailBuild: true)
}
+13 -12
View File
@@ -1,7 +1,7 @@
# DorkNet Jenkins: the stock Jenkins image plus everything the PC and Quest
# client builds need, so the job (the Jenkinsfile at the repo root) runs right
# on the controller. Jobs, plugins and settings stay in the jenkins_home
# volume; only the image changes.
# DorkNet build agent: a Jenkins inbound agent with everything the PC and
# Quest client builds need. It runs next to Jenkins on Coolify, connects to
# the controller over WebSocket, and runs the jobs labelled "dorknet-builder"
# (the Jenkinsfile at the repo root). Jenkins itself stays on the stock image.
#
# PC Python + .NET SDK tools/build_pc.py
# Quest Python/LIEF + JDK + Android NDK, build-tools, apktool
@@ -9,13 +9,13 @@
# Builds restic + rclone stock builds restored from the restic
# 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.
# Match this to the Jenkins image your Dokploy service runs today, so the
# 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}
ARG AGENT_IMAGE=jenkins/inbound-agent:latest-jdk25
FROM ${AGENT_IMAGE}
USER root
@@ -73,9 +73,10 @@ RUN wget -q https://github.com/restic/restic/releases/download/v${RESTIC}/restic
COPY archive-restore.sh /usr/local/bin/archive-restore
RUN chmod +x /usr/local/bin/archive-restore
# Restored stock builds and the restic cache live in the persistent
# jenkins_home volume, so each multi-GB build is downloaded once.
ENV DORKNET_CACHE=/var/jenkins_home/dorknet-cache
# Restored stock builds and the restic cache. Mount a volume here so each
# multi-GB stock build is downloaded once, not after every restart.
ENV DORKNET_CACHE=/home/jenkins/dorknet-cache
RUN mkdir -p ${DORKNET_CACHE} && chown jenkins:jenkins ${DORKNET_CACHE}
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
DOTNET_NOLOGO=1
+79 -29
View File
@@ -3,12 +3,13 @@
The `Jenkinsfile` at the repo root builds the DorkNet clients on your existing
Jenkins.
It runs right on the Jenkins container, so that container's image needs the
build toolchain: Python with LIEF, the .NET SDK, the Android NDK and build-tools,
apktool, restic and rclone. `ci/jenkins/Dockerfile` is the stock
Jenkins image with that toolchain added. Your jobs, plugins and settings live
in the `jenkins_home` volume, not in the image, so switching images leaves
them alone.
The builds run on a **build agent**, not on Jenkins itself:
- **The agent:** a container built from `ci/jenkins/Dockerfile`, which is the
Jenkins inbound agent plus Python with LIEF, the .NET SDK, the Android NDK
and build-tools, apktool, restic and rclone. It runs on Coolify next to
Jenkins and connects to it over WebSocket on Docker's internal network. The
job runs on it through the `dorknet-builder` label.
- **Jenkins itself:** stays on the stock image and runs no builds.
| Platform | Output (Jenkins artifacts) | How |
|---|---|---|
@@ -37,8 +38,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
matching a filter without building anything.
A restored snapshot is cached in `jenkins_home/dorknet-cache`, so each stock
build is downloaded once.
A restored snapshot is cached on the agent's `dorknet_cache` volume, so each
stock build is downloaded once.
A Quest build needs a resolved hook table for its versionCode, committed under
`DorkNet.Quest/targets/`. Only `20230317-2128210.json` exists today. The build
@@ -57,32 +58,81 @@ Resolve a table for another build locally with `build.py build --dump-cs`.
## Setup
### 1. Switch your Jenkins to this image
### 1. Jenkins itself
On your Jenkins service in Dokploy:
The controller is `ci/jenkins/docker-compose.yml`: stock Jenkins, no build
tools. On Coolify, deploy it as a Docker Compose application from this
repository (base directory `/ci/jenkins`), or paste it into a Compose service.
1. **Build from this repo.**
- For an Application service: provider GitHub, branch `march-2023-03-21`,
build type Dockerfile, Dockerfile path `ci/jenkins/Dockerfile`, build
context `ci/jenkins`.
- For a Compose service: change its `jenkins` service to `build:` from
`ci/jenkins` as in `ci/jenkins/docker-compose.yml`.
- Either way, **keep the same volume on `/var/jenkins_home`**.
2. **Match the Jenkins version.** Set the build argument `JENKINS_IMAGE` to the
image the service runs today (e.g. `jenkins/jenkins:2.492.3-lts-jdk17`).
The default is `jenkins/jenkins:lts-jdk17`.
3. **Add the environment:**
- **Java 25.** The file defaults to `jenkins/jenkins:lts-jdk25`; set
`JENKINS_IMAGE` to pin a version, e.g. `jenkins/jenkins:2.587-jdk25`.
Jenkins supports Java 25 until September 2029, Java 21 only until September
2027, and Java 17 not at all. Keep the agent on the same Java major version;
its image defaults to Java 25.
- **`restart: unless-stopped`** is in the file: a restart from the Jenkins UI
exits the container with code 5 and relies on Docker to start it again.
- **Turn on "Connect to predefined network"** (Advanced) so the agent can
reach Jenkins by its container name, `jenkins-<uuid>`.
- **Run no builds on it:** **Manage Jenkins → Nodes → Built-In Node →
Configure**, set **Number of executors** to `0`.
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 |
|---|---|
| `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_QUEST_REPO` | Quest repository folder, e.g. `Rec Room Versions/quest-v2` |
| `ARCHIVE_FTP_TLS` | `explicit` (default), `implicit` or `none` |
4. **Redeploy.** The first image build downloads the Android NDK (~1.5 GB) and
takes a while.
3. **Deploy.** 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.
### 2. Plugins
Both Jenkins and the agent join the server's predefined Coolify network, so
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:
@@ -96,7 +146,7 @@ The job needs these plugins:
Most are part of the suggested set.
### 3. Credentials
### 5. Credentials
Add these under **Manage Jenkins → Credentials**, with exactly these IDs:
@@ -115,7 +165,7 @@ Add these under **Manage Jenkins → Credentials**, with exactly these IDs:
different key means every player has to uninstall before installing a CI
build.
### 4. The job
### 6. The job
1. Create a **New Item → Pipeline** (e.g. `dorknet-client`).
2. Under **Pipeline**, choose **Pipeline script from SCM**:
@@ -126,7 +176,7 @@ build.
4. Run it once with **Build** so Jenkins learns the parameters. Later runs
offer **Build with Parameters**.
### 5. GitHub webhook
### 7. GitHub webhook
In the repository's **Settings → Webhooks**:
@@ -136,5 +186,5 @@ In the repository's **Settings → Webhooks**:
### Disk
The restored stock builds are cached in `jenkins_home/dorknet-cache`, several
GB each. Jenkins keeps the artifacts of the last 3 runs.
The agent's `dorknet_cache` volume holds the restored stock builds, several GB
each. Jenkins keeps the artifacts of the last 3 runs, on its own volume.
+37
View File
@@ -0,0 +1,37 @@
# 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}
volumes:
- dorknet_cache:/home/jenkins/dorknet-cache
- agent_work:/home/jenkins/agent
volumes:
dorknet_cache:
agent_work:
+14 -20
View File
@@ -1,29 +1,23 @@
# Reference for the Jenkins service on Dokploy, built from this directory's
# Dockerfile (the stock Jenkins image plus the client build toolchain).
# The Jenkins controller: stock Jenkins on Java 25. It runs no builds; the
# client builds run on the agent (docker-compose.agent.yml). On Coolify,
# 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.
#
# Use it on your EXISTING Jenkins service rather than as a second Jenkins.
# The data volume must be the one Jenkins uses today, or Jenkins starts empty.
# Point `volumes:` at that volume (and its name under the top-level
# `volumes:`, marked `external: true`), or keep this service's name and volume
# if they already match. See ci/jenkins/README.md.
# Earlier versions of this file built the controller from ci/jenkins/Dockerfile,
# which is now the agent's image. The service name and data volume are the
# same, so redeploying an existing Jenkins from this file keeps its jobs and
# credentials; its builds then wait for the agent.
services:
jenkins:
build:
context: .
dockerfile: Dockerfile
args:
# The image your Jenkins runs today, so the Jenkins version and Java
# stay the same.
JENKINS_IMAGE: ${JENKINS_IMAGE:-jenkins/jenkins:lts-jdk17}
# Pin a version with e.g. jenkins/jenkins:2.587-jdk25. Keep the agent on
# the same Java major version (its image defaults to Java 25).
image: ${JENKINS_IMAGE:-jenkins/jenkins:lts-jdk25}
# A restart from the Jenkins UI exits with code 5 and relies on Docker to
# start it again.
restart: unless-stopped
expose:
- "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:
- 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
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.
Share the `.xapk` however you like: the page reads it from the player's own
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
handling above.
handling above and the optional downloads proxy.