mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d59c9648e3 |
@@ -156,14 +156,6 @@ 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()
|
||||
{
|
||||
@@ -233,14 +225,6 @@ 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.
|
||||
@@ -473,7 +457,6 @@ 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)
|
||||
{
|
||||
@@ -679,7 +662,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), typeof(TitleScreenLoginPatches) })
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
@@ -920,52 +903,6 @@ 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,7 +1,6 @@
|
||||
{
|
||||
"ServerHost": "{HOST}",
|
||||
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
||||
"AutoCreateAccount": false,
|
||||
"EnableDebugConsole": false,
|
||||
"DebugConsoleToggleKey": "BackQuote",
|
||||
"DevCommands": [],
|
||||
|
||||
@@ -30,8 +30,7 @@ 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) 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) | — |
|
||||
| Oculus `ovr_*` platform calls | (no-Meta build only) steer the entitlement/login path to the client's own credential screen | — |
|
||||
|
||||
### Two builds, selected by `OculusAppId`
|
||||
|
||||
@@ -205,8 +204,6 @@ 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,12 +65,6 @@ 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,7 +23,6 @@ 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,11 +57,6 @@
|
||||
#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
|
||||
@@ -87,7 +82,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 *auto_create_account,
|
||||
bool *tls_bypass, bool *accept_images,
|
||||
bool *log_http, bool *debug_http, int *body_max);
|
||||
|
||||
#endif // DORKNET_QUEST_CONFIG_H
|
||||
|
||||
@@ -59,7 +59,6 @@ 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;
|
||||
@@ -90,12 +89,8 @@ 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
|
||||
@@ -108,22 +103,12 @@ 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;
|
||||
|
||||
@@ -154,30 +139,6 @@ 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;
|
||||
@@ -477,73 +438,14 @@ 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) return real;
|
||||
if (m && m == t_bypassed_native) return false;
|
||||
if (ovr_GetType && is_bypassed_type(ovr_GetType(m))) return false;
|
||||
if (!real || !ovr_GetType) return real;
|
||||
unsigned t = ovr_GetType(m);
|
||||
if (t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof) 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;
|
||||
@@ -567,46 +469,10 @@ 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) {
|
||||
@@ -650,12 +516,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -677,8 +537,6 @@ 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")))
|
||||
@@ -687,21 +545,6 @@ 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 *) {
|
||||
@@ -719,7 +562,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_auto_create_account, &g_log_http, &g_debug_http, &g_body_max);
|
||||
&g_tls_bypass, &g_accept_images, &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);
|
||||
@@ -743,8 +586,6 @@ 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,12 +111,6 @@
|
||||
"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,12 +114,6 @@
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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,9 +238,6 @@ 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,13 +217,6 @@ 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,7 +8,6 @@ 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.
|
||||
@@ -28,7 +27,6 @@ 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,7 +1,6 @@
|
||||
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 },
|
||||
@@ -11,8 +10,6 @@ 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);
|
||||
@@ -20,12 +17,6 @@ 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.
|
||||
@@ -46,7 +37,7 @@ export function Header() {
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1 ml-2">
|
||||
{navItems.map(n => (
|
||||
{NAV.map(n => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
@@ -94,7 +85,7 @@ export function Header() {
|
||||
/>
|
||||
</form>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{navItems.map(n => (
|
||||
{NAV.map(n => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// 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.source, xapk.apk), (n) => {
|
||||
const stream = counted(await entryStream(xapk.file, 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.source, entry), (n) => {
|
||||
const stream = counted(await entryStream(xapk.file, entry), (n) => {
|
||||
done += n;
|
||||
onProgress({ phase: 'data', label: 'Copying game data', done, total });
|
||||
});
|
||||
|
||||
@@ -1,59 +1,9 @@
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
@@ -76,7 +26,7 @@ export interface XapkManifest {
|
||||
}
|
||||
|
||||
export interface Xapk {
|
||||
source: ByteSource;
|
||||
file: File;
|
||||
manifest: XapkManifest;
|
||||
entries: Map<string, XapkEntry>;
|
||||
apk: XapkEntry;
|
||||
@@ -89,18 +39,18 @@ const ZIP64_EOCD = 0x06064b50;
|
||||
const CENTRAL = 0x02014b50;
|
||||
const LOCAL = 0x04034b50;
|
||||
|
||||
async function bytes(src: ByteSource, start: number, end: number): Promise<DataView> {
|
||||
return new DataView(await src.read(start, end));
|
||||
async function bytes(file: Blob, start: number, end: number): Promise<DataView> {
|
||||
return new DataView(await file.slice(start, end).arrayBuffer());
|
||||
}
|
||||
|
||||
function u64(v: DataView, at: number): number {
|
||||
return Number(v.getBigUint64(at, true));
|
||||
}
|
||||
|
||||
export async function openXapk(src: ByteSource): Promise<Xapk> {
|
||||
export async function openXapk(file: File): Promise<Xapk> {
|
||||
// End of central directory: last 22 bytes plus up to 64 KiB of comment.
|
||||
const tailStart = Math.max(0, src.size - (22 + 0xffff + 20));
|
||||
const tail = await bytes(src, tailStart, src.size);
|
||||
const tailStart = Math.max(0, file.size - (22 + 0xffff + 20));
|
||||
const tail = await bytes(file, tailStart, file.size);
|
||||
let eocd = -1;
|
||||
for (let i = tail.byteLength - 22; i >= 0; i--) {
|
||||
if (tail.getUint32(i, true) === EOCD) { eocd = i; break; }
|
||||
@@ -115,14 +65,14 @@ export async function openXapk(src: ByteSource): 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(src, z64At, z64At + 56);
|
||||
const z64 = await bytes(file, 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(src, cdOffset, cdOffset + cdSize);
|
||||
const cd = await bytes(file, cdOffset, cdOffset + cdSize);
|
||||
const entries = new Map<string, XapkEntry>();
|
||||
const decoder = new TextDecoder();
|
||||
let p = 0;
|
||||
@@ -159,7 +109,7 @@ export async function openXapk(src: ByteSource): 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(src, manifestEntry)).text()) as XapkManifest;
|
||||
const manifest = JSON.parse(await new Response(await entryStream(file, 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`)
|
||||
@@ -172,15 +122,15 @@ export async function openXapk(src: ByteSource): Promise<Xapk> {
|
||||
return { entry, installPath: x.install_path };
|
||||
});
|
||||
|
||||
return { source: src, manifest, entries, apk, expansions };
|
||||
return { file, manifest, entries, apk, expansions };
|
||||
}
|
||||
|
||||
/** The entry's uncompressed bytes as a stream. */
|
||||
export async function entryStream(src: ByteSource, entry: XapkEntry): Promise<ReadableStream<Uint8Array<ArrayBuffer>>> {
|
||||
const local = await bytes(src, entry.localHeaderOffset, entry.localHeaderOffset + 30);
|
||||
export async function entryStream(file: File, entry: XapkEntry): Promise<ReadableStream<Uint8Array>> {
|
||||
const local = await bytes(file, 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 = await src.stream(start, start + entry.compressedSize);
|
||||
const raw = file.slice(start, start + entry.compressedSize).stream();
|
||||
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}.`);
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
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,8 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk';
|
||||
import { useRef, useState } from 'react';
|
||||
import { openXapk, 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`;
|
||||
@@ -45,36 +43,15 @@ 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 open = async (load: () => Promise<Xapk>) => {
|
||||
const pickFile = async (f: File | undefined) => {
|
||||
if (!f) return;
|
||||
setErr(null); setDone(false); setProgress({}); setBusy('file');
|
||||
try { setXapk(await load()); }
|
||||
try { setXapk(await openXapk(f)); }
|
||||
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()); }
|
||||
@@ -131,32 +108,19 @@ export function Quest() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
<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">
|
||||
{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'}
|
||||
{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>
|
||||
)}
|
||||
</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
+23
-69
@@ -1,21 +1,17 @@
|
||||
// DorkNet client builds — PC (patcher zip + full client) and Quest (.xapk).
|
||||
//
|
||||
// Runs on the "dorknet-builder" agent from ci/jenkins (an inbound agent with
|
||||
// Python + LIEF, .NET, the Android NDK/build-tools, apktool, restic and rclone).
|
||||
// Runs on the Jenkins image from ci/jenkins (the stock image plus Python with
|
||||
// LIEF, .NET, the Android NDK/build-tools, apktool, restic and rclone).
|
||||
//
|
||||
// Stock builds come out of the restic build archive on the FTP. Where it lives
|
||||
// is server configuration, not part of the repo: the FTP host, login and the
|
||||
// restic password are Jenkins credentials, and the repository folders come
|
||||
// from the agent's environment (ARCHIVE_PC_REPO / ARCHIVE_QUEST_REPO), each
|
||||
// overridable per run. A build is picked by snapshot tags.
|
||||
// from the Jenkins container's environment (ARCHIVE_PC_REPO /
|
||||
// ARCHIVE_QUEST_REPO), each overridable per run. A build is picked by
|
||||
// snapshot tags.
|
||||
//
|
||||
// A push builds only the platforms whose client code changed (never for
|
||||
// docs or CI-only changes). A manual run picks the platforms with PLATFORMS.
|
||||
// See ci/jenkins/README.md.
|
||||
//
|
||||
// Finished builds are renamed for players and published to the downloads bucket
|
||||
// (ci/downloads) unless PUBLISH_DOWNLOADS is off. The build scripts keep their
|
||||
// own file names, which the launcher depends on.
|
||||
// On a push, only the platforms whose code changed are rebuilt. A manual run
|
||||
// picks the platforms with PLATFORMS. See ci/jenkins/README.md.
|
||||
|
||||
// The builds this branch targets. Any other archived build can be picked per
|
||||
// run with the *_SNAPSHOT_TAGS parameters.
|
||||
@@ -23,20 +19,14 @@ def PC_VERSION_KEY = 'march_2023_03_21'
|
||||
def PC_DEFAULT_TAGS = 'manifest=7490748483298966814' // Steam 2023-03-21 (appVersion 20230317)
|
||||
def QUEST_DEFAULT_TAGS = 'version-code=2128210' // Quest 20230317
|
||||
|
||||
// How this branch's builds are listed on the site's Downloads page.
|
||||
def RELEASE_ID = '2023.03'
|
||||
def RELEASE_TITLE = 'March 2023'
|
||||
def RELEASE_GAME_VERSION = '2023-03-21'
|
||||
|
||||
def buildPc = false
|
||||
def buildQuest = false
|
||||
|
||||
pipeline {
|
||||
agent { label 'dorknet-builder' }
|
||||
agent any
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
ansiColor('xterm')
|
||||
disableConcurrentBuilds()
|
||||
// Full clients and xapks are several GB each: keep artifacts for the
|
||||
// last few runs only.
|
||||
@@ -54,13 +44,11 @@ pipeline {
|
||||
string(name: 'QUEST_SNAPSHOT_TAGS', defaultValue: '',
|
||||
description: "restic tags picking the stock Quest build, comma = AND. Empty: ${QUEST_DEFAULT_TAGS}")
|
||||
string(name: 'PC_REPO', defaultValue: '',
|
||||
description: 'PC restic repository folder on the FTP. Empty: ARCHIVE_PC_REPO set on the agent.')
|
||||
description: 'PC restic repository folder on the FTP. Empty: ARCHIVE_PC_REPO from the Jenkins environment.')
|
||||
string(name: 'QUEST_REPO', defaultValue: '',
|
||||
description: 'Quest restic repository folder on the FTP. Empty: ARCHIVE_QUEST_REPO set on the agent.')
|
||||
description: 'Quest restic repository folder on the FTP. Empty: ARCHIVE_QUEST_REPO from the Jenkins environment.')
|
||||
booleanParam(name: 'LIST_SNAPSHOTS', defaultValue: false,
|
||||
description: 'Only list the snapshots matching the tags in both repositories (to find a build), then stop.')
|
||||
booleanParam(name: 'PUBLISH_DOWNLOADS', defaultValue: true,
|
||||
description: 'Publish what this run built to the downloads bucket (listed on the site Downloads page). Push builds publish too.')
|
||||
}
|
||||
|
||||
stages {
|
||||
@@ -77,15 +65,10 @@ pipeline {
|
||||
for (cs in currentBuild.changeSets) {
|
||||
for (entry in cs.items) changed.addAll(entry.affectedPaths)
|
||||
}
|
||||
// Only client-facing changes rebuild (and so republish) a
|
||||
// platform; Markdown (.md, .markdown) doesn't count. The job's Git path filter
|
||||
// uses the same paths, so other pushes don't start a run at all.
|
||||
// A CI-only change (Jenkinsfile, ci/) takes a manual run.
|
||||
def touches = { prefixes ->
|
||||
changed.any { p -> !(p.toLowerCase() ==~ /.*\.(md|markdown)/) && prefixes.any { p.startsWith(it) } }
|
||||
}
|
||||
buildPc = touches(['DorkNet.ClientMod/', 'tools/build_pc.py'])
|
||||
buildQuest = touches(['DorkNet.Quest/'])
|
||||
def touches = { prefixes -> changed.any { p -> prefixes.any { p.startsWith(it) } } }
|
||||
def common = ['Jenkinsfile', 'ci/jenkins/']
|
||||
buildPc = touches(common + ['DorkNet.ClientMod/', 'tools/build_pc.py'])
|
||||
buildQuest = touches(common + ['DorkNet.Quest/'])
|
||||
echo "Push build; ${changed.size()} changed file(s)"
|
||||
} else {
|
||||
buildPc = params.PLATFORMS in ['both', 'pc']
|
||||
@@ -104,8 +87,8 @@ pipeline {
|
||||
"PC_TAGS=${params.PC_SNAPSHOT_TAGS}", "QUEST_TAGS=${params.QUEST_SNAPSHOT_TAGS}"]) {
|
||||
sh '''
|
||||
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
|
||||
pc="${PC_REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on the agent or PC_REPO on the run}}"
|
||||
quest="${QUEST_REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on the agent or QUEST_REPO on the run}}"
|
||||
pc="${PC_REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on Jenkins or PC_REPO on the run}}"
|
||||
quest="${QUEST_REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on Jenkins or QUEST_REPO on the run}}"
|
||||
echo "== PC: $pc"; archive-restore list "$pc" "$PC_TAGS"
|
||||
echo "== Quest: $quest"; archive-restore list "$quest" "$QUEST_TAGS"
|
||||
'''
|
||||
@@ -129,7 +112,7 @@ pipeline {
|
||||
withEnv(["REPO_OVERRIDE=${params.PC_REPO}", "TAGS=${params.PC_SNAPSHOT_TAGS ?: PC_DEFAULT_TAGS}"]) {
|
||||
sh(returnStdout: true, script: '''
|
||||
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
|
||||
repo="${REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on the agent or PC_REPO on the run}}"
|
||||
repo="${REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on Jenkins or PC_REPO on the run}}"
|
||||
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/pc" --exclude /.DepotDownloader
|
||||
''').trim().readLines().last()
|
||||
}
|
||||
@@ -157,7 +140,7 @@ pipeline {
|
||||
withEnv(["REPO_OVERRIDE=${params.QUEST_REPO}", "TAGS=${params.QUEST_SNAPSHOT_TAGS ?: QUEST_DEFAULT_TAGS}"]) {
|
||||
sh(returnStdout: true, script: '''
|
||||
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
|
||||
repo="${REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on the agent or QUEST_REPO on the run}}"
|
||||
repo="${REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on Jenkins or QUEST_REPO on the run}}"
|
||||
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/quest"
|
||||
''').trim().readLines().last()
|
||||
}
|
||||
@@ -195,46 +178,17 @@ pipeline {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Downloads') {
|
||||
when { expression { buildPc || buildQuest } }
|
||||
steps {
|
||||
sh """
|
||||
python3 ci/downloads/publish.py stage --out out --release ${RELEASE_ID} \\
|
||||
--title '${RELEASE_TITLE}' --game-version ${RELEASE_GAME_VERSION} --version-key ${PC_VERSION_KEY}
|
||||
"""
|
||||
script {
|
||||
if (params.PUBLISH_DOWNLOADS) {
|
||||
// The bucket's S3 endpoint and name come from the agent's
|
||||
// environment (DOWNLOADS_S3_ENDPOINT / DOWNLOADS_BUCKET).
|
||||
withCredentials([usernamePassword(credentialsId: 'downloads-s3',
|
||||
usernameVariable: 'DOWNLOADS_S3_KEY_ID', passwordVariable: 'DOWNLOADS_S3_SECRET')]) {
|
||||
sh 'python3 ci/downloads/publish.py upload --staged out/downloads'
|
||||
}
|
||||
} else {
|
||||
echo 'PUBLISH_DOWNLOADS is off: the files are only archived.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
// The renamed, player-facing files (and release.json): the same set the
|
||||
// downloads page offers.
|
||||
archiveArtifacts artifacts: 'out/downloads/**', allowEmptyArchive: true, fingerprint: true
|
||||
archiveArtifacts artifacts: 'out/**', allowEmptyArchive: true, fingerprint: true
|
||||
}
|
||||
cleanup {
|
||||
// Runs after archiving. Drops the build output and scratch (several
|
||||
// GB) but keeps the Git checkout: the job's path filter compares
|
||||
// pushes against it, and without a workspace Jenkins treats every
|
||||
// push as a change and starts a run. The restic cache and restored
|
||||
// stock builds live outside the workspace ($DORKNET_CACHE).
|
||||
cleanWs(deleteDirs: true, notFailBuild: true, patterns: [
|
||||
[pattern: 'out/**', type: 'INCLUDE'],
|
||||
[pattern: 'quest-work/**', type: 'INCLUDE'],
|
||||
])
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,16 +216,6 @@ 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.
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish DorkNet client builds to the downloads bucket.
|
||||
|
||||
stage the build output in out/ -> out/downloads/<release>/ under the
|
||||
names players see, plus release.json describing those files
|
||||
upload that folder -> the downloads bucket, then merge release.json into
|
||||
the bucket's manifest.json
|
||||
|
||||
The DorkNet server reads the bucket and offers the files on its site (the
|
||||
Downloads page, and Latest build on the Quest page); see docs/deploy.md.
|
||||
|
||||
The build scripts keep their own file names on purpose: the launcher fetches
|
||||
dorknet-clientpatch-<VersionKey>.zip from GitHub Releases by that exact name.
|
||||
Only the published copies are renamed:
|
||||
|
||||
dorknet-client-<key>.zip DorkNet-<release>-PC-Full.zip
|
||||
dorknet-clientpatch-<key>.zip DorkNet-<release>-PC-Patch.zip
|
||||
DorkNet-Quest-<ver>/DorkNet-<ver>.xapk DorkNet-<release>-Quest.xapk
|
||||
DorkNet-Quest-<ver>/install.*, README DorkNet-<release>-Quest-Installer.zip
|
||||
|
||||
The server reads the bucket over plain HTTP on the internal network (e.g.
|
||||
Garage's web endpoint); nothing needs to be public. upload talks to its S3
|
||||
API with rclone; the endpoint, bucket and key come from the environment:
|
||||
|
||||
DOWNLOADS_S3_ENDPOINT S3 API URL, e.g. http://garage-<uuid>:3900
|
||||
DOWNLOADS_BUCKET bucket name, e.g. dorknet-downloads
|
||||
DOWNLOADS_S3_REGION optional, default garage
|
||||
DOWNLOADS_S3_KEY_ID / DOWNLOADS_S3_SECRET
|
||||
|
||||
python3 ci/downloads/publish.py stage --out out --release 2023.03 \\
|
||||
--title "March 2023" --game-version 2023-03-21 --version-key march_2023_03_21
|
||||
python3 ci/downloads/publish.py upload --staged out/downloads
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
NO_CACHE = "Cache-Control: no-cache"
|
||||
|
||||
INSTALLER_NOTE = """Unzip these files into the same folder as {xapk}, then
|
||||
run install.cmd (Windows) or ./install.sh (macOS / Linux).
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def fail(msg: str) -> NoReturn:
|
||||
print(f"error: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def step(msg: str) -> None:
|
||||
print(f"==> {msg}", flush=True)
|
||||
|
||||
|
||||
def human(n: int) -> str:
|
||||
return f"{n / 2**20:,.1f} MB" if n >= 2**20 else f"{max(1, round(n / 2**10))} KB"
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
while chunk := f.read(1 << 20):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def link_or_copy(src: Path, dst: Path) -> None:
|
||||
"""Hard-link where possible: the full client is several GB."""
|
||||
dst.unlink(missing_ok=True)
|
||||
try:
|
||||
os.link(src, dst)
|
||||
except OSError:
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def game_exe(full_zip: Path) -> str | None:
|
||||
"""The game's exe: the one with a matching <name>_Data folder at the root."""
|
||||
with zipfile.ZipFile(full_zip) as z:
|
||||
names = z.namelist()
|
||||
tops = {n.split("/", 1)[0] for n in names}
|
||||
exes = sorted(t for t in tops if t.lower().endswith(".exe") and f"{t[:-4]}_Data" in tops)
|
||||
return exes[0] if exes else None
|
||||
|
||||
|
||||
# ---- stage ----------------------------------------------------------------
|
||||
|
||||
def cmd_stage(a: argparse.Namespace) -> None:
|
||||
out = Path(a.out)
|
||||
dest = Path(a.dest) if a.dest else out / "downloads"
|
||||
rel_dir = dest / a.release
|
||||
if rel_dir.exists():
|
||||
shutil.rmtree(rel_dir)
|
||||
rel_dir.mkdir(parents=True)
|
||||
prefix = f"DorkNet-{a.release}"
|
||||
|
||||
staged: list[tuple[str, str, Path]] = [] # (id, platform, staged file)
|
||||
extra: dict[str, str] = {}
|
||||
|
||||
full = out / "pc" / f"dorknet-client-{a.version_key}.zip"
|
||||
patch = out / "pc" / f"dorknet-clientpatch-{a.version_key}.zip"
|
||||
if full.is_file():
|
||||
dst = rel_dir / f"{prefix}-PC-Full.zip"
|
||||
link_or_copy(full, dst)
|
||||
staged.append(("pc-full", "pc", dst))
|
||||
exe = game_exe(full)
|
||||
if exe:
|
||||
extra["pc_exe"] = exe
|
||||
if patch.is_file():
|
||||
dst = rel_dir / f"{prefix}-PC-Patch.zip"
|
||||
link_or_copy(patch, dst)
|
||||
staged.append(("pc-patch", "pc", dst))
|
||||
|
||||
packages = sorted((out / "quest").glob("DorkNet-Quest-*/"))
|
||||
if len(packages) > 1:
|
||||
fail(f"more than one Quest package folder in {out / 'quest'}: {', '.join(p.name for p in packages)}")
|
||||
if packages:
|
||||
pkg = packages[0]
|
||||
xapks = sorted(pkg.glob("*.xapk"))
|
||||
if len(xapks) != 1:
|
||||
fail(f"expected one .xapk in {pkg}, found {len(xapks)}")
|
||||
xapk = rel_dir / f"{prefix}-Quest.xapk"
|
||||
link_or_copy(xapks[0], xapk)
|
||||
staged.append(("quest", "quest", xapk))
|
||||
|
||||
installer = rel_dir / f"{prefix}-Quest-Installer.zip"
|
||||
with zipfile.ZipFile(installer, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for name in ("install.cmd", "install.ps1", "install.sh"):
|
||||
src = pkg / name
|
||||
if not src.is_file():
|
||||
fail(f"{src} missing")
|
||||
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
||||
info.external_attr = (0o755 if name.endswith(".sh") else 0o644) << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
z.writestr(info, src.read_bytes())
|
||||
readme = pkg / "README.txt"
|
||||
text = INSTALLER_NOTE.format(xapk=xapk.name) + (readme.read_text(encoding="utf-8") if readme.is_file() else "")
|
||||
info = zipfile.ZipInfo("README.txt", date_time=(1980, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
z.writestr(info, text)
|
||||
staged.append(("quest-installer", "quest", installer))
|
||||
|
||||
if not staged:
|
||||
fail(f"nothing to publish in {out}")
|
||||
|
||||
built_at = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()
|
||||
files = []
|
||||
for fid, platform, path in staged:
|
||||
step(f"{path.name}: hashing")
|
||||
files.append({
|
||||
"id": fid,
|
||||
"platform": platform,
|
||||
"name": path.name,
|
||||
"path": f"{a.release}/{path.name}",
|
||||
"size": path.stat().st_size,
|
||||
"sha256": sha256(path),
|
||||
"build": a.build,
|
||||
"commit": a.commit,
|
||||
"built_at": built_at,
|
||||
})
|
||||
release = {"id": a.release, "title": a.title, "game_version": a.game_version, **extra, "files": files}
|
||||
(dest / "release.json").write_text(json.dumps(release, indent=2) + "\n", encoding="utf-8")
|
||||
print()
|
||||
for f in files:
|
||||
print(f" {f['name']} {human(f['size'])} {f['sha256'][:16]}…")
|
||||
print(f"Staged {len(files)} file(s) in {rel_dir}")
|
||||
|
||||
|
||||
# ---- upload ---------------------------------------------------------------
|
||||
|
||||
def rclone_env() -> dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
for k in ("DOWNLOADS_S3_ENDPOINT", "DOWNLOADS_BUCKET", "DOWNLOADS_S3_KEY_ID", "DOWNLOADS_S3_SECRET"):
|
||||
if not env.get(k):
|
||||
fail(f"{k} is not set")
|
||||
env.update({
|
||||
"RCLONE_CONFIG_DL_TYPE": "s3",
|
||||
"RCLONE_CONFIG_DL_PROVIDER": "Other",
|
||||
"RCLONE_CONFIG_DL_ENDPOINT": env["DOWNLOADS_S3_ENDPOINT"],
|
||||
"RCLONE_CONFIG_DL_REGION": env.get("DOWNLOADS_S3_REGION") or "garage",
|
||||
"RCLONE_CONFIG_DL_ACCESS_KEY_ID": env["DOWNLOADS_S3_KEY_ID"],
|
||||
"RCLONE_CONFIG_DL_SECRET_ACCESS_KEY": env["DOWNLOADS_S3_SECRET"],
|
||||
"RCLONE_CONFIG_DL_FORCE_PATH_STYLE": "true",
|
||||
"RCLONE_CONFIG_DL_NO_CHECK_BUCKET": "true",
|
||||
})
|
||||
return env
|
||||
|
||||
|
||||
def rclone(env: dict[str, str], *args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
|
||||
r = subprocess.run(["rclone", *args], env=env, input=stdin, capture_output=True, text=True)
|
||||
if check and r.returncode != 0:
|
||||
fail(f"rclone {args[0]} failed ({r.returncode}): {r.stderr.strip()}")
|
||||
return r
|
||||
|
||||
|
||||
def merge(manifest: dict, release: dict) -> dict:
|
||||
"""Replace this release's files that were just built; keep the others
|
||||
(a push build may rebuild only one platform)."""
|
||||
releases = [r for r in manifest.get("releases", []) if r.get("id") != release["id"]]
|
||||
old = next((r for r in manifest.get("releases", []) if r.get("id") == release["id"]), {})
|
||||
new_ids = {f["id"] for f in release["files"]}
|
||||
kept = [f for f in old.get("files", []) if f.get("id") not in new_ids]
|
||||
merged = {**old, **release, "files": kept + release["files"]}
|
||||
if "pc_exe" not in release and "pc_exe" in old:
|
||||
merged["pc_exe"] = old["pc_exe"]
|
||||
order = ["pc-full", "pc-patch", "quest", "quest-installer"]
|
||||
merged["files"].sort(key=lambda f: order.index(f["id"]) if f["id"] in order else len(order))
|
||||
releases.append(merged)
|
||||
releases.sort(key=lambda r: r["id"], reverse=True)
|
||||
out = {**manifest, "releases": releases,
|
||||
"updated": datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()}
|
||||
return out
|
||||
|
||||
|
||||
def cmd_upload(a: argparse.Namespace) -> None:
|
||||
staged = Path(a.staged)
|
||||
release = json.loads((staged / "release.json").read_text(encoding="utf-8"))
|
||||
env = rclone_env()
|
||||
bucket = f"dl:{env['DOWNLOADS_BUCKET']}"
|
||||
rel = release["id"]
|
||||
|
||||
# 1. the files, before the manifest that points at them
|
||||
for f in release["files"]:
|
||||
step(f"uploading {f['name']} ({human(f['size'])})")
|
||||
rclone(env, "copyto", str(staged / f["path"]), f"{bucket}/{f['path']}",
|
||||
"--header-upload", NO_CACHE, "--s3-upload-concurrency", "8", "--s3-chunk-size", "64M")
|
||||
|
||||
# 2. merge into the live manifest
|
||||
r = rclone(env, "cat", f"{bucket}/manifest.json", check=False)
|
||||
manifest = json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else {}
|
||||
manifest = merge(manifest, release)
|
||||
|
||||
# 3. the manifest, last: it is what makes the new files visible
|
||||
step("uploading manifest.json")
|
||||
rclone(env, "rcat", f"{bucket}/manifest.json", "--header-upload", NO_CACHE,
|
||||
"--header-upload", "Content-Type: application/json", stdin=json.dumps(manifest, indent=2) + "\n")
|
||||
|
||||
# 4. drop files of this release the manifest no longer lists (renames)
|
||||
listed = {f["path"] for r in manifest["releases"] if r["id"] == rel for f in r["files"]}
|
||||
r = rclone(env, "lsf", f"{bucket}/{rel}/", "--files-only", check=False)
|
||||
for name in r.stdout.split():
|
||||
if f"{rel}/{name}" not in listed:
|
||||
step(f"removing stale {rel}/{name}")
|
||||
rclone(env, "deletefile", f"{bucket}/{rel}/{name}")
|
||||
print(f"Published {len(release['files'])} file(s) for {release['title']}.")
|
||||
|
||||
|
||||
# ---- cli ------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
s = sub.add_parser("stage", help="out/ -> out/downloads/<release>/ + release.json")
|
||||
s.add_argument("--out", default="out", help="the build output folder (default: out)")
|
||||
s.add_argument("--dest", help="where to stage (default: <out>/downloads)")
|
||||
s.add_argument("--release", required=True, help="release id used in file names and paths, e.g. 2023.03")
|
||||
s.add_argument("--title", required=True, help='shown on the Downloads page, e.g. "March 2023"')
|
||||
s.add_argument("--game-version", required=True, help="the stock build's date, e.g. 2023-03-21")
|
||||
s.add_argument("--version-key", required=True, help="the PC build's version key, e.g. march_2023_03_21")
|
||||
s.add_argument("--build", default=os.environ.get("BUILD_NUMBER", ""), help="CI build number (default: $BUILD_NUMBER)")
|
||||
s.add_argument("--commit", default=os.environ.get("GIT_COMMIT", ""), help="source commit (default: $GIT_COMMIT)")
|
||||
s.set_defaults(fn=cmd_stage)
|
||||
|
||||
u = sub.add_parser("upload", help="staged folder -> downloads bucket + manifest.json")
|
||||
u.add_argument("--staged", default="out/downloads", help="the folder `stage` wrote (default: out/downloads)")
|
||||
u.set_defaults(fn=cmd_upload)
|
||||
|
||||
a = p.parse_args()
|
||||
a.fn(a)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+12
-13
@@ -1,7 +1,7 @@
|
||||
# DorkNet build agent: a Jenkins inbound agent with everything the PC and
|
||||
# Quest client builds need. It runs next to Jenkins on Coolify, connects to
|
||||
# the controller over WebSocket, and runs the jobs labelled "dorknet-builder"
|
||||
# (the Jenkinsfile at the repo root). Jenkins itself stays on the stock image.
|
||||
# DorkNet Jenkins: the stock Jenkins image plus everything the PC and Quest
|
||||
# client builds need, so the job (the Jenkinsfile at the repo root) runs right
|
||||
# on the controller. Jobs, plugins and settings stay in the jenkins_home
|
||||
# volume; only the image changes.
|
||||
#
|
||||
# PC Python + .NET SDK tools/build_pc.py
|
||||
# Quest Python/LIEF + JDK + Android NDK, build-tools, apktool
|
||||
@@ -9,13 +9,13 @@
|
||||
# Builds restic + rclone stock builds restored from the restic
|
||||
# archive on the FTP (archive-restore.sh)
|
||||
#
|
||||
# Java 25: Jenkins supports it until Sep 2029 (Java 21 only until Sep 2027).
|
||||
# Keep the agent's Java the same major version as the controller's.
|
||||
#
|
||||
# See ci/jenkins/README.md.
|
||||
|
||||
ARG AGENT_IMAGE=jenkins/inbound-agent:latest-jdk25
|
||||
FROM ${AGENT_IMAGE}
|
||||
# Match this to the Jenkins image your Dokploy service runs today, so the
|
||||
# upgrade keeps the same Jenkins version and Java: e.g.
|
||||
# --build-arg JENKINS_IMAGE=jenkins/jenkins:2.492.3-lts-jdk17
|
||||
ARG JENKINS_IMAGE=jenkins/jenkins:lts-jdk17
|
||||
FROM ${JENKINS_IMAGE}
|
||||
|
||||
USER root
|
||||
|
||||
@@ -73,10 +73,9 @@ RUN wget -q https://github.com/restic/restic/releases/download/v${RESTIC}/restic
|
||||
COPY archive-restore.sh /usr/local/bin/archive-restore
|
||||
RUN chmod +x /usr/local/bin/archive-restore
|
||||
|
||||
# Restored stock builds and the restic cache. Mount a volume here so each
|
||||
# multi-GB stock build is downloaded once, not after every restart.
|
||||
ENV DORKNET_CACHE=/home/jenkins/dorknet-cache
|
||||
RUN mkdir -p ${DORKNET_CACHE} && chown jenkins:jenkins ${DORKNET_CACHE}
|
||||
# Restored stock builds and the restic cache live in the persistent
|
||||
# jenkins_home volume, so each multi-GB build is downloaded once.
|
||||
ENV DORKNET_CACHE=/var/jenkins_home/dorknet-cache
|
||||
|
||||
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
|
||||
DOTNET_NOLOGO=1
|
||||
|
||||
+39
-150
@@ -3,34 +3,21 @@
|
||||
The `Jenkinsfile` at the repo root builds the DorkNet clients on your existing
|
||||
Jenkins.
|
||||
|
||||
The builds run on a **build agent**, not on Jenkins itself:
|
||||
- **The agent:** a container built from `ci/jenkins/Dockerfile`, which is the
|
||||
Jenkins inbound agent plus Python with LIEF, the .NET SDK, the Android NDK
|
||||
and build-tools, apktool, restic and rclone. It runs on Coolify next to
|
||||
Jenkins and connects to it over WebSocket on Docker's internal network. The
|
||||
job runs on it through the `dorknet-builder` label.
|
||||
- **Jenkins itself:** stays on the stock image and runs no builds.
|
||||
It runs right on the Jenkins container, so that container's image needs the
|
||||
build toolchain: Python with LIEF, the .NET SDK, the Android NDK and build-tools,
|
||||
apktool, restic and rclone. `ci/jenkins/Dockerfile` is the stock
|
||||
Jenkins image with that toolchain added. Your jobs, plugins and settings live
|
||||
in the `jenkins_home` volume, not in the image, so switching images leaves
|
||||
them alone.
|
||||
|
||||
Each build is published to the **downloads bucket** under a name players can
|
||||
read, and the same files are kept as Jenkins artifacts
|
||||
(`out/downloads/2023.03/`). The DorkNet server offers them on its site: the
|
||||
**Downloads** page, and **Latest build** on the **Quest** page
|
||||
(`docs/deploy.md` → Client downloads).
|
||||
|
||||
| Download | What it is | Built from |
|
||||
| Platform | Output (Jenkins artifacts) | How |
|
||||
|---|---|---|
|
||||
| `DorkNet-2023.03-PC-Full.zip` | the stock Steam build with the patcher applied and your config; ready to run | `tools/build_pc.py client` → `dorknet-client-march_2023_03_21.zip` |
|
||||
| `DorkNet-2023.03-PC-Patch.zip` | MelonLoader, mod, config template, Steam stub, `manifest.json`; no game files | `tools/build_pc.py patcher` → `dorknet-clientpatch-march_2023_03_21.zip` |
|
||||
| `DorkNet-2023.03-Quest.xapk` | patched APK + OBB, for the browser installer or SideQuest | `DorkNet.Quest/scripts/build.py build` → `DorkNet-Quest-<ver>/DorkNet-<ver>.xapk` |
|
||||
| `DorkNet-2023.03-Quest-Installer.zip` | `install.cmd` / `install.ps1` / `install.sh` + README, unzipped next to the `.xapk` | the same package folder |
|
||||
| PC | `out/pc/dorknet-clientpatch-march_2023_03_21.zip`: MelonLoader, mod, config template, Steam stub, `manifest.json`; no game files | `tools/build_pc.py patcher` |
|
||||
| PC | `out/pc/dorknet-client-march_2023_03_21.zip`: the stock Steam build with the patcher applied and your config; ready to run | `tools/build_pc.py client` |
|
||||
| Quest | `out/quest/dorknet-recroom-<code>.apk` and `out/quest/DorkNet-Quest-<ver>/` (`.xapk` + installers) | `DorkNet.Quest/scripts/build.py build` |
|
||||
|
||||
The build scripts keep their own names: the launcher downloads
|
||||
`dorknet-clientpatch-<VersionKey>.zip` from GitHub Releases by that exact
|
||||
name. `ci/downloads/publish.py` renames only the published copies.
|
||||
|
||||
The full PC client and the Quest packages contain game files. Keep the bucket
|
||||
internal and the site behind an access proxy; don't publish them anywhere
|
||||
public.
|
||||
The full PC client and the Quest packages contain game files, so keep them on
|
||||
Jenkins. Don't publish them anywhere public.
|
||||
|
||||
## Where the stock builds come from
|
||||
|
||||
@@ -50,8 +37,8 @@ The job defaults to the builds this branch targets: Steam manifest
|
||||
build can be chosen per run. Tick **LIST_SNAPSHOTS** to print the snapshots
|
||||
matching a filter without building anything.
|
||||
|
||||
A restored snapshot is cached on the agent's `dorknet_cache` volume, so each
|
||||
stock build is downloaded once.
|
||||
A restored snapshot is cached in `jenkins_home/dorknet-cache`, so each stock
|
||||
build is downloaded once.
|
||||
|
||||
A Quest build needs a resolved hook table for its versionCode, committed under
|
||||
`DorkNet.Quest/targets/`. Only `20230317-2128210.json` exists today. The build
|
||||
@@ -60,108 +47,42 @@ Resolve a table for another build locally with `build.py build --dump-cs`.
|
||||
|
||||
## Triggers
|
||||
|
||||
- **Push** to the branch (GitHub webhook) builds only for client-facing
|
||||
changes, and only the platform they touch:
|
||||
- `DorkNet.ClientMod/` and `tools/build_pc.py` rebuild PC;
|
||||
- **Push** to the branch (GitHub webhook) rebuilds only what changed:
|
||||
- `DorkNet.ClientMod/` and the PC scripts rebuild PC;
|
||||
- `DorkNet.Quest/` rebuilds Quest;
|
||||
- Markdown files (`.md`, `.markdown`) never count; server, docs and CI-only pushes (including
|
||||
the `Jenkinsfile` and `ci/`) build nothing. Run those manually.
|
||||
|
||||
Set the same filter on the job so other pushes don't start a run at all:
|
||||
under the job's **Git** source, add **Additional Behaviours → Polling
|
||||
ignores commits in certain paths** with included regions
|
||||
`DorkNet[.]ClientMod/.*`, `DorkNet[.]Quest/.*`, `tools/build_pc[.]py` (one
|
||||
per line) and excluded region `.*[.](md|markdown)`. The filter compares against the
|
||||
last build's checkout, so the pipeline keeps it and deletes only `out/`
|
||||
and its scratch folders after each run; without a checkout, Jenkins treats
|
||||
every push as a change.
|
||||
- `Jenkinsfile` and `ci/jenkins/` rebuild both.
|
||||
- **Build with Parameters** runs a manual build:
|
||||
- `PLATFORMS` (both / pc / quest), and `PC_FULL_CLIENT` for the full PC client;
|
||||
- snapshot tags, and per-run repository overrides;
|
||||
- `PUBLISH_DOWNLOADS` (on by default) to publish to the downloads bucket.
|
||||
|
||||
Push builds publish too, but only the platform they rebuilt: the manifest
|
||||
keeps the other platform's last files.
|
||||
- snapshot tags, and per-run repository overrides.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Jenkins itself
|
||||
### 1. Switch your Jenkins to this image
|
||||
|
||||
The controller is `ci/jenkins/docker-compose.yml`: stock Jenkins, no build
|
||||
tools. On Coolify, deploy it as a Docker Compose application from this
|
||||
repository (base directory `/ci/jenkins`), or paste it into a Compose service.
|
||||
On your Jenkins service in Dokploy:
|
||||
|
||||
- **Java 25.** The file defaults to `jenkins/jenkins:lts-jdk25`; set
|
||||
`JENKINS_IMAGE` to pin a version, e.g. `jenkins/jenkins:2.587-jdk25`.
|
||||
Jenkins supports Java 25 until September 2029, Java 21 only until September
|
||||
2027, and Java 17 not at all. Keep the agent on the same Java major version;
|
||||
its image defaults to Java 25.
|
||||
- **`restart: unless-stopped`** is in the file: a restart from the Jenkins UI
|
||||
exits the container with code 5 and relies on Docker to start it again.
|
||||
- **Turn on "Connect to predefined network"** (Advanced) so the agent can
|
||||
reach Jenkins by its container name, `jenkins-<uuid>`.
|
||||
- **Run no builds on it:** **Manage Jenkins → Nodes → Built-In Node →
|
||||
Configure**, set **Number of executors** to `0`.
|
||||
|
||||
Earlier versions of this file built the controller from `ci/jenkins/Dockerfile`,
|
||||
which is now the agent's image. The service and data volume keep their names,
|
||||
so an existing Jenkins deployed from this file keeps its jobs and credentials
|
||||
when redeployed; it switches to the stock image, and its builds wait until
|
||||
the agent (step 3) is connected.
|
||||
|
||||
That redeploy also upgrades Jenkins, and from Java 17 (the old default) to
|
||||
Java 25, unless `JENKINS_IMAGE` pins your current image. Before it:
|
||||
|
||||
1. **Back up the data volume** (`jenkins_home`), e.g. with a Coolify volume
|
||||
backup or `docker run --rm -v <volume>:/v -v "$PWD":/b alpine tar czf
|
||||
/b/jenkins_home.tgz -C /v .`, so you can go back to the old image.
|
||||
2. After it starts, open **Manage Jenkins**. Plugins that need a newer
|
||||
version show up there as failed to load; update them under **Plugins →
|
||||
Updates** and restart.
|
||||
3. Run the job once (**LIST_SNAPSHOTS** is a quick check) before relying on it.
|
||||
|
||||
### 2. The agent node
|
||||
|
||||
**Manage Jenkins → Nodes → New Node**, a permanent agent:
|
||||
|
||||
| Setting | Value |
|
||||
|---|---|
|
||||
| Name | `dorknet-builder` |
|
||||
| Labels | `dorknet-builder` |
|
||||
| Remote root directory | `/home/jenkins/agent` |
|
||||
| Launch method | Launch agent by connecting it to the controller, with **Use WebSocket** ticked |
|
||||
| Number of executors | 1 |
|
||||
|
||||
Save, open the node, and copy its **secret**.
|
||||
|
||||
### 3. Deploy the agent on Coolify
|
||||
|
||||
1. **Create a Docker Compose application** from this repository: build pack
|
||||
Docker Compose, base directory **`/ci/jenkins`**, compose location
|
||||
**`/docker-compose.agent.yml`**. The base directory is the build context, so with
|
||||
`/` the build would pick up the server's `Dockerfile` at the repo root. It
|
||||
needs no domain. Under Advanced, turn on **Connect to predefined network**.
|
||||
2. **Set its environment:**
|
||||
1. **Build from this repo.**
|
||||
- For an Application service: provider GitHub, branch `march-2023-03-21`,
|
||||
build type Dockerfile, Dockerfile path `ci/jenkins/Dockerfile`, build
|
||||
context `ci/jenkins`.
|
||||
- For a Compose service: change its `jenkins` service to `build:` from
|
||||
`ci/jenkins` as in `ci/jenkins/docker-compose.yml`.
|
||||
- Either way, **keep the same volume on `/var/jenkins_home`**.
|
||||
2. **Match the Jenkins version.** Set the build argument `JENKINS_IMAGE` to the
|
||||
image the service runs today (e.g. `jenkins/jenkins:2.492.3-lts-jdk17`).
|
||||
The default is `jenkins/jenkins:lts-jdk17`.
|
||||
3. **Add the environment:**
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `JENKINS_URL` | Jenkins' **internal** address, `http://jenkins-<service uuid>:8080/`. Not the public URL, which is behind Cloudflare Access. |
|
||||
| `JENKINS_SECRET` | the node secret from step 2 |
|
||||
| `JENKINS_AGENT_NAME` | optional, default `dorknet-builder` |
|
||||
| `ARCHIVE_PC_REPO` | Steam repository folder on the FTP, e.g. `Rec Room Versions/steam-v2` (plain text, not URL-encoded) |
|
||||
| `ARCHIVE_QUEST_REPO` | Quest repository folder, e.g. `Rec Room Versions/quest-v2` |
|
||||
| `ARCHIVE_FTP_TLS` | `explicit` (default), `implicit` or `none` |
|
||||
| `DOWNLOADS_S3_ENDPOINT` | the downloads bucket's S3 API, internal address, e.g. `http://garage-<service uuid>:3900` |
|
||||
| `DOWNLOADS_BUCKET` | its name, e.g. `dorknet-downloads` |
|
||||
|
||||
3. **Deploy.** The first image build downloads the Android NDK (~1.5 GB) and
|
||||
takes a while. The node shows as connected in Jenkins once the agent is up.
|
||||
4. **Redeploy.** The first image build downloads the Android NDK (~1.5 GB) and
|
||||
takes a while.
|
||||
|
||||
Both Jenkins and the agent join the server's predefined Coolify network, so
|
||||
the agent reaches Jenkins by container name. Check the name under the Jenkins
|
||||
service's containers before using it.
|
||||
|
||||
### 4. Plugins
|
||||
### 2. Plugins
|
||||
|
||||
The job needs these plugins:
|
||||
|
||||
@@ -170,12 +91,10 @@ The job needs these plugins:
|
||||
- GitHub (for the push trigger)
|
||||
- Credentials Binding
|
||||
- Timestamper
|
||||
- AnsiColor
|
||||
- Workspace Cleanup
|
||||
|
||||
Most are part of the suggested set.
|
||||
|
||||
### 5. Credentials
|
||||
### 3. Credentials
|
||||
|
||||
Add these under **Manage Jenkins → Credentials**, with exactly these IDs:
|
||||
|
||||
@@ -188,14 +107,13 @@ Add these under **Manage Jenkins → Credentials**, with exactly these IDs:
|
||||
| `quest-keystore-password` | Secret text | its store/key password |
|
||||
| `clientmod-config` | Secret file | your `dorknet-clientmod.json` |
|
||||
| `quest-config` | Secret file | your `dorknet-quest.cfg` |
|
||||
| `downloads-s3` | Username with password | S3 key for the downloads bucket: access key ID / secret. Give it read and write on that bucket only. |
|
||||
|
||||
**Use the keystore your players' installs were signed with**, the
|
||||
`dorknet-debug.keystore` from your earlier local builds (password `dorknet`). A
|
||||
different key means every player has to uninstall before installing a CI
|
||||
build.
|
||||
|
||||
### 6. The job
|
||||
### 4. The job
|
||||
|
||||
1. Create a **New Item → Pipeline** (e.g. `dorknet-client`).
|
||||
2. Under **Pipeline**, choose **Pipeline script from SCM**:
|
||||
@@ -206,7 +124,7 @@ build.
|
||||
4. Run it once with **Build** so Jenkins learns the parameters. Later runs
|
||||
offer **Build with Parameters**.
|
||||
|
||||
### 7. GitHub webhook
|
||||
### 5. GitHub webhook
|
||||
|
||||
In the repository's **Settings → Webhooks**:
|
||||
|
||||
@@ -214,36 +132,7 @@ In the repository's **Settings → Webhooks**:
|
||||
- Content type: `application/json`
|
||||
- Events: just the push event
|
||||
|
||||
### 8. The downloads bucket
|
||||
|
||||
`publish.py upload` puts the files and a `manifest.json` describing them in
|
||||
an S3 bucket. The DorkNet server reads that bucket over HTTP on the internal
|
||||
network and offers the files on its site (`docs/deploy.md` → Client
|
||||
downloads), so the bucket needs no public route. With Garage (the S3 store on
|
||||
the Coolify server):
|
||||
|
||||
1. **Bucket and key.** Create the bucket, turn on Garage's website access for
|
||||
it (the server reads it through the web endpoint, port `3902`, which picks
|
||||
the bucket by the request's Host header), and give Jenkins a key for that
|
||||
bucket only:
|
||||
|
||||
```sh
|
||||
garage bucket create dorknet-downloads
|
||||
garage bucket website --allow dorknet-downloads
|
||||
garage key create jenkins-downloads
|
||||
garage bucket allow --read --write --key <key id> dorknet-downloads
|
||||
```
|
||||
|
||||
Store the key as the `downloads-s3` credential.
|
||||
2. **Server.** Point the DorkNet server at the web endpoint:
|
||||
`DorkNet__Downloads__BaseUrl=http://garage-<service uuid>:3902` and
|
||||
`DorkNet__Downloads__Host=dorknet-downloads`.
|
||||
|
||||
Each release keeps one copy of each file; a new build overwrites it. The
|
||||
manifest is uploaded last, so the site only ever lists files that are fully
|
||||
uploaded.
|
||||
|
||||
### Disk
|
||||
|
||||
The agent's `dorknet_cache` volume holds the restored stock builds, several GB
|
||||
each. Jenkins keeps the artifacts of the last 3 runs, on its own volume.
|
||||
The restored stock builds are cached in `jenkins_home/dorknet-cache`, several
|
||||
GB each. Jenkins keeps the artifacts of the last 3 runs.
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# DorkNet build agent on Coolify. Deploy as its own Docker Compose application
|
||||
# from this repository with base directory /ci/jenkins, compose location
|
||||
# /docker-compose.agent.yml and "Connect to predefined network" on. It needs no
|
||||
# domain: it dials out to Jenkins over WebSocket on Docker's internal network.
|
||||
# Set the variables in the resource's Environment Variables; see
|
||||
# ci/jenkins/README.md. The controller is docker-compose.yml.
|
||||
services:
|
||||
agent:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
# Reap the processes build tools leave behind (dotnet build servers,
|
||||
# Gradle daemons, adb), as the inbound-agent image's docs ask for.
|
||||
init: true
|
||||
environment:
|
||||
# Jenkins as seen from inside Docker (e.g. http://<jenkins container
|
||||
# name>:8080), not the public URL, which is behind Cloudflare Access.
|
||||
# Both are needed at runtime only; keep them out of the image build so
|
||||
# the node secret never reaches a build argument.
|
||||
JENKINS_URL: ${JENKINS_URL}
|
||||
JENKINS_AGENT_NAME: ${JENKINS_AGENT_NAME:-dorknet-builder}
|
||||
JENKINS_SECRET: ${JENKINS_SECRET}
|
||||
JENKINS_WEB_SOCKET: "true"
|
||||
JENKINS_AGENT_WORKDIR: /home/jenkins/agent
|
||||
# Where the stock builds live in the archive (restic repository folders
|
||||
# on the FTP, plain text). Overridable per run.
|
||||
ARCHIVE_PC_REPO: ${ARCHIVE_PC_REPO:-}
|
||||
ARCHIVE_QUEST_REPO: ${ARCHIVE_QUEST_REPO:-}
|
||||
ARCHIVE_FTP_TLS: ${ARCHIVE_FTP_TLS:-explicit}
|
||||
# The downloads bucket (S3 API, internal address) the builds are
|
||||
# published to. See ci/downloads/publish.py.
|
||||
DOWNLOADS_S3_ENDPOINT: ${DOWNLOADS_S3_ENDPOINT:-}
|
||||
DOWNLOADS_BUCKET: ${DOWNLOADS_BUCKET:-}
|
||||
volumes:
|
||||
- dorknet_cache:/home/jenkins/dorknet-cache
|
||||
- agent_work:/home/jenkins/agent
|
||||
|
||||
volumes:
|
||||
dorknet_cache:
|
||||
agent_work:
|
||||
@@ -1,23 +1,29 @@
|
||||
# The Jenkins controller: stock Jenkins on Java 25. It runs no builds; the
|
||||
# client builds run on the agent (docker-compose.agent.yml). On Coolify,
|
||||
# deploy it as a Docker Compose application from this repository (base
|
||||
# directory /ci/jenkins) or paste it into a Compose service, with "Connect to
|
||||
# predefined network" on so the agent can reach it. See ci/jenkins/README.md.
|
||||
# Reference for the Jenkins service on Dokploy, built from this directory's
|
||||
# Dockerfile (the stock Jenkins image plus the client build toolchain).
|
||||
#
|
||||
# Earlier versions of this file built the controller from ci/jenkins/Dockerfile,
|
||||
# which is now the agent's image. The service name and data volume are the
|
||||
# same, so redeploying an existing Jenkins from this file keeps its jobs and
|
||||
# credentials; its builds then wait for the agent.
|
||||
# Use it on your EXISTING Jenkins service rather than as a second Jenkins.
|
||||
# The data volume must be the one Jenkins uses today, or Jenkins starts empty.
|
||||
# Point `volumes:` at that volume (and its name under the top-level
|
||||
# `volumes:`, marked `external: true`), or keep this service's name and volume
|
||||
# if they already match. See ci/jenkins/README.md.
|
||||
services:
|
||||
jenkins:
|
||||
# Pin a version with e.g. jenkins/jenkins:2.587-jdk25. Keep the agent on
|
||||
# the same Java major version (its image defaults to Java 25).
|
||||
image: ${JENKINS_IMAGE:-jenkins/jenkins:lts-jdk25}
|
||||
# A restart from the Jenkins UI exits with code 5 and relies on Docker to
|
||||
# start it again.
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# The image your Jenkins runs today, so the Jenkins version and Java
|
||||
# stay the same.
|
||||
JENKINS_IMAGE: ${JENKINS_IMAGE:-jenkins/jenkins:lts-jdk17}
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8080"
|
||||
environment:
|
||||
# Where the stock builds live in the archive (restic repository folders
|
||||
# on the FTP), as plain text. Overridable per run.
|
||||
ARCHIVE_PC_REPO: ${ARCHIVE_PC_REPO:-}
|
||||
ARCHIVE_QUEST_REPO: ${ARCHIVE_QUEST_REPO:-}
|
||||
ARCHIVE_FTP_TLS: ${ARCHIVE_FTP_TLS:-explicit}
|
||||
volumes:
|
||||
- jenkins_home:/var/jenkins_home
|
||||
|
||||
|
||||
@@ -412,39 +412,6 @@ 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
|
||||
|
||||
+1
-19
@@ -55,17 +55,6 @@ 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.
|
||||
|
||||
@@ -85,13 +74,6 @@ 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 and the optional downloads proxy.
|
||||
handling above.
|
||||
|
||||
Reference in New Issue
Block a user