mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb73f3c484 | |||
| cc7f77b7de |
@@ -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
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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}.`);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user