mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
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.
This commit is contained in:
@@ -0,0 +1,112 @@
|
|||||||
|
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, 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 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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.Response.Body = new MemoryStream();
|
||||||
|
var controller = new DownloadsController(service) { 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,61 @@
|
|||||||
|
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: put the site behind an
|
||||||
|
/// access proxy (e.g. Cloudflare Access) before turning this on.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Route("api/site/v1/downloads")]
|
||||||
|
public class DownloadsController(DownloadsService downloads) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>GET /api/site/v1/downloads — the manifest as published
|
||||||
|
/// (snake_case keys), or 404 when downloads aren't set up.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetManifest(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!downloads.IsConfigured) 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 (!downloads.IsConfigured) { 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,87 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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;
|
||||||
|
foreach (var release in releases.OfType<JsonObject>())
|
||||||
|
{
|
||||||
|
if (release["files"] is not JsonArray files) continue;
|
||||||
|
foreach (var file in files.OfType<JsonObject>())
|
||||||
|
{
|
||||||
|
if (string.Equals((string?)file["path"], path, StringComparison.Ordinal)) return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)));
|
||||||
|
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);
|
||||||
|
return await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,12 @@ 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;
|
||||||
|
// the request's own abort token still ends it.
|
||||||
|
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
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 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('/')}`;
|
||||||
|
|
||||||
|
/** The newest release's Quest package, if one is published. */
|
||||||
|
export function latestQuest(m: DownloadsManifest | null): { release: DownloadRelease; file: DownloadFile } | null {
|
||||||
|
for (const release of m?.releases ?? []) {
|
||||||
|
const file = release.files.find((f) => f.id === 'quest');
|
||||||
|
if (file) return { release, file };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}.`);
|
||||||
|
|||||||
@@ -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?build=latest">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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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, latestQuest } 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,35 @@ 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<ReturnType<typeof latestQuest>>(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 pickLatest = async (l = latest) => {
|
||||||
|
if (l) await open(() => openXapk(urlSource(fileUrl(l.file), l.file.name, l.file.size)));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getDownloads().then((m) => {
|
||||||
|
const l = latestQuest(m);
|
||||||
|
setLatest(l);
|
||||||
|
// Arriving from the Downloads page's "Install on Quest".
|
||||||
|
if (l && params.get('build') === 'latest') void pickLatest(l);
|
||||||
|
}).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 +130,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}>
|
||||||
<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>
|
{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])} />
|
<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={() => pickLatest()}>
|
||||||
</button>
|
{busy === 'file' ? 'Reading…' : `Latest build: ${latest.release.title}${latest.file.build ? ` (build ${latest.file.build})` : ''}`}
|
||||||
{xapk && (
|
</button>
|
||||||
<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}>
|
||||||
|
|||||||
@@ -412,6 +412,36 @@ 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user