mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb73f3c484 | |||
| cc7f77b7de | |||
| 0839d932ce | |||
| 9523525cb8 | |||
| 3880745efd |
@@ -156,6 +156,14 @@ public class Mod : MelonMod
|
||||
// "N"). Empty = no hotkey. A hotkey also arms the feature when
|
||||
// RecRoyaleNightMode is false, so it can be switched on in-game.
|
||||
public static string RecRoyaleNightToggleKey = "";
|
||||
|
||||
// A client with no remembered account for this machine. false: the
|
||||
// title screen opens the sign-in page so the player logs into their
|
||||
// existing account. true: Rec Room's own behaviour, which creates a
|
||||
// new account automatically -- only useful when the server allows
|
||||
// sign-ups (otherwise the title screen keeps retrying and failing).
|
||||
// See TitleScreenLoginPatches.
|
||||
public static bool AutoCreateAccount = false;
|
||||
}
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
@@ -225,6 +233,14 @@ public class Mod : MelonMod
|
||||
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
||||
}
|
||||
|
||||
// Title-screen auto-login: with no remembered account, open the
|
||||
// sign-in page instead of creating a new account.
|
||||
if (!Cfg.AutoCreateAccount)
|
||||
{
|
||||
TryPatchByName("RRUI.Data.TitleScreenFlowModel", "IAKHIJIHAPJ",
|
||||
prefix: nameof(TitleScreenLoginPatches.AutoLogin_Prefix));
|
||||
}
|
||||
|
||||
// Rec Royale night mode: armed when enabled OR when a hotkey is set
|
||||
// (so it can be switched on in-game). Scene detection + mood apply
|
||||
// run from OnSceneWasLoaded / OnUpdate below.
|
||||
@@ -457,6 +473,7 @@ public class Mod : MelonMod
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "AutoCreateAccount", out v)) Cfg.AutoCreateAccount = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
@@ -662,7 +679,7 @@ public class Mod : MelonMod
|
||||
private MethodInfo GetPatchMethod(string name)
|
||||
{
|
||||
// Look in the core + feature patch holder classes.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight) })
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight), typeof(TitleScreenLoginPatches) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
@@ -903,6 +920,52 @@ internal static class FileHashCheckerPatches
|
||||
return false;
|
||||
}
|
||||
}
|
||||
internal static class TitleScreenLoginPatches
|
||||
{
|
||||
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time
|
||||
// auto-login: the title screen downloads this machine's remembered
|
||||
// accounts (CFILOMKCAPL), picks one (BFHOLLPGBPB) and logs into it here.
|
||||
// With none (null) it creates a brand-new account instead (PJMGFADOEEM ->
|
||||
// grant_type create_account). A server with sign-ups off refuses that, the
|
||||
// error handler (IBDFENJNMAA) shows the connection-error page and the flow
|
||||
// tries again, forever. Open the sign-in page instead (GoToLogin, what the
|
||||
// "Log in" button does). ManuallyLogin carries the player into the game
|
||||
// from there on its own, so the boot step gets a promise that never
|
||||
// settles (MAMJDBJDFAF, the game's non-generic promise).
|
||||
private static Type? _promiseType;
|
||||
|
||||
public static bool AutoLogin_Prefix(object __instance, object? __0, MethodBase __originalMethod, ref object? __result)
|
||||
{
|
||||
if (__0 is not null || Mod.Cfg.AutoCreateAccount) return true;
|
||||
try
|
||||
{
|
||||
_promiseType ??= Mod.ResolveType("MAMJDBJDFAF");
|
||||
var goToLogin = __instance.GetType().GetMethod("GoToLogin", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null);
|
||||
if (_promiseType is null || goToLogin is null)
|
||||
{
|
||||
Mod.Log.Warning("[title] no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
|
||||
return true;
|
||||
}
|
||||
// Il2CppInterop wraps the interface return type (LDGADANDBIO) as
|
||||
// its own class, so hand back the new promise's native object
|
||||
// wrapped in that type.
|
||||
var pending = (Il2CppObjectBase)Activator.CreateInstance(_promiseType)!;
|
||||
var returnType = ((MethodInfo)__originalMethod).ReturnType;
|
||||
var result = Activator.CreateInstance(returnType, pending.Pointer);
|
||||
goToLogin.Invoke(__instance, null);
|
||||
__result = result;
|
||||
Mod.Log.Msg("[title] no remembered account; opened the sign-in page instead of creating a new account");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// goToLogin.Invoke wraps the real failure in TargetInvocationException.
|
||||
var cause = ex is TargetInvocationException { InnerException: not null } tie ? tie.InnerException! : ex;
|
||||
Mod.Log.Warning($"[title] sign-in redirect failed, creating an account as Rec Room does: {cause.GetType().Name}: {cause.Message}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
internal static class ToxModPatches
|
||||
{
|
||||
public static bool CanInitializeToxMod_Prefix(ref bool __result)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ServerHost": "{HOST}",
|
||||
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
||||
"AutoCreateAccount": false,
|
||||
"EnableDebugConsole": false,
|
||||
"DebugConsoleToggleKey": "BackQuote",
|
||||
"DevCommands": [],
|
||||
|
||||
@@ -30,7 +30,8 @@ Photon shards with PC DorkNet players with no server-side version change.
|
||||
| image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning |
|
||||
| hile-warning / unknown-DLL / file-hash / ToxMod callbacks | neutralised so a re-signed, hook-loaded client is not warned/quit | `ClientMod` AntiTamper/ToxMod/FileHash |
|
||||
| integrity report + quit funnels | **log only** — names any new rejection in logcat | — |
|
||||
| Oculus `ovr_*` platform calls | (no-Meta build only) steer the entitlement/login path to the client's own credential screen | — |
|
||||
| Oculus `ovr_*` platform calls | (no-Meta build only) pass the entitlement / user-proof / logged-in-user requests Meta refuses for the renamed app (including the SDK's second check on the inner native message), answering the logged-in user with the placeholder user id `1` | — |
|
||||
| title-screen auto-login (`TitleScreenFlowModel`) | a headset with no remembered account opens the **sign-in page** instead of auto-creating an account (`AutoCreateAccount = true` restores Rec Room's behaviour) | — |
|
||||
|
||||
### Two builds, selected by `OculusAppId`
|
||||
|
||||
@@ -204,6 +205,8 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
|
||||
| `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin |
|
||||
| multiplayer never connects | Photon app ids not set in the cfg |
|
||||
| Quest and PC players never see each other's rooms | `PhotonRegion` differs from the PC ClientMod `PhotonCloudRegion` |
|
||||
| "Unable to load Oculus user account" | old build (before the GetLoggedInUser bypass), or a Meta build whose app id the account isn't entitled to |
|
||||
| game restarts while "creating a new account" | `AutoCreateAccount = true` on a server with sign-ups off — set it to `false` so the sign-in page opens |
|
||||
|
||||
## Layout
|
||||
|
||||
|
||||
@@ -65,6 +65,12 @@ EnableTlsTrustBypass = true
|
||||
# Accept image Content-Signatures the server signs with its own key.
|
||||
AcceptUnsignedImages = true
|
||||
|
||||
# A headset with no remembered account. false: open the sign-in page so the
|
||||
# player logs into their existing DorkNet account. true: Rec Room's own
|
||||
# behaviour, which creates a new account automatically -- only useful when the
|
||||
# server allows sign-ups (otherwise the game keeps retrying and failing).
|
||||
AutoCreateAccount = false
|
||||
|
||||
# ---- diagnostics (adb logcat -s DORKNET) ----
|
||||
|
||||
LogHttp = true
|
||||
|
||||
@@ -23,6 +23,7 @@ KEYS = {
|
||||
"PhotonRegion": ("DN_PHOTON_REGION", "str"),
|
||||
"EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"),
|
||||
"AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"),
|
||||
"AutoCreateAccount": ("DN_AUTO_CREATE_ACCOUNT", "bool"),
|
||||
"LogHttp": ("DN_LOG_HTTP", "bool"),
|
||||
"Debug": ("DN_DEBUG_HTTP", "bool"),
|
||||
"LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"),
|
||||
|
||||
Binary file not shown.
@@ -57,6 +57,11 @@
|
||||
#ifndef DN_ACCEPT_UNSIGNED_IMAGES
|
||||
# define DN_ACCEPT_UNSIGNED_IMAGES true
|
||||
#endif
|
||||
// A headset with no remembered account: false opens the sign-in page, true
|
||||
// keeps Rec Room's behaviour of creating a new account automatically.
|
||||
#ifndef DN_AUTO_CREATE_ACCOUNT
|
||||
# define DN_AUTO_CREATE_ACCOUNT false
|
||||
#endif
|
||||
#ifndef DN_LOG_HTTP
|
||||
# define DN_LOG_HTTP true
|
||||
#endif
|
||||
@@ -82,7 +87,7 @@ void dn_load_runtime_config(char *server_host, size_t server_host_cap,
|
||||
char *photon_realtime, char *photon_voice, char *photon_chat,
|
||||
char *photon_server, size_t photon_server_cap, int *photon_port,
|
||||
char *photon_region, size_t photon_region_cap,
|
||||
bool *tls_bypass, bool *accept_images,
|
||||
bool *tls_bypass, bool *accept_images, bool *auto_create_account,
|
||||
bool *log_http, bool *debug_http, int *body_max);
|
||||
|
||||
#endif // DORKNET_QUEST_CONFIG_H
|
||||
|
||||
@@ -59,6 +59,7 @@ static int g_photon_port = DN_PHOTON_PORT;
|
||||
static char g_photon_region[16] = DN_PHOTON_REGION;
|
||||
static bool g_tls_bypass = DN_TLS_TRUST_BYPASS;
|
||||
static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES;
|
||||
static bool g_auto_create_account = DN_AUTO_CREATE_ACCOUNT;
|
||||
static bool g_log_http = DN_LOG_HTTP;
|
||||
static bool g_debug_http = DN_DEBUG_HTTP;
|
||||
static int g_body_max = DN_LOG_BODY_MAX;
|
||||
@@ -89,8 +90,12 @@ static const size_t kArrayDataOff = 0x20;
|
||||
// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME).
|
||||
static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u;
|
||||
static const unsigned kMsgUserGetUserProof = 578880643u;
|
||||
static const unsigned kMsgUserGetLoggedInUser = 1131361373u;
|
||||
static const char *kPlaceholderNonce = "dorknet-no-platform-account";
|
||||
static const unsigned long long kNoPlatformUserId = 1ULL;
|
||||
// Stand-in ovrUserHandle for a GetLoggedInUser that Meta refused. Only our
|
||||
// hooked ovr_User_* accessors ever see it; they never pass it to the SDK.
|
||||
static void *const kPlaceholderUser = (void *)0x2;
|
||||
|
||||
static const size_t kLogChunk = 1500;
|
||||
static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS
|
||||
@@ -103,12 +108,22 @@ typedef void *(*il2cpp_class_get_method_from_name_t)(void *klass, const char *na
|
||||
typedef void *(*il2cpp_object_new_t)(void *klass);
|
||||
typedef void *(*il2cpp_string_new_t)(const char *str);
|
||||
typedef void *(*il2cpp_runtime_invoke_t)(void *method, void *obj, void **params, void **exc);
|
||||
typedef void *(*il2cpp_domain_get_t)();
|
||||
typedef void **(*il2cpp_domain_get_assemblies_t)(void *domain, size_t *size);
|
||||
typedef void *(*il2cpp_assembly_get_image_t)(void *assembly);
|
||||
typedef const char *(*il2cpp_image_get_name_t)(void *image);
|
||||
typedef void *(*il2cpp_class_from_name_t)(void *image, const char *ns, const char *name);
|
||||
|
||||
static il2cpp_object_get_class_t il2cpp_object_get_class;
|
||||
static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name;
|
||||
static il2cpp_object_new_t il2cpp_object_new;
|
||||
static il2cpp_string_new_t il2cpp_string_new;
|
||||
static il2cpp_runtime_invoke_t il2cpp_runtime_invoke;
|
||||
static il2cpp_domain_get_t il2cpp_domain_get;
|
||||
static il2cpp_domain_get_assemblies_t il2cpp_domain_get_assemblies;
|
||||
static il2cpp_assembly_get_image_t il2cpp_assembly_get_image;
|
||||
static il2cpp_image_get_name_t il2cpp_image_get_name;
|
||||
static il2cpp_class_from_name_t il2cpp_class_from_name;
|
||||
|
||||
static uintptr_t g_il2cpp_base;
|
||||
|
||||
@@ -139,6 +154,30 @@ static const unsigned char *invoke_bytes(void *obj, const char *method, size_t *
|
||||
return (const unsigned char *)((char *)arr + kArrayDataOff);
|
||||
}
|
||||
|
||||
static bool invoke_void(void *obj, const char *method) {
|
||||
if (!obj) return false;
|
||||
void *cls = il2cpp_object_get_class(obj);
|
||||
if (!cls) return false;
|
||||
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||
if (!m) return false;
|
||||
void *exc = nullptr;
|
||||
il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||
return !exc;
|
||||
}
|
||||
|
||||
static void *find_class(const char *image_name, const char *ns, const char *name) {
|
||||
if (!il2cpp_domain_get || !il2cpp_domain_get_assemblies || !il2cpp_assembly_get_image ||
|
||||
!il2cpp_image_get_name || !il2cpp_class_from_name) return nullptr;
|
||||
size_t n = 0;
|
||||
void **assemblies = il2cpp_domain_get_assemblies(il2cpp_domain_get(), &n);
|
||||
for (size_t i = 0; assemblies && i < n; i++) {
|
||||
void *image = il2cpp_assembly_get_image(assemblies[i]);
|
||||
const char *iname = image ? il2cpp_image_get_name(image) : nullptr;
|
||||
if (iname && !strcmp(iname, image_name)) return il2cpp_class_from_name(image, ns, name);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static void invoke_str(void *obj, const char *method, char *out, size_t cap) {
|
||||
out[0] = '\0';
|
||||
if (!obj) return;
|
||||
@@ -438,14 +477,73 @@ typedef unsigned (*ovr_GetType_t)(void *m);
|
||||
static ovr_IsError_t orig_ovr_IsError;
|
||||
static ovr_GetType_t ovr_GetType;
|
||||
|
||||
static bool is_bypassed_type(unsigned t) {
|
||||
return t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof ||
|
||||
t == kMsgUserGetLoggedInUser;
|
||||
}
|
||||
|
||||
// The SDK's Message constructor checks IsError twice: on the message, and --
|
||||
// when that passes -- on ovr_Message_GetNativeMessage(message). The inner
|
||||
// message carries the platform's refusal too but doesn't report the outer
|
||||
// type, so remember the inner handle of a bypassed message (same thread, the
|
||||
// constructor asks about it immediately) and pass it as well.
|
||||
static __thread void *t_bypassed_native;
|
||||
static __thread unsigned t_bypassed_type;
|
||||
|
||||
static bool hk_ovr_IsError(void *m) {
|
||||
bool real = orig_ovr_IsError(m);
|
||||
if (!real || !ovr_GetType) return real;
|
||||
unsigned t = ovr_GetType(m);
|
||||
if (t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof) return false;
|
||||
if (!real) return real;
|
||||
if (m && m == t_bypassed_native) return false;
|
||||
if (ovr_GetType && is_bypassed_type(ovr_GetType(m))) return false;
|
||||
return real;
|
||||
}
|
||||
|
||||
typedef void *(*ovr_GetNativeMessage_t)(void *m);
|
||||
static ovr_GetNativeMessage_t orig_ovr_GetNativeMessage;
|
||||
static void *hk_ovr_GetNativeMessage(void *m) {
|
||||
void *native = orig_ovr_GetNativeMessage(m);
|
||||
unsigned t = ovr_GetType ? ovr_GetType(m) : 0;
|
||||
t_bypassed_native = is_bypassed_type(t) ? native : nullptr;
|
||||
t_bypassed_type = t;
|
||||
return native;
|
||||
}
|
||||
|
||||
// OculusPlatformManager.LoadUser asks for the logged-in user; on a headset
|
||||
// that reaches Meta, Meta refuses it for this re-packaged app, LoadUser
|
||||
// rejects ("GetLoggedInUser failed") and Initialize shows "Unable to load
|
||||
// Oculus user account". A headset that can't reach Meta gets a cached user
|
||||
// and never hits this. Answer a refused request with a placeholder user whose
|
||||
// ID is kNoPlatformUserId -- the same placeholder the server already treats
|
||||
// as "no platform account" (PlayerService.IsPlaceholderPlatformId).
|
||||
static void *(*orig_ovr_Message_GetUser)(void *m);
|
||||
static void *hk_ovr_Message_GetUser(void *m) {
|
||||
void *u = orig_ovr_Message_GetUser ? orig_ovr_Message_GetUser(m) : nullptr;
|
||||
if (u) return u;
|
||||
// The SDK reads the user from the inner native message
|
||||
// (GetUser(GetNativeMessage(msg))), so match that handle as well.
|
||||
bool logged_in_user = (m && m == t_bypassed_native && t_bypassed_type == kMsgUserGetLoggedInUser) ||
|
||||
(ovr_GetType && ovr_GetType(m) == kMsgUserGetLoggedInUser);
|
||||
if (!logged_in_user) return u;
|
||||
LOGI("GetLoggedInUser refused by the platform; using the no-platform placeholder user");
|
||||
return kPlaceholderUser;
|
||||
}
|
||||
|
||||
// Accessors the SDK's User model reads. Real users pass straight through; the
|
||||
// placeholder gets empty strings and zero values.
|
||||
#define DN_USER_STR_ACCESSOR(name) static const char *(*orig_##name)(void *u); static const char *hk_##name(void *u) { if (u == kPlaceholderUser) return ""; return orig_##name ? orig_##name(u) : ""; }
|
||||
#define DN_USER_VAL_ACCESSOR(name) static uintptr_t (*orig_##name)(void *u); static uintptr_t hk_##name(void *u) { if (u == kPlaceholderUser) return 0; return orig_##name ? orig_##name(u) : 0; }
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetDisplayName)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetOculusID)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetImageUrl)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetSmallImageUrl)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetInviteToken)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresence)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDeeplinkMessage)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDestinationApiName)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceLobbySessionId)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceMatchSessionId)
|
||||
DN_USER_VAL_ACCESSOR(ovr_User_GetPresenceStatus) // ovrUserPresenceStatus: 0 = Unknown
|
||||
|
||||
static void *(*orig_ovr_GetUserProof)(void *m);
|
||||
static void *hk_ovr_GetUserProof(void *m) {
|
||||
void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr;
|
||||
@@ -469,10 +567,46 @@ static unsigned long long hk_ovr_GetLoggedInUserID() {
|
||||
|
||||
static unsigned long long (*orig_ovr_User_GetID)(void *u);
|
||||
static unsigned long long hk_ovr_User_GetID(void *u) {
|
||||
if (u == kPlaceholderUser) return kNoPlatformUserId;
|
||||
unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0;
|
||||
return real ? real : kNoPlatformUserId;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ title screen --
|
||||
|
||||
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time auto-login:
|
||||
// the title screen downloads the headset's remembered accounts and logs into
|
||||
// one; with none (null) it creates a brand-new account instead
|
||||
// (PJMGFADOEEM -> grant_type create_account). A server with sign-ups off
|
||||
// refuses that, the error handler shows the connection-error page and the
|
||||
// flow tries again, forever. Unless AutoCreateAccount is on, open the sign-in
|
||||
// page instead (TitleScreenFlowModel.GoToLogin, what the "Log in" button
|
||||
// does). ManuallyLogin carries the player into the game from there on its own,
|
||||
// so the boot step gets a promise that never settles.
|
||||
static void *(*orig_TitleAutoLogin)(void *thiz, void *cached, void *method);
|
||||
static void *new_pending_promise() {
|
||||
// MAMJDBJDFAF: the game's non-generic promise (RecRoom.Promises.Runtime);
|
||||
// a fresh one stays pending.
|
||||
static void *cls = find_class("RecRoom.Promises.Runtime.dll", "", "MAMJDBJDFAF");
|
||||
if (!cls) return nullptr;
|
||||
void *ctor = il2cpp_class_get_method_from_name(cls, ".ctor", 0);
|
||||
if (!ctor) return nullptr;
|
||||
void *p = il2cpp_object_new(cls);
|
||||
void *exc = nullptr;
|
||||
if (p) il2cpp_runtime_invoke(ctor, p, nullptr, &exc);
|
||||
return exc ? nullptr : p;
|
||||
}
|
||||
static void *hk_TitleAutoLogin(void *thiz, void *cached, void *method) {
|
||||
if (cached || g_auto_create_account) return orig_TitleAutoLogin(thiz, cached, method);
|
||||
void *pending = new_pending_promise();
|
||||
if (!pending || !invoke_void(thiz, "GoToLogin")) {
|
||||
LOGE("no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
|
||||
return orig_TitleAutoLogin(thiz, cached, method);
|
||||
}
|
||||
LOGI("no remembered account on this headset; opened the sign-in page instead of creating a new account");
|
||||
return pending;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- init ------
|
||||
|
||||
static uintptr_t module_base(const char *name) {
|
||||
@@ -516,6 +650,12 @@ static bool resolve_il2cpp(void *h) {
|
||||
SYM(il2cpp_string_new)
|
||||
SYM(il2cpp_runtime_invoke)
|
||||
#undef SYM
|
||||
// Optional: only the title-screen sign-in redirect needs these.
|
||||
il2cpp_domain_get = (il2cpp_domain_get_t)dlsym(h, "il2cpp_domain_get");
|
||||
il2cpp_domain_get_assemblies = (il2cpp_domain_get_assemblies_t)dlsym(h, "il2cpp_domain_get_assemblies");
|
||||
il2cpp_assembly_get_image = (il2cpp_assembly_get_image_t)dlsym(h, "il2cpp_assembly_get_image");
|
||||
il2cpp_image_get_name = (il2cpp_image_get_name_t)dlsym(h, "il2cpp_image_get_name");
|
||||
il2cpp_class_from_name = (il2cpp_class_from_name_t)dlsym(h, "il2cpp_class_from_name");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -537,6 +677,8 @@ static void arm_oculus_platform_bypass() {
|
||||
return;
|
||||
}
|
||||
void *a;
|
||||
if ((a = dlsym(ovr, "ovr_Message_GetNativeMessage")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetNativeMessage, (void **)&orig_ovr_GetNativeMessage);
|
||||
if ((a = dlsym(ovr, "ovr_GetLoggedInUserID")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID);
|
||||
if ((a = dlsym(ovr, "ovr_User_GetID")))
|
||||
@@ -545,6 +687,21 @@ static void arm_oculus_platform_bypass() {
|
||||
A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof);
|
||||
if ((a = dlsym(ovr, "ovr_UserProof_GetNonce")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_GetNonce);
|
||||
if ((a = dlsym(ovr, "ovr_Message_GetUser")))
|
||||
A64HookFunction(a, (void *)hk_ovr_Message_GetUser, (void **)&orig_ovr_Message_GetUser);
|
||||
#define DN_HOOK_USER(name) if ((a = dlsym(ovr, #name))) A64HookFunction(a, (void *)hk_##name, (void **)&orig_##name);
|
||||
DN_HOOK_USER(ovr_User_GetDisplayName)
|
||||
DN_HOOK_USER(ovr_User_GetOculusID)
|
||||
DN_HOOK_USER(ovr_User_GetImageUrl)
|
||||
DN_HOOK_USER(ovr_User_GetSmallImageUrl)
|
||||
DN_HOOK_USER(ovr_User_GetInviteToken)
|
||||
DN_HOOK_USER(ovr_User_GetPresence)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceDeeplinkMessage)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceDestinationApiName)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceLobbySessionId)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceMatchSessionId)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceStatus)
|
||||
#undef DN_HOOK_USER
|
||||
}
|
||||
|
||||
static void *init_thread(void *) {
|
||||
@@ -562,7 +719,7 @@ static void *init_thread(void *) {
|
||||
g_photon_realtime, g_photon_voice, g_photon_chat,
|
||||
g_photon_server, sizeof(g_photon_server), &g_photon_port,
|
||||
g_photon_region, sizeof(g_photon_region),
|
||||
&g_tls_bypass, &g_accept_images, &g_log_http, &g_debug_http, &g_body_max);
|
||||
&g_tls_bypass, &g_accept_images, &g_auto_create_account, &g_log_http, &g_debug_http, &g_body_max);
|
||||
|
||||
LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s",
|
||||
DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host);
|
||||
@@ -586,6 +743,8 @@ static void *init_thread(void *) {
|
||||
arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr);
|
||||
}
|
||||
|
||||
arm(&dn_targets.title_auto_login, (void *)hk_TitleAutoLogin, (void **)&orig_TitleAutoLogin);
|
||||
|
||||
if (g_accept_images)
|
||||
arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr);
|
||||
|
||||
|
||||
@@ -111,6 +111,12 @@
|
||||
"length": 412,
|
||||
"fingerprint": "0xb12b0044ac4f844f",
|
||||
"why": "Log-only: prints the quit code and caller."
|
||||
},
|
||||
"title_auto_login": {
|
||||
"rva": 58892048,
|
||||
"length": 668,
|
||||
"fingerprint": "0x83bebb9a30983b33",
|
||||
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,12 @@
|
||||
"class": "public class SessionManager ",
|
||||
"method": "public static void TryApplicationQuit(int DKOGJICFAEP)",
|
||||
"why": "Log-only: prints the quit code and caller."
|
||||
},
|
||||
{
|
||||
"id": "title_auto_login",
|
||||
"class": "public class TitleScreenFlowModel ",
|
||||
"method": "private LDGADANDBIO IAKHIJIHAPJ(LBNJFPOLCDL.MDGJJCFGJNF AJCKAHHJDEN)",
|
||||
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
<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>
|
||||
<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'}
|
||||
</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>
|
||||
{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>
|
||||
)}
|
||||
<button className="btn-secondary" disabled={busy !== null} onClick={() => fileRef.current?.click()}>
|
||||
{busy === 'file' && !latest ? 'Reading…' : xapk && !latest ? 'Choose a different file' : 'Choose file'}
|
||||
</button>
|
||||
</div>
|
||||
{xapk && (
|
||||
<p className="text-sm text-ink-300">
|
||||
Selected: <span className="text-ink-100 font-medium">{name}</span> {xapk.manifest.version_name} · {fmtSize(totalSize)}
|
||||
<span className="text-ink-500"> · {xapk.source.name}</span>
|
||||
</p>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
<Step n={2} title="Connect your headset" done={!!headset}>
|
||||
|
||||
Vendored
+2
-1
@@ -27,6 +27,7 @@ pipeline {
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
ansiColor('xterm')
|
||||
disableConcurrentBuilds()
|
||||
// Full clients and xapks are several GB each: keep artifacts for the
|
||||
// last few runs only.
|
||||
@@ -188,7 +189,7 @@ pipeline {
|
||||
// Runs after archiving. Keeps the restic cache and restored stock
|
||||
// builds ($DORKNET_CACHE in jenkins_home, outside the workspace); drops
|
||||
// the workspace's build output and scratch.
|
||||
deleteDir()
|
||||
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,16 @@ keep the prefab value) trade per-frame size for bandwidth if a high FPS
|
||||
saturates the link. Only the player *sharing* needs the override. Default
|
||||
`0` (off).
|
||||
|
||||
### Sign-in page instead of a new account
|
||||
|
||||
When the title screen finds no remembered account for the machine, Rec Room
|
||||
creates a brand-new account automatically. On a server with sign-ups off that
|
||||
is refused and the title screen keeps retrying. By default the mod opens the
|
||||
**sign-in page** instead, so players log into their existing DorkNet account.
|
||||
Set `"AutoCreateAccount": true` in `dorknet-clientmod.json` to keep Rec Room's
|
||||
behaviour on a server that allows sign-ups. The Quest build has the same
|
||||
option (`AutoCreateAccount` in `dorknet-quest.cfg`).
|
||||
|
||||
### RRO quest team size (opt-in)
|
||||
|
||||
RRO quests ship with a baked 4-player `GameConfigurationAsset` team cap.
|
||||
|
||||
@@ -91,6 +91,8 @@ The job needs these plugins:
|
||||
- GitHub (for the push trigger)
|
||||
- Credentials Binding
|
||||
- Timestamper
|
||||
- AnsiColor
|
||||
- Workspace Cleanup
|
||||
|
||||
Most are part of the suggested set.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+19
-1
@@ -55,6 +55,17 @@ an account, never matched by the cached-login lookup
|
||||
players are keyed by `deviceId` alone, exactly like the PC Steam-emulator path.
|
||||
A real Meta platform id (the Meta build) still tags and lists normally.
|
||||
|
||||
Because the placeholder never lists a remembered account, the title screen
|
||||
would normally create a new account on every fresh headset. With
|
||||
`AutoCreateAccount = false` (the default) the hook library opens the sign-in
|
||||
page instead, so players log into their existing DorkNet account; set it to
|
||||
`true` only on a server that allows sign-ups.
|
||||
|
||||
On a headset that reaches Meta, the Oculus platform also refuses the
|
||||
"logged-in user" request for the renamed app ("Unable to load Oculus user
|
||||
account"). The no-Meta build passes that refusal and hands the game the same
|
||||
placeholder user id `1`.
|
||||
|
||||
Thanks to [RecFlare](https://github.com/recflare) for documenting this
|
||||
pitfall in their Quest client notes.
|
||||
|
||||
@@ -74,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