mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a5c3a9a64 | |||
| d0d26c9821 | |||
| bb22675a91 | |||
| c1000c82aa | |||
| 3b332a6476 | |||
| d295fd5c37 | |||
| 8e12db660b | |||
| e7c0b076f2 | |||
| 0a3bad7c6c | |||
| 1718912a07 | |||
| 5acad4c9e2 | |||
| ec88a39750 | |||
| 6e5b011059 | |||
| 2e09042344 | |||
| 9c5b574d6e | |||
| 29aca0e88c | |||
| eb73f3c484 | |||
| cc7f77b7de | |||
| 19402459a9 | |||
| 3266ce47f3 | |||
| cf3e6ea025 | |||
| 0839d932ce | |||
| 9523525cb8 | |||
| 3880745efd | |||
| 31d5a022c8 | |||
| 83b896ffaf | |||
| fcc734726a | |||
| b1c1239165 | |||
| afad9bbc5a | |||
| e33145307f | |||
| 38335c06a9 | |||
| 5ab28b1d4f | |||
| d60b0642a8 |
@@ -25,7 +25,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-dotnet@v4
|
- uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 9.0.x
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
- name: Install protobuf compiler
|
- name: Install protobuf compiler
|
||||||
run: |
|
run: |
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Start server
|
- name: Start server
|
||||||
run: |
|
run: |
|
||||||
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
|
dotnet DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
|
||||||
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
|
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
|
||||||
|
|
||||||
- name: Wait for server readiness
|
- name: Wait for server readiness
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
- name: Run endpoint contract
|
- name: Run endpoint contract
|
||||||
run: |
|
run: |
|
||||||
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
|
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
|
||||||
--server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \
|
--server-assembly DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll \
|
||||||
--base-url http://127.0.0.1:18080 \
|
--base-url http://127.0.0.1:18080 \
|
||||||
--report "$RUNNER_TEMP/endpoint-contract-report.json"
|
--report "$RUNNER_TEMP/endpoint-contract-report.json"
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-dotnet@v4
|
- uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
# ── Server self-contained publish ────────────────────────────────
|
# ── Server self-contained publish ────────────────────────────────
|
||||||
- name: Publish DorkNet.Server (win-x64, self-contained)
|
- name: Publish DorkNet.Server (win-x64, self-contained)
|
||||||
@@ -43,10 +43,10 @@ jobs:
|
|||||||
Compress-Archive -Path publish/server/* `
|
Compress-Archive -Path publish/server/* `
|
||||||
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
||||||
|
|
||||||
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ───
|
# ── Client patcher zip (built via tools/build_pc.py) ─────────────
|
||||||
- name: Build client patcher zip
|
- name: Build client patcher zip
|
||||||
run: |
|
run: |
|
||||||
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }}
|
python tools/build_pc.py patcher --version-key ${{ env.VERSION_KEY }}
|
||||||
|
|
||||||
# ── Publish both as Release assets ───────────────────────────────
|
# ── Publish both as Release assets ───────────────────────────────
|
||||||
- name: Upload Release assets
|
- name: Upload Release assets
|
||||||
|
|||||||
+2
-2
@@ -12,8 +12,8 @@ dist/
|
|||||||
# Local SQLite + uploaded blobs only — DON'T match the source-tree
|
# Local SQLite + uploaded blobs only — DON'T match the source-tree
|
||||||
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
|
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
|
||||||
# case-insensitive filesystems. Use the precise runtime data dirs.
|
# case-insensitive filesystems. Use the precise runtime data dirs.
|
||||||
DorkNet.Server/bin/Debug/net9.0/data/
|
DorkNet.Server/bin/Debug/net10.0/data/
|
||||||
DorkNet.Server/bin/Release/net9.0/data/
|
DorkNet.Server/bin/Release/net10.0/data/
|
||||||
# appsettings.Production.json is intentionally versioned — contains
|
# appsettings.Production.json is intentionally versioned — contains
|
||||||
# only the Postgres provider switch + Kestrel bind address. All real
|
# only the Postgres provider switch + Kestrel bind address. All real
|
||||||
# secrets come from Coolify env vars at runtime.
|
# secrets come from Coolify env vars at runtime.
|
||||||
|
|||||||
+4
-4
@@ -1,14 +1,14 @@
|
|||||||
# DorkNet.Server container image — Coolify deploy target.
|
# DorkNet.Server container image — Coolify deploy target.
|
||||||
# ────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
# Multi-stage: SDK image builds + publishes, runtime image is the
|
# Multi-stage: SDK image builds + publishes, runtime image is the
|
||||||
# slimmer aspnet:9.0 with only the published output.
|
# slimmer aspnet:10.0 with only the published output.
|
||||||
#
|
#
|
||||||
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
|
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
|
||||||
# to ProjectReference + dotnet restore). The .dockerignore at the
|
# to ProjectReference + dotnet restore). The .dockerignore at the
|
||||||
# repo root keeps the bin/, obj/, and other dev junk out of the
|
# repo root keeps the bin/, obj/, and other dev junk out of the
|
||||||
# context.
|
# context.
|
||||||
|
|
||||||
ARG DOTNET_VERSION=9.0
|
ARG DOTNET_VERSION=10.0
|
||||||
ARG NODE_VERSION=22
|
ARG NODE_VERSION=22
|
||||||
|
|
||||||
# ── admin SPA build stage ──────────────────────────────────────────
|
# ── admin SPA build stage ──────────────────────────────────────────
|
||||||
@@ -76,13 +76,13 @@ WORKDIR /app
|
|||||||
|
|
||||||
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
|
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
|
||||||
# its own probe regardless of what the image's HEALTHCHECK uses, so
|
# its own probe regardless of what the image's HEALTHCHECK uses, so
|
||||||
# both have to be present. The aspnet:9.0 base image strips both by
|
# both have to be present. The aspnet:10.0 base image strips both by
|
||||||
# default to keep the surface area small.
|
# default to keep the surface area small.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends curl wget \
|
&& apt-get install -y --no-install-recommends curl wget \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654.
|
# Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
|
||||||
# Coolify mounts persistent volumes with write permission for this UID.
|
# Coolify mounts persistent volumes with write permission for this UID.
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
ARG DOTNET_VERSION=9.0
|
ARG DOTNET_VERSION=10.0
|
||||||
ARG NODE_VERSION=22
|
ARG NODE_VERSION=22
|
||||||
|
|
||||||
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ public class Mod : MelonMod
|
|||||||
// "N"). Empty = no hotkey. A hotkey also arms the feature when
|
// "N"). Empty = no hotkey. A hotkey also arms the feature when
|
||||||
// RecRoyaleNightMode is false, so it can be switched on in-game.
|
// RecRoyaleNightMode is false, so it can be switched on in-game.
|
||||||
public static string RecRoyaleNightToggleKey = "";
|
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()
|
public override void OnInitializeMelon()
|
||||||
{
|
{
|
||||||
@@ -225,6 +233,14 @@ public class Mod : MelonMod
|
|||||||
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
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
|
// 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
|
// (so it can be switched on in-game). Scene detection + mood apply
|
||||||
// run from OnSceneWasLoaded / OnUpdate below.
|
// run from OnSceneWasLoaded / OnUpdate below.
|
||||||
@@ -457,6 +473,7 @@ public class Mod : MelonMod
|
|||||||
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
||||||
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
||||||
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = 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, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
||||||
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
@@ -662,7 +679,7 @@ public class Mod : MelonMod
|
|||||||
private MethodInfo GetPatchMethod(string name)
|
private MethodInfo GetPatchMethod(string name)
|
||||||
{
|
{
|
||||||
// Look in the core + feature patch holder classes.
|
// Look in the core + feature patch holder classes.
|
||||||
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight) })
|
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight), typeof(TitleScreenLoginPatches) })
|
||||||
{
|
{
|
||||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||||
if (m is not null) return m;
|
if (m is not null) return m;
|
||||||
@@ -903,6 +920,52 @@ internal static class FileHashCheckerPatches
|
|||||||
return false;
|
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
|
internal static class ToxModPatches
|
||||||
{
|
{
|
||||||
public static bool CanInitializeToxMod_Prefix(ref bool __result)
|
public static bool CanInitializeToxMod_Prefix(ref bool __result)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"ServerHost": "{HOST}",
|
"ServerHost": "{HOST}",
|
||||||
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
||||||
|
"AutoCreateAccount": false,
|
||||||
"EnableDebugConsole": false,
|
"EnableDebugConsole": false,
|
||||||
"DebugConsoleToggleKey": "BackQuote",
|
"DebugConsoleToggleKey": "BackQuote",
|
||||||
"DevCommands": [],
|
"DevCommands": [],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
+28
-17
@@ -30,7 +30,8 @@ Photon shards with PC DorkNet players with no server-side version change.
|
|||||||
| image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning |
|
| 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 |
|
| 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 | — |
|
| integrity report + quit funnels | **log only** — names any new rejection in logcat | — |
|
||||||
| Oculus `ovr_*` platform calls | (no-Meta build only) steer the entitlement/login path to the client's own credential screen | — |
|
| Oculus `ovr_*` platform calls | (no-Meta build only) pass the entitlement / user-proof / logged-in-user requests Meta refuses for the renamed app (including the SDK's second check on the inner native message), answering the logged-in user with the placeholder user id `1` | — |
|
||||||
|
| title-screen auto-login (`TitleScreenFlowModel`) | a headset with no remembered account opens the **sign-in page** instead of auto-creating an account (`AutoCreateAccount = true` restores Rec Room's behaviour) | — |
|
||||||
|
|
||||||
### Two builds, selected by `OculusAppId`
|
### Two builds, selected by `OculusAppId`
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ Photon shards with PC DorkNet players with no server-side version change.
|
|||||||
and its matching **OBB**.
|
and its matching **OBB**.
|
||||||
|
|
||||||
> On Windows with Controlled Folder Access on, the NDK linker and apksigner
|
> On Windows with Controlled Folder Access on, the NDK linker and apksigner
|
||||||
> can't write under `Documents`. `build.ps1` builds in `%TEMP%` by default;
|
> can't write under `Documents`. `build.py` builds in `%TEMP%` by default;
|
||||||
> keep the work dir outside protected folders.
|
> keep the work dir outside protected folders.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
@@ -76,20 +77,28 @@ dotnet Il2CppDumper.dll `
|
|||||||
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
|
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
|
||||||
# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName
|
# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName
|
||||||
|
|
||||||
# 3. Build the signed APK:
|
# 3. Build the signed APK (and, with --obb, the release package):
|
||||||
$env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk'
|
$env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk'
|
||||||
$env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018'
|
$env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018'
|
||||||
$env:APKTOOL_JAR = 'C:\path\to\apktool.jar'
|
$env:APKTOOL_JAR = 'C:\path\to\apktool.jar'
|
||||||
DorkNet.Quest\scripts\build.ps1 `
|
py -3 DorkNet.Quest\scripts\build.py build `
|
||||||
-Apk C:\path\to\RecRoom-store-2128210.apk `
|
--apk C:\path\to\RecRoom-store-2128210.apk `
|
||||||
-DumpCs <scratch>\dump\dump.cs `
|
--dump-cs <scratch>\dump\dump.cs `
|
||||||
-Obb C:\path o\main.2128210.com.AgainstGravity.RecRoom.obb
|
--obb C:\path\to\main.2128210.com.AgainstGravity.RecRoom.obb `
|
||||||
|
--keystore C:\path\to\dorknet-debug.keystore
|
||||||
# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk
|
# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk
|
||||||
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with -Obb)
|
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with --obb)
|
||||||
```
|
```
|
||||||
|
|
||||||
With `-Obb`, the build also runs `scripts/package.ps1`, which writes a release
|
The same command works on Linux and macOS (`python3 DorkNet.Quest/scripts/build.py build …`).
|
||||||
folder ready to hand to players:
|
|
||||||
|
For a build whose target table is already committed under `targets/`, pass
|
||||||
|
`--targets-json DorkNet.Quest/targets/20230317-2128210.json` instead of
|
||||||
|
`--dump-cs`. No Il2CppDumper is needed then: the build checks the table
|
||||||
|
against the APK's `libil2cpp.so` and stops on a mismatch. This is what the
|
||||||
|
Jenkins job does (`ci/jenkins/README.md`).
|
||||||
|
|
||||||
|
With `--obb`, the build also writes a release folder ready to hand to players:
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -98,15 +107,16 @@ folder ready to hand to players:
|
|||||||
| `install.sh` | one-command install on macOS / Linux |
|
| `install.sh` | one-command install on macOS / Linux |
|
||||||
| `README.txt` | player instructions |
|
| `README.txt` | player instructions |
|
||||||
|
|
||||||
To package an APK you already built, run `scripts/package.ps1 -Apk <apk> -Obb <obb>`.
|
To package an APK you already built, run `build.py package --apk <apk> --obb <obb>`.
|
||||||
The package contains game binaries from your store copy, so only share it with
|
The package contains game binaries from your store copy, so only share it with
|
||||||
people who own the game.
|
people who own the game.
|
||||||
|
|
||||||
`build.ps1` chains: resolve targets → generate headers → compile
|
`build.py build` chains: resolve targets → generate headers → compile
|
||||||
`libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest →
|
`libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest →
|
||||||
app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build →
|
app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build →
|
||||||
zipalign → apksigner (v2+v3). It generates a fresh debug keystore in the work
|
zipalign → apksigner (v2+v3). Without `--keystore` it generates a fresh debug
|
||||||
dir on first run. **Keep that keystore** — Meta ties tokens to app id + package
|
keystore in the work dir on first run. **Keep that keystore** and pass it with
|
||||||
|
`--keystore` from then on — Meta ties tokens to app id + package
|
||||||
+ signing cert, so re-signing with a different key breaks any Meta registration.
|
+ signing cert, so re-signing with a different key breaks any Meta registration.
|
||||||
It is never committed.
|
It is never committed.
|
||||||
|
|
||||||
@@ -189,12 +199,14 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
|
|||||||
| Symptom | Likely cause |
|
| Symptom | Likely cause |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase |
|
| `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase |
|
||||||
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.ps1`, don't hand-zip |
|
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.py`, don't hand-zip |
|
||||||
| boots to an empty world | OBB missing or named for the wrong versionCode |
|
| boots to an empty world | OBB missing or named for the wrong versionCode |
|
||||||
| `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm |
|
| `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm |
|
||||||
| `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin |
|
| `[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 |
|
| 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` |
|
| 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
|
## Layout
|
||||||
|
|
||||||
@@ -209,8 +221,7 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
|
|||||||
| `scripts/gen_targets_header.py` | target table → `targets_generated.h` |
|
| `scripts/gen_targets_header.py` | target table → `targets_generated.h` |
|
||||||
| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
|
| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
|
||||||
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
|
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
|
||||||
| `scripts/build.ps1` | one-shot: store APK → signed patched APK (+ release package with `-Obb`) |
|
| `scripts/build.py` | `build`: store APK → signed patched APK (+ release package with `--obb`); `package`: patched APK + OBB → `.xapk` + installers |
|
||||||
| `scripts/package.ps1` | patched APK + OBB → `.xapk` + installers |
|
|
||||||
| `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package |
|
| `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package |
|
||||||
| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) |
|
| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) |
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ EnableTlsTrustBypass = true
|
|||||||
# Accept image Content-Signatures the server signs with its own key.
|
# Accept image Content-Signatures the server signs with its own key.
|
||||||
AcceptUnsignedImages = true
|
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) ----
|
# ---- diagnostics (adb logcat -s DORKNET) ----
|
||||||
|
|
||||||
LogHttp = true
|
LogHttp = true
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Build a DorkNet-patched Rec Room Quest APK from a pristine store APK.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
End to end:
|
|
||||||
1. resolve_targets.py -> per-build target table (RVA + fingerprint)
|
|
||||||
2. gen_targets_header.py -> targets_generated.h
|
|
||||||
3. gen_config_header.py -> config_generated.h (from your cfg)
|
|
||||||
4. NDK clang++ -> libdorknet.so (arm64)
|
|
||||||
5. apktool decode -> working project
|
|
||||||
6. package rename + manifest/version edits
|
|
||||||
7. drop libdorknet.so in, DT_NEEDED it into libmain.so (LIEF)
|
|
||||||
8. apktool build -> zipalign -> apksigner -> the signed output APK
|
|
||||||
|
|
||||||
Nothing game-derived is written into the repo. The work directory defaults to
|
|
||||||
a temp path because Windows Controlled Folder Access blocks the NDK linker and
|
|
||||||
apksigner from writing under Documents; override with -WorkDir if needed.
|
|
||||||
|
|
||||||
You supply the store APK and (for install) the matching OBB. No game binaries
|
|
||||||
ship with this tool.
|
|
||||||
|
|
||||||
.PARAMETER Apk
|
|
||||||
Path to the pristine store APK (com.AgainstGravity.RecRoom, versionCode
|
|
||||||
2128210 / version 20230317).
|
|
||||||
|
|
||||||
.PARAMETER Config
|
|
||||||
dorknet-quest.cfg with your ServerHost / Photon ids / package name. Defaults
|
|
||||||
to dorknet-quest.cfg next to the repo folder; falls back to the .example.
|
|
||||||
|
|
||||||
.PARAMETER WorkDir
|
|
||||||
Scratch build directory (must be outside Controlled-Folder-Access-protected
|
|
||||||
locations). Defaults to $env:TEMP\dorknet-quest-build.
|
|
||||||
|
|
||||||
.PARAMETER OutApk
|
|
||||||
Where to write the finished APK. Default: <WorkDir>\dorknet-recroom-<ver>.apk.
|
|
||||||
|
|
||||||
.PARAMETER NdkRoot
|
|
||||||
Android NDK root. Default: $env:ANDROID_NDK, else the NDK under $env:ANDROID_SDK_ROOT\ndk.
|
|
||||||
|
|
||||||
.PARAMETER BuildToolsDir
|
|
||||||
Android SDK build-tools dir holding zipalign + apksigner. Default: resolved
|
|
||||||
from $env:ANDROID_SDK_ROOT.
|
|
||||||
|
|
||||||
.PARAMETER Obb
|
|
||||||
The store OBB matching the APK. When given, package.ps1 also bundles the
|
|
||||||
result into an installable .xapk with one-command installers.
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)][string]$Apk,
|
|
||||||
[string]$Config,
|
|
||||||
[string]$WorkDir = (Join-Path $env:TEMP 'dorknet-quest-build'),
|
|
||||||
[string]$OutApk,
|
|
||||||
[string]$NdkRoot,
|
|
||||||
[string]$BuildToolsDir,
|
|
||||||
[string]$ApktoolJar,
|
|
||||||
[string]$DumpCs,
|
|
||||||
[string]$ApiLevel = '24',
|
|
||||||
[string]$Obb
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
|
|
||||||
$RepoDir = Split-Path -Parent $PSScriptRoot
|
|
||||||
$Scripts = $PSScriptRoot
|
|
||||||
$OldPkg = 'com.AgainstGravity.RecRoom'
|
|
||||||
$SoName = 'libdorknet.so'
|
|
||||||
|
|
||||||
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
|
||||||
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
|
|
||||||
|
|
||||||
# ---- resolve tools ------------------------------------------------------
|
|
||||||
if (-not $NdkRoot) {
|
|
||||||
$NdkRoot = $env:ANDROID_NDK
|
|
||||||
if (-not $NdkRoot -and $env:ANDROID_SDK_ROOT) {
|
|
||||||
$cand = Join-Path $env:ANDROID_SDK_ROOT 'ndk'
|
|
||||||
if (Test-Path $cand) { $NdkRoot = (Get-ChildItem $cand -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (-not $NdkRoot) { throw "Android NDK not found. Pass -NdkRoot or set ANDROID_NDK / ANDROID_SDK_ROOT." }
|
|
||||||
$Cxx = Join-Path $NdkRoot "toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android$ApiLevel-clang++.cmd"
|
|
||||||
$ObjDump = Join-Path $NdkRoot 'toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-objdump.exe'
|
|
||||||
Need $Cxx 'NDK aarch64 clang++'
|
|
||||||
|
|
||||||
if (-not $BuildToolsDir) {
|
|
||||||
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
|
|
||||||
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
|
|
||||||
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
|
|
||||||
}
|
|
||||||
$ZipAlign = Join-Path $BuildToolsDir 'zipalign.exe'
|
|
||||||
$ApkSigner = Join-Path $BuildToolsDir 'apksigner.bat'
|
|
||||||
Need $ZipAlign 'zipalign'
|
|
||||||
Need $ApkSigner 'apksigner'
|
|
||||||
|
|
||||||
if (-not $ApktoolJar) {
|
|
||||||
$ApktoolJar = if ($env:APKTOOL_JAR) { $env:APKTOOL_JAR } elseif ($env:ANDROID_SDK_ROOT) { Join-Path $env:ANDROID_SDK_ROOT 'apktool.jar' } else { '' }
|
|
||||||
}
|
|
||||||
Need $ApktoolJar 'apktool.jar'
|
|
||||||
Need $Apk 'store APK'
|
|
||||||
|
|
||||||
if (-not $Config) {
|
|
||||||
$c = Join-Path $RepoDir 'dorknet-quest.cfg'
|
|
||||||
$Config = if (Test-Path $c) { $c } else { Join-Path $RepoDir 'dorknet-quest.cfg.example' }
|
|
||||||
}
|
|
||||||
Need $Config 'config'
|
|
||||||
Write-Host "Using config: $Config"
|
|
||||||
|
|
||||||
$PkgName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*PackageName\s*=' } | Select-Object -First 1)
|
|
||||||
$PkgName = if ($PkgName) { ($PkgName -split '=', 2)[1].Trim() } else { 'net.dorknet.RecRoom' }
|
|
||||||
$AppName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*AppName\s*=' } | Select-Object -First 1)
|
|
||||||
$AppName = if ($AppName) { ($AppName -split '=', 2)[1].Split('#')[0].Trim() } else { '' }
|
|
||||||
if (-not $AppName) { $AppName = 'DorkNet' }
|
|
||||||
|
|
||||||
# Resolve the launcher to its FULL exe path -- "& py ..." would otherwise match
|
|
||||||
# the (case-insensitive) function name and recurse. Prefer py -3.14, else python.
|
|
||||||
$PyCmd = (Get-Command py.exe -ErrorAction SilentlyContinue)
|
|
||||||
if ($PyCmd) { $PyExe = $PyCmd.Source; $PyPre = @('-3.14', '-I') }
|
|
||||||
else { $PyExe = (Get-Command python.exe -ErrorAction Stop).Source; $PyPre = @('-I') }
|
|
||||||
function Invoke-Py { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$a)
|
|
||||||
$pa = $PyPre + $a
|
|
||||||
& $PyExe @pa
|
|
||||||
if ($LASTEXITCODE) { throw "python failed: $($a -join ' ')" } }
|
|
||||||
|
|
||||||
# ---- work dirs ----------------------------------------------------------
|
|
||||||
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
|
|
||||||
$Gen = Join-Path $WorkDir 'gen'
|
|
||||||
$Project = Join-Path $WorkDir 'project'
|
|
||||||
$Extract = Join-Path $WorkDir 'extract'
|
|
||||||
New-Item -ItemType Directory -Force -Path $Gen, $Extract | Out-Null
|
|
||||||
|
|
||||||
# ---- 1. extract + dump + resolve targets --------------------------------
|
|
||||||
Step 'extracting libil2cpp.so + global-metadata.dat'
|
|
||||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
||||||
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $Apk))
|
|
||||||
try {
|
|
||||||
foreach ($e in @('lib/arm64-v8a/libil2cpp.so', 'assets/bin/Data/Managed/Metadata/global-metadata.dat', 'lib/arm64-v8a/libmain.so')) {
|
|
||||||
$entry = $zip.GetEntry($e); if (-not $entry) { throw "APK missing $e" }
|
|
||||||
$dest = Join-Path $Extract (Split-Path $e -Leaf)
|
|
||||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true)
|
|
||||||
}
|
|
||||||
} finally { $zip.Dispose() }
|
|
||||||
$Il2Cpp = Join-Path $Extract 'libil2cpp.so'
|
|
||||||
$LibMain = Join-Path $Extract 'libmain.so'
|
|
||||||
|
|
||||||
if (-not $DumpCs) {
|
|
||||||
# Prefer a dump.cs the operator points at; otherwise require one. Dumping
|
|
||||||
# is left to Il2CppDumper (see README) to keep this script toolchain-light.
|
|
||||||
throw "Pass -DumpCs <dump.cs> from Il2CppDumper for this APK (see DorkNet.Quest/README.md)."
|
|
||||||
}
|
|
||||||
Need $DumpCs 'dump.cs'
|
|
||||||
|
|
||||||
Step 'resolving hook targets'
|
|
||||||
$verName = '20230317'; $verCode = '2128210'
|
|
||||||
# Write the resolved table into the work dir (Controlled Folder Access can
|
|
||||||
# block writes under Documents). The repo keeps a committed copy under targets/.
|
|
||||||
$table = Join-Path $WorkDir "$verName-$verCode.json"
|
|
||||||
Invoke-Py @((Join-Path $Scripts 'resolve_targets.py'), '--dump', $DumpCs, '--il2cpp', $Il2Cpp,
|
|
||||||
'--version-name', $verName, '--version-code', $verCode, '--objdump', $ObjDump, '--out', $table)
|
|
||||||
|
|
||||||
Step 'generating headers'
|
|
||||||
Invoke-Py @((Join-Path $Scripts 'gen_targets_header.py'), $table, (Join-Path $Gen 'targets_generated.h'))
|
|
||||||
Invoke-Py @((Join-Path $Scripts 'gen_config_header.py'), $Config, $PkgName, (Join-Path $Gen 'config_generated.h'))
|
|
||||||
|
|
||||||
# ---- 4. compile libdorknet.so -------------------------------------------
|
|
||||||
Step 'compiling libdorknet.so (arm64)'
|
|
||||||
$src = @(
|
|
||||||
(Join-Path $RepoDir 'src\dorknet_quest.cpp'),
|
|
||||||
(Join-Path $RepoDir 'src\config.cpp'),
|
|
||||||
(Join-Path $RepoDir 'src\third_party\And64InlineHook\And64InlineHook.cpp')
|
|
||||||
)
|
|
||||||
$So = Join-Path $WorkDir $SoName
|
|
||||||
& $Cxx '-std=c++17' '-fPIC' '-shared' '-O2' '-fvisibility=hidden' '-static-libstdc++' `
|
|
||||||
'-ffunction-sections' '-fdata-sections' '-Wl,--gc-sections' `
|
|
||||||
"-I$Gen" "-I$(Join-Path $RepoDir 'src')" $src '-llog' '-o' $So
|
|
||||||
if ($LASTEXITCODE) { throw 'compile failed' }
|
|
||||||
Need $So 'libdorknet.so'
|
|
||||||
|
|
||||||
# ---- 5. apktool decode --------------------------------------------------
|
|
||||||
Step 'apktool decode'
|
|
||||||
if (Test-Path $Project) { Remove-Item -Recurse -Force $Project }
|
|
||||||
& java -jar $ApktoolJar decode -f -o $Project (Resolve-Path $Apk)
|
|
||||||
if ($LASTEXITCODE) { throw 'apktool decode failed' }
|
|
||||||
|
|
||||||
# ---- 6. package rename + manifest -------------------------------------
|
|
||||||
Step "renaming package $OldPkg -> $PkgName"
|
|
||||||
$manifest = Join-Path $Project 'AndroidManifest.xml'
|
|
||||||
$m = Get-Content -Raw $manifest
|
|
||||||
$m = $m.Replace($OldPkg, $PkgName)
|
|
||||||
if ($m -notmatch 'android.intent.category.LAUNCHER') {
|
|
||||||
$m = $m.Replace('<category android:name="android.intent.category.INFO"/>',
|
|
||||||
'<category android:name="android.intent.category.INFO"/><category android:name="android.intent.category.LAUNCHER"/>')
|
|
||||||
}
|
|
||||||
Set-Content -NoNewline -Path $manifest -Value $m
|
|
||||||
|
|
||||||
# The launcher label comes from @string/app_name; set it in every locale.
|
|
||||||
Step "naming the app '$AppName'"
|
|
||||||
$labelXml = [System.Security.SecurityElement]::Escape($AppName).Replace("'", "\'")
|
|
||||||
foreach ($sx in Get-ChildItem (Join-Path $Project 'res') -Filter strings.xml -Recurse) {
|
|
||||||
$s = Get-Content -Raw -Encoding utf8 $sx.FullName
|
|
||||||
$n = [regex]::Replace($s, '<string name="app_name">[^<]*</string>', "<string name=`"app_name`">$labelXml</string>")
|
|
||||||
if ($n -ne $s) { Set-Content -NoNewline -Encoding utf8 -Path $sx.FullName -Value $n }
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- 7. inject the hook library ----------------------------------------
|
|
||||||
Step 'injecting libdorknet.so'
|
|
||||||
Copy-Item $So (Join-Path $Project 'lib\arm64-v8a' $SoName) -Force
|
|
||||||
$projMain = Join-Path $Project 'lib\arm64-v8a\libmain.so'
|
|
||||||
Invoke-Py @((Join-Path $Scripts 'inject_needed.py'), $projMain, $SoName)
|
|
||||||
|
|
||||||
# ---- 8. rebuild + align + sign -----------------------------------------
|
|
||||||
Step 'apktool build'
|
|
||||||
$unsigned = Join-Path $WorkDir 'unsigned.apk'
|
|
||||||
& java -jar $ApktoolJar build -f -o $unsigned $Project
|
|
||||||
if ($LASTEXITCODE) { throw 'apktool build failed' }
|
|
||||||
|
|
||||||
$Keystore = Join-Path $WorkDir 'dorknet-debug.keystore'
|
|
||||||
if (-not (Test-Path $Keystore)) {
|
|
||||||
Step 'generating a fresh debug keystore (kept in the work dir, never committed)'
|
|
||||||
& keytool -genkeypair -v -keystore $Keystore -alias dorknet -keyalg RSA -keysize 2048 `
|
|
||||||
-validity 10000 -storepass dorknet -keypass dorknet -dname 'CN=DorkNet Quest, O=DorkNet'
|
|
||||||
if ($LASTEXITCODE) { throw 'keytool failed' }
|
|
||||||
}
|
|
||||||
|
|
||||||
Step 'zipalign'
|
|
||||||
$aligned = Join-Path $WorkDir 'aligned.apk'
|
|
||||||
& $ZipAlign -f -p 4 $unsigned $aligned
|
|
||||||
if ($LASTEXITCODE) { throw 'zipalign failed' }
|
|
||||||
|
|
||||||
if (-not $OutApk) { $OutApk = Join-Path $WorkDir "dorknet-recroom-$verName.apk" }
|
|
||||||
Step "apksigner -> $OutApk"
|
|
||||||
& $ApkSigner sign --ks $Keystore --ks-key-alias dorknet --ks-pass pass:dorknet --key-pass pass:dorknet `
|
|
||||||
--v2-signing-enabled true --v3-signing-enabled true --out $OutApk $aligned
|
|
||||||
if ($LASTEXITCODE) { throw 'apksigner failed' }
|
|
||||||
& $ApkSigner verify --print-certs $OutApk | Out-Null
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "DONE. Patched APK: $OutApk" -ForegroundColor Green
|
|
||||||
Write-Host "Package: $PkgName versionCode: $verCode" -ForegroundColor Green
|
|
||||||
if ($Obb) {
|
|
||||||
Write-Host ""
|
|
||||||
& (Join-Path $Scripts 'package.ps1') -Apk $OutApk -Obb $Obb -BuildToolsDir $BuildToolsDir
|
|
||||||
} else {
|
|
||||||
Write-Host "Install it, then push the matching OBB to:" -ForegroundColor Green
|
|
||||||
Write-Host " /sdcard/Android/obb/$PkgName/main.$verCode.$PkgName.obb" -ForegroundColor Green
|
|
||||||
Write-Host "(or pass -Obb to bundle both into an installable .xapk)" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build a DorkNet-patched Rec Room Quest APK (and optionally an .xapk release).
|
||||||
|
|
||||||
|
From a pristine store APK, end to end:
|
||||||
|
|
||||||
|
1. hook targets resolve them from an Il2CppDumper dump.cs
|
||||||
|
(resolve_targets.py), or use a committed table checked
|
||||||
|
against the APK's libil2cpp.so
|
||||||
|
2. headers targets_generated.h + config_generated.h
|
||||||
|
3. compile libdorknet.so (arm64) with the Android NDK
|
||||||
|
4. apktool decode
|
||||||
|
5. rename package rename, launcher category, app label
|
||||||
|
6. inject libdorknet.so in, DT_NEEDED into libmain.so (LIEF)
|
||||||
|
7. sign apktool build -> zipalign -> apksigner
|
||||||
|
8. package with --obb: .xapk + one-command installers (or run
|
||||||
|
`package` on an APK you already built)
|
||||||
|
|
||||||
|
Nothing game-derived is written into the repo. You supply the store APK and,
|
||||||
|
for a release package, the matching OBB. The output contains game binaries
|
||||||
|
from your own store copy: share it only with people who own the game.
|
||||||
|
|
||||||
|
Runs on Windows, Linux and macOS. Needs Python 3.9+ with LIEF, a JDK (java,
|
||||||
|
keytool), the Android NDK, Android build-tools and apktool.
|
||||||
|
|
||||||
|
py -3 build.py build --apk recroom.apk --obb main.2128210.com.AgainstGravity.RecRoom.obb \\
|
||||||
|
--targets-json ../targets/20230317-2128210.json --keystore dorknet.keystore
|
||||||
|
py -3 build.py package --apk dorknet-recroom-20230317.apk --obb main.2128210....obb
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NoReturn
|
||||||
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
|
|
||||||
|
SCRIPTS = Path(__file__).resolve().parent
|
||||||
|
REPO = SCRIPTS.parent # DorkNet.Quest/
|
||||||
|
OLD_PKG = "com.AgainstGravity.RecRoom"
|
||||||
|
SO_NAME = "libdorknet.so"
|
||||||
|
ON_WINDOWS = os.name == "nt"
|
||||||
|
|
||||||
|
|
||||||
|
def step(msg: str) -> None:
|
||||||
|
print(f"==> {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg: str) -> NoReturn:
|
||||||
|
raise SystemExit(f"error: {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def need(path: Path | str, what: str) -> Path:
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
fail(f"{what} not found: {p}")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def run(*cmd: object, cwd: Path | None = None) -> None:
|
||||||
|
args = [str(c) for c in cmd]
|
||||||
|
r = subprocess.run(args, cwd=cwd)
|
||||||
|
if r.returncode:
|
||||||
|
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
|
||||||
|
|
||||||
|
|
||||||
|
def capture(*cmd: object) -> str:
|
||||||
|
args = [str(c) for c in cmd]
|
||||||
|
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||||
|
if r.returncode:
|
||||||
|
sys.stderr.write(r.stderr)
|
||||||
|
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
|
||||||
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def run_py(script: str, *args: object) -> None:
|
||||||
|
"""Run a sibling helper script with this interpreter (isolated mode)."""
|
||||||
|
run(sys.executable, "-I", SCRIPTS / script, *args)
|
||||||
|
|
||||||
|
|
||||||
|
def exe(name: str) -> str:
|
||||||
|
return name + (".exe" if ON_WINDOWS else "")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- tool resolution ------------------------------------------------------
|
||||||
|
|
||||||
|
def sdk_root() -> Path | None:
|
||||||
|
v = os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME")
|
||||||
|
return Path(v) if v else None
|
||||||
|
|
||||||
|
|
||||||
|
def newest_subdir(parent: Path) -> Path | None:
|
||||||
|
if not parent.is_dir():
|
||||||
|
return None
|
||||||
|
dirs = sorted((d for d in parent.iterdir() if d.is_dir()), key=lambda d: d.name, reverse=True)
|
||||||
|
return dirs[0] if dirs else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_build_tools(arg: str | None) -> Path:
|
||||||
|
if arg:
|
||||||
|
return need(arg, "build-tools dir")
|
||||||
|
root = sdk_root()
|
||||||
|
found = newest_subdir(root / "build-tools") if root else None
|
||||||
|
if not found:
|
||||||
|
fail("Android build-tools not found. Pass --build-tools-dir or set ANDROID_SDK_ROOT.")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ndk(arg: str | None) -> Path:
|
||||||
|
if arg:
|
||||||
|
return need(arg, "NDK")
|
||||||
|
if os.environ.get("ANDROID_NDK"):
|
||||||
|
return need(os.environ["ANDROID_NDK"], "NDK (ANDROID_NDK)")
|
||||||
|
root = sdk_root()
|
||||||
|
found = newest_subdir(root / "ndk") if root else None
|
||||||
|
if not found:
|
||||||
|
fail("Android NDK not found. Pass --ndk-root or set ANDROID_NDK / ANDROID_SDK_ROOT.")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def ndk_bin(ndk: Path) -> Path:
|
||||||
|
if ON_WINDOWS:
|
||||||
|
tag = "windows-x86_64"
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
tag = "darwin-x86_64"
|
||||||
|
else:
|
||||||
|
tag = "linux-x86_64"
|
||||||
|
return ndk / "toolchains" / "llvm" / "prebuilt" / tag / "bin"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_apktool(arg: str | None) -> Path:
|
||||||
|
if arg:
|
||||||
|
return need(arg, "apktool.jar")
|
||||||
|
if os.environ.get("APKTOOL_JAR"):
|
||||||
|
return need(os.environ["APKTOOL_JAR"], "apktool.jar (APKTOOL_JAR)")
|
||||||
|
root = sdk_root()
|
||||||
|
if root and (root / "apktool.jar").exists():
|
||||||
|
return root / "apktool.jar"
|
||||||
|
raise SystemExit("error: apktool.jar not found. Pass --apktool-jar or set APKTOOL_JAR.")
|
||||||
|
|
||||||
|
|
||||||
|
def cfg_value(config: Path, key: str) -> str | None:
|
||||||
|
for line in config.read_text(encoding="utf-8").splitlines():
|
||||||
|
m = re.match(rf"^\s*{re.escape(key)}\s*=(.*)$", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1).split("#", 1)[0].strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- build ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_build(a: argparse.Namespace) -> None:
|
||||||
|
ndk = resolve_ndk(a.ndk_root)
|
||||||
|
bin_dir = ndk_bin(ndk)
|
||||||
|
cxx = need(bin_dir / (f"aarch64-linux-android{a.api_level}-clang++" + (".cmd" if ON_WINDOWS else "")),
|
||||||
|
"NDK aarch64 clang++")
|
||||||
|
objdump = bin_dir / exe("llvm-objdump")
|
||||||
|
build_tools = resolve_build_tools(a.build_tools_dir)
|
||||||
|
zipalign = need(build_tools / exe("zipalign"), "zipalign")
|
||||||
|
apksigner = need(build_tools / ("apksigner.bat" if ON_WINDOWS else "apksigner"), "apksigner")
|
||||||
|
apktool = resolve_apktool(a.apktool_jar)
|
||||||
|
apk = need(a.apk, "store APK").resolve()
|
||||||
|
|
||||||
|
config = Path(a.config) if a.config else (
|
||||||
|
REPO / "dorknet-quest.cfg" if (REPO / "dorknet-quest.cfg").exists() else REPO / "dorknet-quest.cfg.example")
|
||||||
|
need(config, "config")
|
||||||
|
print(f"Using config: {config}")
|
||||||
|
pkg_name = cfg_value(config, "PackageName") or "net.dorknet.RecRoom"
|
||||||
|
app_name = cfg_value(config, "AppName") or "DorkNet"
|
||||||
|
|
||||||
|
work = Path(a.work_dir)
|
||||||
|
gen, project, extract = work / "gen", work / "project", work / "extract"
|
||||||
|
for d in (work, gen, extract):
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. extract + targets
|
||||||
|
step("extracting libil2cpp.so + global-metadata.dat")
|
||||||
|
with zipfile.ZipFile(apk) as z:
|
||||||
|
for entry in ("lib/arm64-v8a/libil2cpp.so",
|
||||||
|
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
|
||||||
|
"lib/arm64-v8a/libmain.so"):
|
||||||
|
try:
|
||||||
|
data = z.read(entry)
|
||||||
|
except KeyError:
|
||||||
|
fail(f"APK missing {entry}")
|
||||||
|
(extract / Path(entry).name).write_bytes(data)
|
||||||
|
il2cpp = extract / "libil2cpp.so"
|
||||||
|
|
||||||
|
ver_name, ver_code = "20230317", "2128210"
|
||||||
|
if a.targets_json:
|
||||||
|
# A committed, already-resolved table. It is only valid for the exact
|
||||||
|
# libil2cpp.so it was resolved against, so check that first.
|
||||||
|
table = need(a.targets_json, "target table")
|
||||||
|
step(f"using target table {table}")
|
||||||
|
data = json.loads(table.read_text(encoding="utf-8"))
|
||||||
|
sha = hashlib.sha256(il2cpp.read_bytes()).hexdigest()
|
||||||
|
want = str(data.get("il2cppSha256") or "").lower()
|
||||||
|
if want and sha != want:
|
||||||
|
fail(f"libil2cpp.so in this APK ({sha}) is not the build the target table was resolved "
|
||||||
|
f"for ({want}). Resolve a table for it with --dump-cs.")
|
||||||
|
ver_name, ver_code = str(data["versionName"]), str(data["versionCode"])
|
||||||
|
else:
|
||||||
|
if not a.dump_cs:
|
||||||
|
# Dumping is left to Il2CppDumper (see README) to keep this
|
||||||
|
# script toolchain-light.
|
||||||
|
fail("Pass --dump-cs <dump.cs> from Il2CppDumper for this APK, or --targets-json with a "
|
||||||
|
"resolved table (see DorkNet.Quest/README.md).")
|
||||||
|
need(a.dump_cs, "dump.cs")
|
||||||
|
step("resolving hook targets")
|
||||||
|
# Written into the work dir (Controlled Folder Access can block writes
|
||||||
|
# under Documents). The repo keeps a committed copy under targets/.
|
||||||
|
table = work / f"{ver_name}-{ver_code}.json"
|
||||||
|
run_py("resolve_targets.py", "--dump", a.dump_cs, "--il2cpp", il2cpp,
|
||||||
|
"--version-name", ver_name, "--version-code", ver_code,
|
||||||
|
"--objdump", objdump, "--out", table)
|
||||||
|
|
||||||
|
# 2. headers
|
||||||
|
step("generating headers")
|
||||||
|
run_py("gen_targets_header.py", table, gen / "targets_generated.h")
|
||||||
|
run_py("gen_config_header.py", config, pkg_name, gen / "config_generated.h")
|
||||||
|
|
||||||
|
# 3. compile
|
||||||
|
step("compiling libdorknet.so (arm64)")
|
||||||
|
src = REPO / "src"
|
||||||
|
so = work / SO_NAME
|
||||||
|
run(cxx, "-std=c++17", "-fPIC", "-shared", "-O2", "-fvisibility=hidden", "-static-libstdc++",
|
||||||
|
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
|
||||||
|
f"-I{gen}", f"-I{src}",
|
||||||
|
src / "dorknet_quest.cpp", src / "config.cpp",
|
||||||
|
src / "third_party" / "And64InlineHook" / "And64InlineHook.cpp",
|
||||||
|
"-llog", "-o", so)
|
||||||
|
need(so, "libdorknet.so")
|
||||||
|
|
||||||
|
# 4. decode
|
||||||
|
step("apktool decode")
|
||||||
|
if project.exists():
|
||||||
|
shutil.rmtree(project)
|
||||||
|
run("java", "-jar", apktool, "decode", "-f", "-o", project, apk)
|
||||||
|
|
||||||
|
# 5. package rename + manifest + label
|
||||||
|
step(f"renaming package {OLD_PKG} -> {pkg_name}")
|
||||||
|
manifest = project / "AndroidManifest.xml"
|
||||||
|
m = manifest.read_text(encoding="utf-8").replace(OLD_PKG, pkg_name)
|
||||||
|
if "android.intent.category.LAUNCHER" not in m:
|
||||||
|
m = m.replace('<category android:name="android.intent.category.INFO"/>',
|
||||||
|
'<category android:name="android.intent.category.INFO"/>'
|
||||||
|
'<category android:name="android.intent.category.LAUNCHER"/>')
|
||||||
|
manifest.write_text(m, encoding="utf-8", newline="")
|
||||||
|
|
||||||
|
# The launcher label comes from @string/app_name; set it in every locale.
|
||||||
|
step(f"naming the app '{app_name}'")
|
||||||
|
# XML entities for & < > " ' (the PowerShell version used
|
||||||
|
# SecurityElement.Escape), so no raw quote reaches Android's string parser.
|
||||||
|
label = xml_escape(app_name, {'"': """, "'": "'"})
|
||||||
|
for sx in (project / "res").rglob("strings.xml"):
|
||||||
|
s = sx.read_text(encoding="utf-8")
|
||||||
|
n = re.sub(r'<string name="app_name">[^<]*</string>', lambda _: f'<string name="app_name">{label}</string>', s)
|
||||||
|
if n != s:
|
||||||
|
sx.write_text(n, encoding="utf-8", newline="")
|
||||||
|
|
||||||
|
# 6. inject
|
||||||
|
step(f"injecting {SO_NAME}")
|
||||||
|
shutil.copy2(so, project / "lib" / "arm64-v8a" / SO_NAME)
|
||||||
|
run_py("inject_needed.py", project / "lib" / "arm64-v8a" / "libmain.so", SO_NAME)
|
||||||
|
|
||||||
|
# 7. build + align + sign
|
||||||
|
step("apktool build")
|
||||||
|
unsigned = work / "unsigned.apk"
|
||||||
|
run("java", "-jar", apktool, "build", "-f", "-o", unsigned, project)
|
||||||
|
|
||||||
|
if a.keystore:
|
||||||
|
keystore = need(a.keystore, "keystore")
|
||||||
|
else:
|
||||||
|
keystore = work / "dorknet-debug.keystore"
|
||||||
|
if not keystore.exists():
|
||||||
|
step("generating a fresh debug keystore (kept in the work dir, never committed)")
|
||||||
|
run("keytool", "-genkeypair", "-v", "-keystore", keystore, "-alias", a.key_alias,
|
||||||
|
"-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
|
||||||
|
"-storepass", a.keystore_pass, "-keypass", a.keystore_pass,
|
||||||
|
"-dname", "CN=DorkNet Quest, O=DorkNet")
|
||||||
|
|
||||||
|
step("zipalign")
|
||||||
|
aligned = work / "aligned.apk"
|
||||||
|
run(zipalign, "-f", "-p", "4", unsigned, aligned)
|
||||||
|
|
||||||
|
out_apk = Path(a.out_apk) if a.out_apk else work / f"dorknet-recroom-{ver_name}.apk"
|
||||||
|
out_apk.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
step(f"apksigner -> {out_apk}")
|
||||||
|
run(apksigner, "sign", "--ks", keystore, "--ks-key-alias", a.key_alias,
|
||||||
|
"--ks-pass", f"pass:{a.keystore_pass}", "--key-pass", f"pass:{a.keystore_pass}",
|
||||||
|
"--v2-signing-enabled", "true", "--v3-signing-enabled", "true",
|
||||||
|
"--out", out_apk, aligned)
|
||||||
|
capture(apksigner, "verify", "--print-certs", out_apk)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"DONE. Patched APK: {out_apk}")
|
||||||
|
print(f"Package: {pkg_name} versionCode: {ver_code}")
|
||||||
|
if a.obb:
|
||||||
|
print()
|
||||||
|
package(out_apk, Path(a.obb), Path(a.package_dir) if a.package_dir else None, build_tools)
|
||||||
|
else:
|
||||||
|
print("Install it, then push the matching OBB to:")
|
||||||
|
print(f" /sdcard/Android/obb/{pkg_name}/main.{ver_code}.{pkg_name}.obb")
|
||||||
|
print("(or pass --obb to bundle both into an installable .xapk)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- package --------------------------------------------------------------
|
||||||
|
|
||||||
|
README_TXT = """{name} {ver} - Rec Room for DorkNet (Meta Quest)
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
|
||||||
|
in over USB and accept "Allow USB debugging" inside the headset.
|
||||||
|
2. Install. Easiest: open the Quest page of your DorkNet server's website
|
||||||
|
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
|
||||||
|
steps. Or use the scripts here:
|
||||||
|
Windows: double-click install.cmd
|
||||||
|
macOS / Linux: ./install.sh
|
||||||
|
The installer needs adb (Android platform-tools) on PATH or in
|
||||||
|
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
|
||||||
|
offers to launch it.
|
||||||
|
3. In the headset: Library > Unknown Sources > {name}.
|
||||||
|
|
||||||
|
The app installs as {pkg} alongside any store copy of Rec Room.
|
||||||
|
SideQuest users can also drop the .xapk onto SideQuest instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def package(apk: Path, obb: Path, out_dir: Path | None, build_tools: Path) -> Path:
|
||||||
|
"""Bundle a patched APK and its OBB into an .xapk plus installers.
|
||||||
|
|
||||||
|
The XAPK is the usual APKPure layout (manifest.json + APK + OBB + icon), so
|
||||||
|
XAPK-aware installers (SideQuest's file drop, SAI, APKPure) can open it too.
|
||||||
|
The APK and OBB are stored uncompressed: they are already compressed, and
|
||||||
|
storing them keeps packaging and install fast."""
|
||||||
|
apk, obb = need(apk, "patched APK").resolve(), need(obb, "OBB").resolve()
|
||||||
|
aapt2 = need(build_tools / exe("aapt2"), "aapt2")
|
||||||
|
|
||||||
|
step("reading APK metadata")
|
||||||
|
badging = capture(aapt2, "dump", "badging", apk).splitlines()
|
||||||
|
|
||||||
|
def badge(pattern: str) -> str | None:
|
||||||
|
for line in badging:
|
||||||
|
mm = re.search(pattern, line)
|
||||||
|
if mm:
|
||||||
|
return mm.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
pkg = badge(r"^package: name='([^']+)'")
|
||||||
|
ver_code = badge(r"versionCode='([^']+)'")
|
||||||
|
ver_name = badge(r"versionName='([^']+)'")
|
||||||
|
min_sdk = badge(r"^(?:minSdkVersion|sdkVersion):'([^']+)'")
|
||||||
|
tgt_sdk = badge(r"^targetSdkVersion:'([^']+)'")
|
||||||
|
label = badge(r"^application-label:'([^']*)'")
|
||||||
|
if not pkg or not ver_code:
|
||||||
|
fail("could not read package name / versionCode from the APK")
|
||||||
|
perms = [mm.group(1) for line in badging if (mm := re.match(r"^uses-permission: name='([^']+)'", line))]
|
||||||
|
icons = sorted(((int(mm.group(1)), mm.group(2)) for line in badging
|
||||||
|
if (mm := re.match(r"^application-icon-(\d+):'([^']+)'", line))), reverse=True)
|
||||||
|
|
||||||
|
# The launcher icon may be an adaptive-icon XML; XAPK installers want a
|
||||||
|
# PNG, so fall back to the same-named PNG from the densest mipmap/drawable.
|
||||||
|
icon_path = None
|
||||||
|
with zipfile.ZipFile(apk) as z:
|
||||||
|
names = set(z.namelist())
|
||||||
|
if icons:
|
||||||
|
ref = icons[0][1]
|
||||||
|
if ref.endswith(".png") and ref in names:
|
||||||
|
icon_path = ref
|
||||||
|
else:
|
||||||
|
base = Path(ref).stem
|
||||||
|
for d in ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi", "ldpi"):
|
||||||
|
for kind in ("mipmap", "drawable"):
|
||||||
|
cand = f"res/{kind}-{d}/{base}.png"
|
||||||
|
if icon_path is None and cand in names:
|
||||||
|
icon_path = cand
|
||||||
|
icon_bytes = z.read(icon_path) if icon_path else None
|
||||||
|
print(f" {pkg} version {ver_name} ({ver_code})")
|
||||||
|
|
||||||
|
obb_entry = f"Android/obb/{pkg}/main.{ver_code}.{pkg}.obb"
|
||||||
|
out_dir = out_dir or apk.parent / f"DorkNet-Quest-{ver_name}"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
xapk = out_dir / f"DorkNet-{ver_name}.xapk"
|
||||||
|
xapk.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"xapk_version": 2,
|
||||||
|
"package_name": pkg,
|
||||||
|
"name": label or "DorkNet",
|
||||||
|
"version_code": ver_code,
|
||||||
|
"version_name": ver_name,
|
||||||
|
"min_sdk_version": min_sdk,
|
||||||
|
"target_sdk_version": tgt_sdk,
|
||||||
|
"permissions": perms,
|
||||||
|
"total_size": apk.stat().st_size + obb.stat().st_size,
|
||||||
|
"expansions": [{"file": obb_entry, "install_location": "EXTERNAL_STORAGE", "install_path": obb_entry}],
|
||||||
|
}
|
||||||
|
if icon_bytes:
|
||||||
|
manifest["icon"] = "icon.png"
|
||||||
|
|
||||||
|
step(f"writing {xapk} (the OBB is large; this takes a minute)")
|
||||||
|
with zipfile.ZipFile(xapk, "w", allowZip64=True) as z:
|
||||||
|
z.writestr(zipfile.ZipInfo("manifest.json"), json.dumps(manifest, indent=2), compress_type=zipfile.ZIP_DEFLATED)
|
||||||
|
if icon_bytes:
|
||||||
|
z.writestr("icon.png", icon_bytes, compress_type=zipfile.ZIP_STORED)
|
||||||
|
z.write(apk, f"{pkg}.apk", compress_type=zipfile.ZIP_STORED)
|
||||||
|
z.write(obb, obb_entry, compress_type=zipfile.ZIP_STORED)
|
||||||
|
|
||||||
|
step("adding installers")
|
||||||
|
for f in ("install.ps1", "install.cmd", "install.sh"):
|
||||||
|
shutil.copy2(SCRIPTS / "installer" / f, out_dir / f)
|
||||||
|
(out_dir / "install.sh").chmod(0o755)
|
||||||
|
(out_dir / "README.txt").write_text(
|
||||||
|
README_TXT.format(name=manifest["name"], ver=ver_name, pkg=pkg), encoding="utf-8")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"DONE. Package folder: {out_dir}")
|
||||||
|
print(f" {xapk.name} ({xapk.stat().st_size / 2**30:.2f} GB)")
|
||||||
|
print(f"Install with: {out_dir / 'install.cmd'}")
|
||||||
|
return out_dir
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_package(a: argparse.Namespace) -> None:
|
||||||
|
package(Path(a.apk), Path(a.obb), Path(a.out_dir) if a.out_dir else None,
|
||||||
|
resolve_build_tools(a.build_tools_dir))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- cli ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
b = sub.add_parser("build", help="store APK -> signed patched APK (+ .xapk with --obb)")
|
||||||
|
b.add_argument("--apk", required=True, help="pristine store APK (com.AgainstGravity.RecRoom)")
|
||||||
|
b.add_argument("--obb", help="matching store OBB; also writes the .xapk release package")
|
||||||
|
b.add_argument("--config", help="dorknet-quest.cfg (default: DorkNet.Quest/dorknet-quest.cfg, else the .example)")
|
||||||
|
b.add_argument("--dump-cs", help="Il2CppDumper dump.cs for this APK, to resolve the hook targets")
|
||||||
|
b.add_argument("--targets-json", help="a resolved target table to use instead of --dump-cs; must match the APK's libil2cpp.so")
|
||||||
|
b.add_argument("--keystore", help="signing keystore; reuse the same one for every build so updates install in place")
|
||||||
|
b.add_argument("--keystore-pass", default="dorknet", help="store and key password (default: dorknet)")
|
||||||
|
b.add_argument("--key-alias", default="dorknet", help="key alias (default: dorknet)")
|
||||||
|
b.add_argument("--work-dir", default=str(Path(tempfile.gettempdir()) / "dorknet-quest-build"),
|
||||||
|
help="scratch directory (on Windows keep it outside Controlled Folder Access, e.g. %%TEMP%%)")
|
||||||
|
b.add_argument("--out-apk", help="where to write the APK (default: <work-dir>/dorknet-recroom-<ver>.apk)")
|
||||||
|
b.add_argument("--package-dir", help="release folder for the .xapk (default: <apk folder>/DorkNet-Quest-<ver>)")
|
||||||
|
b.add_argument("--ndk-root", help="Android NDK root (default: ANDROID_NDK, else newest under ANDROID_SDK_ROOT/ndk)")
|
||||||
|
b.add_argument("--build-tools-dir", help="Android build-tools dir (default: newest under ANDROID_SDK_ROOT/build-tools)")
|
||||||
|
b.add_argument("--apktool-jar", help="apktool.jar (default: APKTOOL_JAR, else ANDROID_SDK_ROOT/apktool.jar)")
|
||||||
|
b.add_argument("--api-level", default="24", help="NDK API level (default: 24)")
|
||||||
|
b.set_defaults(func=cmd_build)
|
||||||
|
|
||||||
|
k = sub.add_parser("package", help="patched APK + OBB -> .xapk + installers")
|
||||||
|
k.add_argument("--apk", required=True)
|
||||||
|
k.add_argument("--obb", required=True)
|
||||||
|
k.add_argument("--out-dir", help="release folder (default: <apk folder>/DorkNet-Quest-<ver>)")
|
||||||
|
k.add_argument("--build-tools-dir")
|
||||||
|
k.set_defaults(func=cmd_package)
|
||||||
|
|
||||||
|
a = p.parse_args()
|
||||||
|
a.func(a)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -23,6 +23,7 @@ KEYS = {
|
|||||||
"PhotonRegion": ("DN_PHOTON_REGION", "str"),
|
"PhotonRegion": ("DN_PHOTON_REGION", "str"),
|
||||||
"EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"),
|
"EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"),
|
||||||
"AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"),
|
"AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"),
|
||||||
|
"AutoCreateAccount": ("DN_AUTO_CREATE_ACCOUNT", "bool"),
|
||||||
"LogHttp": ("DN_LOG_HTTP", "bool"),
|
"LogHttp": ("DN_LOG_HTTP", "bool"),
|
||||||
"Debug": ("DN_DEBUG_HTTP", "bool"),
|
"Debug": ("DN_DEBUG_HTTP", "bool"),
|
||||||
"LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"),
|
"LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"),
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Bundle a patched Quest APK and its OBB into one installable package.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Writes a release folder holding:
|
|
||||||
DorkNet-<ver>.xapk standard XAPK (manifest.json + APK + OBB + icon)
|
|
||||||
install.cmd / install.ps1 one-command installer for Windows
|
|
||||||
install.sh one-command installer for macOS / Linux
|
|
||||||
README.txt what to do with it
|
|
||||||
|
|
||||||
The XAPK is the usual APKPure layout, so XAPK-aware installers (SideQuest's
|
|
||||||
file drop, SAI, APKPure) can also open it. The OBB is stored uncompressed:
|
|
||||||
it is already compressed, and storing it keeps packaging and install fast.
|
|
||||||
|
|
||||||
The output contains game binaries from your own store copy. Share it only
|
|
||||||
with people who own the game.
|
|
||||||
|
|
||||||
.PARAMETER Apk
|
|
||||||
The signed, patched APK produced by build.ps1.
|
|
||||||
|
|
||||||
.PARAMETER Obb
|
|
||||||
The store OBB matching the APK's versionCode (main.<code>.com.AgainstGravity.RecRoom.obb).
|
|
||||||
|
|
||||||
.PARAMETER OutDir
|
|
||||||
Release folder to create. Default: <APK folder>\DorkNet-Quest-<ver>.
|
|
||||||
|
|
||||||
.PARAMETER BuildToolsDir
|
|
||||||
Android SDK build-tools dir holding aapt2. Default: resolved from $env:ANDROID_SDK_ROOT.
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)][string]$Apk,
|
|
||||||
[Parameter(Mandatory = $true)][string]$Obb,
|
|
||||||
[string]$OutDir,
|
|
||||||
[string]$BuildToolsDir
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
|
|
||||||
|
|
||||||
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
|
||||||
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
|
|
||||||
|
|
||||||
Need $Apk 'patched APK'
|
|
||||||
Need $Obb 'OBB'
|
|
||||||
$Apk = (Resolve-Path -LiteralPath $Apk).Path
|
|
||||||
$Obb = (Resolve-Path -LiteralPath $Obb).Path
|
|
||||||
|
|
||||||
if (-not $BuildToolsDir) {
|
|
||||||
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
|
|
||||||
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
|
|
||||||
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
|
|
||||||
}
|
|
||||||
$Aapt2 = Join-Path $BuildToolsDir 'aapt2.exe'
|
|
||||||
Need $Aapt2 'aapt2'
|
|
||||||
|
|
||||||
# ---- read the APK's identity --------------------------------------------
|
|
||||||
Step 'reading APK metadata'
|
|
||||||
$badging = & $Aapt2 dump badging $Apk 2>$null
|
|
||||||
if ($LASTEXITCODE) { throw 'aapt2 dump badging failed' }
|
|
||||||
function Badge($pattern) { foreach ($l in $badging) { if ($l -match $pattern) { return $Matches[1] } } return $null }
|
|
||||||
$pkg = Badge "^package: name='([^']+)'"
|
|
||||||
$verCode = Badge "versionCode='([^']+)'"
|
|
||||||
$verName = Badge "versionName='([^']+)'"
|
|
||||||
$minSdk = Badge "^(?:minSdkVersion|sdkVersion):'([^']+)'"
|
|
||||||
$tgtSdk = Badge "^targetSdkVersion:'([^']+)'"
|
|
||||||
$label = Badge "^application-label:'([^']*)'"
|
|
||||||
if (-not $pkg -or -not $verCode) { throw 'could not read package name / versionCode from the APK' }
|
|
||||||
$perms = @($badging | Where-Object { $_ -match "^uses-permission: name='([^']+)'" } | ForEach-Object { $_ -replace "^uses-permission: name='([^']+)'.*", '$1' })
|
|
||||||
$iconRef = @($badging | Where-Object { $_ -match "^application-icon-\d+:'([^']+)'" } |
|
|
||||||
ForEach-Object { [pscustomobject]@{ Density = [int]($_ -replace "^application-icon-(\d+):.*", '$1'); Path = ($_ -replace "^application-icon-\d+:'([^']+)'.*", '$1') } } |
|
|
||||||
Sort-Object Density -Descending | Select-Object -First 1 | ForEach-Object { $_.Path })
|
|
||||||
|
|
||||||
# The launcher icon may be an adaptive-icon XML; XAPK installers want a PNG, so
|
|
||||||
# fall back to the same-named PNG from the densest mipmap/drawable folder.
|
|
||||||
$iconPath = $null
|
|
||||||
if ($iconRef) {
|
|
||||||
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
|
|
||||||
try {
|
|
||||||
if ($iconRef -like '*.png' -and $src.GetEntry($iconRef)) { $iconPath = $iconRef }
|
|
||||||
else {
|
|
||||||
$base = [System.IO.Path]::GetFileNameWithoutExtension($iconRef)
|
|
||||||
foreach ($d in 'xxxhdpi', 'xxhdpi', 'xhdpi', 'hdpi', 'mdpi', 'ldpi') {
|
|
||||||
foreach ($kind in 'mipmap', 'drawable') {
|
|
||||||
if (-not $iconPath -and $src.GetEntry("res/$kind-$d/$base.png")) { $iconPath = "res/$kind-$d/$base.png" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally { $src.Dispose() }
|
|
||||||
}
|
|
||||||
Write-Host " $pkg version $verName ($verCode)"
|
|
||||||
|
|
||||||
$obbName = "main.$verCode.$pkg.obb"
|
|
||||||
$obbEntry = "Android/obb/$pkg/$obbName"
|
|
||||||
$apkEntry = "$pkg.apk"
|
|
||||||
|
|
||||||
if (-not $OutDir) { $OutDir = Join-Path (Split-Path -Parent $Apk) "DorkNet-Quest-$verName" }
|
|
||||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
|
||||||
$xapk = Join-Path $OutDir "DorkNet-$verName.xapk"
|
|
||||||
if (Test-Path $xapk) { Remove-Item -Force $xapk }
|
|
||||||
|
|
||||||
# ---- manifest.json -------------------------------------------------------
|
|
||||||
$manifest = [ordered]@{
|
|
||||||
xapk_version = 2
|
|
||||||
package_name = $pkg
|
|
||||||
name = if ($label) { $label } else { 'DorkNet' }
|
|
||||||
version_code = $verCode
|
|
||||||
version_name = $verName
|
|
||||||
min_sdk_version = $minSdk
|
|
||||||
target_sdk_version = $tgtSdk
|
|
||||||
permissions = $perms
|
|
||||||
total_size = (Get-Item $Apk).Length + (Get-Item $Obb).Length
|
|
||||||
expansions = @([ordered]@{ file = $obbEntry; install_location = 'EXTERNAL_STORAGE'; install_path = $obbEntry })
|
|
||||||
}
|
|
||||||
if ($iconPath) { $manifest.icon = 'icon.png' }
|
|
||||||
|
|
||||||
# ---- write the XAPK ------------------------------------------------------
|
|
||||||
Step "writing $xapk (the OBB is large; this takes a minute)"
|
|
||||||
$zip = [System.IO.Compression.ZipFile]::Open($xapk, [System.IO.Compression.ZipArchiveMode]::Create)
|
|
||||||
try {
|
|
||||||
$e = $zip.CreateEntry('manifest.json', [System.IO.Compression.CompressionLevel]::Optimal)
|
|
||||||
$w = New-Object System.IO.StreamWriter($e.Open(), (New-Object System.Text.UTF8Encoding($false)))
|
|
||||||
$w.Write(($manifest | ConvertTo-Json -Depth 5)); $w.Dispose()
|
|
||||||
|
|
||||||
if ($iconPath) {
|
|
||||||
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
|
|
||||||
try {
|
|
||||||
$ie = $src.GetEntry($iconPath)
|
|
||||||
if ($ie) {
|
|
||||||
$dst = $zip.CreateEntry('icon.png', [System.IO.Compression.CompressionLevel]::NoCompression)
|
|
||||||
$in = $ie.Open(); $out = $dst.Open(); $in.CopyTo($out); $out.Dispose(); $in.Dispose()
|
|
||||||
}
|
|
||||||
} finally { $src.Dispose() }
|
|
||||||
}
|
|
||||||
|
|
||||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Apk, $apkEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
|
|
||||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Obb, $obbEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
|
|
||||||
} finally { $zip.Dispose() }
|
|
||||||
|
|
||||||
# ---- installers + readme -------------------------------------------------
|
|
||||||
Step 'adding installers'
|
|
||||||
foreach ($f in 'install.ps1', 'install.cmd', 'install.sh') {
|
|
||||||
Copy-Item (Join-Path $PSScriptRoot "installer\$f") (Join-Path $OutDir $f) -Force
|
|
||||||
}
|
|
||||||
$readme = @"
|
|
||||||
$($manifest.name) $verName - Rec Room for DorkNet (Meta Quest)
|
|
||||||
============================================================
|
|
||||||
|
|
||||||
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
|
|
||||||
in over USB and accept "Allow USB debugging" inside the headset.
|
|
||||||
2. Install. Easiest: open the Quest page of your DorkNet server's website
|
|
||||||
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
|
|
||||||
steps. Or use the scripts here:
|
|
||||||
Windows: double-click install.cmd
|
|
||||||
macOS / Linux: ./install.sh
|
|
||||||
The installer needs adb (Android platform-tools) on PATH or in
|
|
||||||
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
|
|
||||||
offers to launch it.
|
|
||||||
3. In the headset: Library > Unknown Sources > $($manifest.name).
|
|
||||||
|
|
||||||
The app installs as $pkg alongside any store copy of Rec Room.
|
|
||||||
SideQuest users can also drop the .xapk onto SideQuest instead.
|
|
||||||
"@
|
|
||||||
Set-Content -Path (Join-Path $OutDir 'README.txt') -Value $readme -Encoding utf8
|
|
||||||
|
|
||||||
$sizeGb = '{0:N2}' -f ((Get-Item $xapk).Length / 1GB)
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "DONE. Package folder: $OutDir" -ForegroundColor Green
|
|
||||||
Write-Host " $(Split-Path -Leaf $xapk) ($sizeGb GB)" -ForegroundColor Green
|
|
||||||
Write-Host "Install with: $(Join-Path $OutDir 'install.cmd')" -ForegroundColor Green
|
|
||||||
Binary file not shown.
@@ -57,6 +57,11 @@
|
|||||||
#ifndef DN_ACCEPT_UNSIGNED_IMAGES
|
#ifndef DN_ACCEPT_UNSIGNED_IMAGES
|
||||||
# define DN_ACCEPT_UNSIGNED_IMAGES true
|
# define DN_ACCEPT_UNSIGNED_IMAGES true
|
||||||
#endif
|
#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
|
#ifndef DN_LOG_HTTP
|
||||||
# define DN_LOG_HTTP true
|
# define DN_LOG_HTTP true
|
||||||
#endif
|
#endif
|
||||||
@@ -82,7 +87,7 @@ void dn_load_runtime_config(char *server_host, size_t server_host_cap,
|
|||||||
char *photon_realtime, char *photon_voice, char *photon_chat,
|
char *photon_realtime, char *photon_voice, char *photon_chat,
|
||||||
char *photon_server, size_t photon_server_cap, int *photon_port,
|
char *photon_server, size_t photon_server_cap, int *photon_port,
|
||||||
char *photon_region, size_t photon_region_cap,
|
char *photon_region, size_t photon_region_cap,
|
||||||
bool *tls_bypass, bool *accept_images,
|
bool *tls_bypass, bool *accept_images, bool *auto_create_account,
|
||||||
bool *log_http, bool *debug_http, int *body_max);
|
bool *log_http, bool *debug_http, int *body_max);
|
||||||
|
|
||||||
#endif // DORKNET_QUEST_CONFIG_H
|
#endif // DORKNET_QUEST_CONFIG_H
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ static int g_photon_port = DN_PHOTON_PORT;
|
|||||||
static char g_photon_region[16] = DN_PHOTON_REGION;
|
static char g_photon_region[16] = DN_PHOTON_REGION;
|
||||||
static bool g_tls_bypass = DN_TLS_TRUST_BYPASS;
|
static bool g_tls_bypass = DN_TLS_TRUST_BYPASS;
|
||||||
static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES;
|
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_log_http = DN_LOG_HTTP;
|
||||||
static bool g_debug_http = DN_DEBUG_HTTP;
|
static bool g_debug_http = DN_DEBUG_HTTP;
|
||||||
static int g_body_max = DN_LOG_BODY_MAX;
|
static int g_body_max = DN_LOG_BODY_MAX;
|
||||||
@@ -89,8 +90,12 @@ static const size_t kArrayDataOff = 0x20;
|
|||||||
// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME).
|
// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME).
|
||||||
static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u;
|
static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u;
|
||||||
static const unsigned kMsgUserGetUserProof = 578880643u;
|
static const unsigned kMsgUserGetUserProof = 578880643u;
|
||||||
|
static const unsigned kMsgUserGetLoggedInUser = 1131361373u;
|
||||||
static const char *kPlaceholderNonce = "dorknet-no-platform-account";
|
static const char *kPlaceholderNonce = "dorknet-no-platform-account";
|
||||||
static const unsigned long long kNoPlatformUserId = 1ULL;
|
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 size_t kLogChunk = 1500;
|
||||||
static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS
|
static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS
|
||||||
@@ -103,12 +108,22 @@ typedef void *(*il2cpp_class_get_method_from_name_t)(void *klass, const char *na
|
|||||||
typedef void *(*il2cpp_object_new_t)(void *klass);
|
typedef void *(*il2cpp_object_new_t)(void *klass);
|
||||||
typedef void *(*il2cpp_string_new_t)(const char *str);
|
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_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_object_get_class_t il2cpp_object_get_class;
|
||||||
static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name;
|
static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name;
|
||||||
static il2cpp_object_new_t il2cpp_object_new;
|
static il2cpp_object_new_t il2cpp_object_new;
|
||||||
static il2cpp_string_new_t il2cpp_string_new;
|
static il2cpp_string_new_t il2cpp_string_new;
|
||||||
static il2cpp_runtime_invoke_t il2cpp_runtime_invoke;
|
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;
|
static uintptr_t g_il2cpp_base;
|
||||||
|
|
||||||
@@ -139,6 +154,30 @@ static const unsigned char *invoke_bytes(void *obj, const char *method, size_t *
|
|||||||
return (const unsigned char *)((char *)arr + kArrayDataOff);
|
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) {
|
static void invoke_str(void *obj, const char *method, char *out, size_t cap) {
|
||||||
out[0] = '\0';
|
out[0] = '\0';
|
||||||
if (!obj) return;
|
if (!obj) return;
|
||||||
@@ -438,14 +477,73 @@ typedef unsigned (*ovr_GetType_t)(void *m);
|
|||||||
static ovr_IsError_t orig_ovr_IsError;
|
static ovr_IsError_t orig_ovr_IsError;
|
||||||
static ovr_GetType_t ovr_GetType;
|
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) {
|
static bool hk_ovr_IsError(void *m) {
|
||||||
bool real = orig_ovr_IsError(m);
|
bool real = orig_ovr_IsError(m);
|
||||||
if (!real || !ovr_GetType) return real;
|
if (!real) return real;
|
||||||
unsigned t = ovr_GetType(m);
|
if (m && m == t_bypassed_native) return false;
|
||||||
if (t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof) return false;
|
if (ovr_GetType && is_bypassed_type(ovr_GetType(m))) return false;
|
||||||
return real;
|
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 *(*orig_ovr_GetUserProof)(void *m);
|
||||||
static void *hk_ovr_GetUserProof(void *m) {
|
static void *hk_ovr_GetUserProof(void *m) {
|
||||||
void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr;
|
void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr;
|
||||||
@@ -469,10 +567,46 @@ static unsigned long long hk_ovr_GetLoggedInUserID() {
|
|||||||
|
|
||||||
static unsigned long long (*orig_ovr_User_GetID)(void *u);
|
static unsigned long long (*orig_ovr_User_GetID)(void *u);
|
||||||
static unsigned long long hk_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;
|
unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0;
|
||||||
return real ? real : kNoPlatformUserId;
|
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 ------
|
// ----------------------------------------------------------------- init ------
|
||||||
|
|
||||||
static uintptr_t module_base(const char *name) {
|
static uintptr_t module_base(const char *name) {
|
||||||
@@ -516,6 +650,12 @@ static bool resolve_il2cpp(void *h) {
|
|||||||
SYM(il2cpp_string_new)
|
SYM(il2cpp_string_new)
|
||||||
SYM(il2cpp_runtime_invoke)
|
SYM(il2cpp_runtime_invoke)
|
||||||
#undef SYM
|
#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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,6 +677,8 @@ static void arm_oculus_platform_bypass() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void *a;
|
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")))
|
if ((a = dlsym(ovr, "ovr_GetLoggedInUserID")))
|
||||||
A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID);
|
A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID);
|
||||||
if ((a = dlsym(ovr, "ovr_User_GetID")))
|
if ((a = dlsym(ovr, "ovr_User_GetID")))
|
||||||
@@ -545,6 +687,21 @@ static void arm_oculus_platform_bypass() {
|
|||||||
A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof);
|
A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof);
|
||||||
if ((a = dlsym(ovr, "ovr_UserProof_GetNonce")))
|
if ((a = dlsym(ovr, "ovr_UserProof_GetNonce")))
|
||||||
A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_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 *) {
|
static void *init_thread(void *) {
|
||||||
@@ -562,7 +719,7 @@ static void *init_thread(void *) {
|
|||||||
g_photon_realtime, g_photon_voice, g_photon_chat,
|
g_photon_realtime, g_photon_voice, g_photon_chat,
|
||||||
g_photon_server, sizeof(g_photon_server), &g_photon_port,
|
g_photon_server, sizeof(g_photon_server), &g_photon_port,
|
||||||
g_photon_region, sizeof(g_photon_region),
|
g_photon_region, sizeof(g_photon_region),
|
||||||
&g_tls_bypass, &g_accept_images, &g_log_http, &g_debug_http, &g_body_max);
|
&g_tls_bypass, &g_accept_images, &g_auto_create_account, &g_log_http, &g_debug_http, &g_body_max);
|
||||||
|
|
||||||
LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s",
|
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);
|
DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host);
|
||||||
@@ -586,6 +743,8 @@ static void *init_thread(void *) {
|
|||||||
arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr);
|
arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
arm(&dn_targets.title_auto_login, (void *)hk_TitleAutoLogin, (void **)&orig_TitleAutoLogin);
|
||||||
|
|
||||||
if (g_accept_images)
|
if (g_accept_images)
|
||||||
arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr);
|
arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr);
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,12 @@
|
|||||||
"length": 412,
|
"length": 412,
|
||||||
"fingerprint": "0xb12b0044ac4f844f",
|
"fingerprint": "0xb12b0044ac4f844f",
|
||||||
"why": "Log-only: prints the quit code and caller."
|
"why": "Log-only: prints the quit code and caller."
|
||||||
|
},
|
||||||
|
"title_auto_login": {
|
||||||
|
"rva": 58892048,
|
||||||
|
"length": 668,
|
||||||
|
"fingerprint": "0x83bebb9a30983b33",
|
||||||
|
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,12 @@
|
|||||||
"class": "public class SessionManager ",
|
"class": "public class SessionManager ",
|
||||||
"method": "public static void TryApplicationQuit(int DKOGJICFAEP)",
|
"method": "public static void TryApplicationQuit(int DKOGJICFAEP)",
|
||||||
"why": "Log-only: prints the quit code and caller."
|
"why": "Log-only: prints the quit code and caller."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "title_auto_login",
|
||||||
|
"class": "public class TitleScreenFlowModel ",
|
||||||
|
"method": "private LDGADANDBIO IAKHIJIHAPJ(LBNJFPOLCDL.MDGJJCFGJNF AJCKAHHJDEN)",
|
||||||
|
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Hubs;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stand-in for the notify hub that records every push instead of sending it,
|
||||||
|
/// so tests can assert on the exact frames the game client would receive.
|
||||||
|
/// A frame is the hub method plus its single string argument, parsed back into
|
||||||
|
/// the <c>{Id, Msg}</c> envelope.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CapturingHubContext : IHubContext<NotifyHub>
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<SentFrame> _sent = new();
|
||||||
|
|
||||||
|
public IHubClients Clients => new CapturingClients(this);
|
||||||
|
public IGroupManager Groups { get; } = new NoopGroups();
|
||||||
|
|
||||||
|
public IReadOnlyList<SentFrame> Sent => _sent.ToArray();
|
||||||
|
|
||||||
|
/// <summary>Frames sent to a target such as <c>group:player:12</c>, in
|
||||||
|
/// order, optionally filtered by envelope <c>Id</c>.</summary>
|
||||||
|
public IReadOnlyList<SentFrame> To(string target, string? id = null) =>
|
||||||
|
_sent.Where(f => f.Target == target && (id is null || f.Id == id)).ToList();
|
||||||
|
|
||||||
|
public static string PlayerGroup(long playerId) => $"group:{NotifyHub.GroupForPlayer(playerId)}";
|
||||||
|
|
||||||
|
internal void Record(string target, IReadOnlyList<string>? excluded, string method, object?[] args) =>
|
||||||
|
_sent.Enqueue(new SentFrame(target, excluded ?? Array.Empty<string>(), method,
|
||||||
|
args.Length == 1 && args[0] is string s ? s : JsonSerializer.Serialize(args)));
|
||||||
|
|
||||||
|
public sealed record SentFrame(string Target, IReadOnlyList<string> Excluded, string Method, string Payload)
|
||||||
|
{
|
||||||
|
public JsonElement Envelope => JsonDocument.Parse(Payload).RootElement;
|
||||||
|
public string? Id => Envelope.TryGetProperty("Id", out var id) ? id.GetString() : null;
|
||||||
|
public JsonElement Msg => Envelope.GetProperty("Msg");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingClients(CapturingHubContext owner) : IHubClients
|
||||||
|
{
|
||||||
|
private IClientProxy Proxy(string target, IReadOnlyList<string>? excluded = null) =>
|
||||||
|
new CapturingProxy(owner, target, excluded);
|
||||||
|
|
||||||
|
public IClientProxy All => Proxy("all");
|
||||||
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy("all", excludedConnectionIds);
|
||||||
|
public IClientProxy Client(string connectionId) => Proxy($"conn:{connectionId}");
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy($"conns:{string.Join(",", connectionIds)}");
|
||||||
|
public IClientProxy Group(string groupName) => Proxy($"group:{groupName}");
|
||||||
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) =>
|
||||||
|
Proxy($"group:{groupName}", excludedConnectionIds);
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => new FanOutProxy(groupNames.Select(g => Proxy($"group:{g}")).ToList());
|
||||||
|
public IClientProxy User(string userId) => Proxy($"user:{userId}");
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy($"users:{string.Join(",", userIds)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingProxy(CapturingHubContext owner, string target, IReadOnlyList<string>? excluded) : IClientProxy
|
||||||
|
{
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
owner.Record(target, excluded, method, args);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FanOutProxy(IReadOnlyList<IClientProxy> proxies) : IClientProxy
|
||||||
|
{
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.WhenAll(proxies.Select(p => p.SendCoreAsync(method, args, cancellationToken)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoopGroups : IGroupManager
|
||||||
|
{
|
||||||
|
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using DorkNet.Server.Controllers.Site;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>The site's downloads proxy: off without config, only on the site's
|
||||||
|
/// own host, serves only files the manifest lists, and passes Range through so
|
||||||
|
/// the browser Quest installer can read an .xapk in place.</summary>
|
||||||
|
public sealed class DownloadsControllerTests
|
||||||
|
{
|
||||||
|
private const string Apex = "dorknet.example.com";
|
||||||
|
|
||||||
|
private const string Manifest = """
|
||||||
|
{"releases":[{"id":"2023.03","title":"March 2023","files":[
|
||||||
|
{"id":"quest","platform":"quest","name":"DorkNet-2023.03-Quest.xapk","path":"2023.03/DorkNet-2023.03-Quest.xapk","size":10,"sha256":"x"}]}]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Without_a_base_url_the_manifest_is_404()
|
||||||
|
{
|
||||||
|
var (controller, _) = Create(baseUrl: null, _ => throw new InvalidOperationException("no request expected"));
|
||||||
|
|
||||||
|
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("api.dorknet.example.com")]
|
||||||
|
[InlineData("cdn.dorknet.example.com")]
|
||||||
|
public async Task Other_hosts_get_404_without_touching_the_bucket(string host)
|
||||||
|
{
|
||||||
|
var (controller, ctx) = Create("http://bucket.internal:3902", _ => throw new InvalidOperationException("no request expected"));
|
||||||
|
ctx.Request.Host = new HostString(host);
|
||||||
|
|
||||||
|
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
|
||||||
|
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
|
||||||
|
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Only_listed_paths_are_found()
|
||||||
|
{
|
||||||
|
var manifest = (JsonObject)JsonNode.Parse(Manifest)!;
|
||||||
|
|
||||||
|
Assert.NotNull(DownloadsService.FindFile(manifest, "2023.03/DorkNet-2023.03-Quest.xapk"));
|
||||||
|
Assert.Null(DownloadsService.FindFile(manifest, "manifest.json"));
|
||||||
|
Assert.Null(DownloadsService.FindFile(manifest, "2023.03/../manifest.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unlisted_file_is_404_and_never_fetched()
|
||||||
|
{
|
||||||
|
var requested = new List<string>();
|
||||||
|
var (controller, ctx) = Create("http://bucket.internal:3902", req =>
|
||||||
|
{
|
||||||
|
requested.Add(req.RequestUri!.AbsolutePath);
|
||||||
|
return Json(Manifest);
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.GetFile("index.html", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
|
||||||
|
Assert.Equal(["/manifest.json"], requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Range_is_forwarded_and_the_partial_response_streamed()
|
||||||
|
{
|
||||||
|
HttpRequestMessage? fileRequest = null;
|
||||||
|
var (controller, ctx) = Create("http://bucket.internal:3902/", req =>
|
||||||
|
{
|
||||||
|
if (req.RequestUri!.AbsolutePath == "/manifest.json") return Json(Manifest);
|
||||||
|
fileRequest = req;
|
||||||
|
var res = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent("abcd"u8.ToArray()) };
|
||||||
|
res.Content.Headers.ContentRange = new ContentRangeHeaderValue(2, 5, 10);
|
||||||
|
return res;
|
||||||
|
}, host: "downloads.example.com");
|
||||||
|
ctx.Request.Headers.Range = "bytes=2-5";
|
||||||
|
|
||||||
|
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(fileRequest);
|
||||||
|
Assert.Equal("/2023.03/DorkNet-2023.03-Quest.xapk", fileRequest!.RequestUri!.AbsolutePath);
|
||||||
|
Assert.Equal("bytes=2-5", fileRequest.Headers.Range!.ToString());
|
||||||
|
Assert.Equal("downloads.example.com", fileRequest.Headers.Host);
|
||||||
|
Assert.Equal(StatusCodes.Status206PartialContent, ctx.Response.StatusCode);
|
||||||
|
Assert.Equal("bytes 2-5/10", ctx.Response.Headers.ContentRange.ToString());
|
||||||
|
Assert.Equal("bytes", ctx.Response.Headers.AcceptRanges.ToString());
|
||||||
|
Assert.Equal("abcd", Encoding.ASCII.GetString(((MemoryStream)ctx.Response.Body).ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (DownloadsController, HttpContext) Create(
|
||||||
|
string? baseUrl, Func<HttpRequestMessage, HttpResponseMessage> handler, string? host = null)
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["DorkNet:Downloads:BaseUrl"] = baseUrl,
|
||||||
|
["DorkNet:Downloads:Host"] = host,
|
||||||
|
}).Build();
|
||||||
|
var service = new DownloadsService(config, new StubFactory(handler), NullLogger<DownloadsService>.Instance);
|
||||||
|
var ctx = new DefaultHttpContext();
|
||||||
|
ctx.Request.Host = new HostString(Apex);
|
||||||
|
ctx.Response.Body = new MemoryStream();
|
||||||
|
var controller = new DownloadsController(service, new DomainConfig(Apex))
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext { HttpContext = ctx },
|
||||||
|
};
|
||||||
|
return (controller, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpResponseMessage Json(string body) =>
|
||||||
|
new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
||||||
|
|
||||||
|
private sealed class StubFactory(Func<HttpRequestMessage, HttpResponseMessage> handler) : IHttpClientFactory
|
||||||
|
{
|
||||||
|
public HttpClient CreateClient(string name) => new(new StubHandler(handler));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||||
|
=> Task.FromResult(handler(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DorkNet.Server.Controllers.API.Inventory;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
using DorkNet.Server.Hubs;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Push frames checked against the 2023-03-21 client's notification readers
|
||||||
|
/// (RecNet.Runtime). Every push is the hub method "Notification" with one
|
||||||
|
/// JSON string <c>{"Id":"<key>","Msg":{…}}</c>; the client looks the Id
|
||||||
|
/// up case-sensitively and parses Msg strictly into the handler's DTO, so a
|
||||||
|
/// wrong key or shape drops the push without any error.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NotificationContractTests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
private readonly DorkNetServerFactory _factory;
|
||||||
|
|
||||||
|
public NotificationContractTests(DorkNetServerFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Friend_request_pushes_each_side_its_own_relationship_and_an_inbox_message()
|
||||||
|
{
|
||||||
|
var (factory, hub) = WithCapturingHub();
|
||||||
|
using var setup = ApiClient(factory);
|
||||||
|
var sender = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
|
using var client = ApiClient(factory, sender);
|
||||||
|
(await client.PostAsync($"/api/relationships/v2/sendfriendrequest?id={target.PlayerId}", null))
|
||||||
|
.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
// RelationshipChanged "1" = the Relationship row from each side's view.
|
||||||
|
var toTarget = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1"));
|
||||||
|
Assert.Equal("Notification", toTarget.Method);
|
||||||
|
Assert.Equal(sender.PlayerId, toTarget.Msg.GetProperty("PlayerID").GetInt64());
|
||||||
|
Assert.Equal(2, toTarget.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestReceived
|
||||||
|
Assert.False(toTarget.Msg.TryGetProperty("Reason", out _));
|
||||||
|
|
||||||
|
var toSender = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1"));
|
||||||
|
Assert.Equal(target.PlayerId, toSender.Msg.GetProperty("PlayerID").GetInt64());
|
||||||
|
Assert.Equal(1, toSender.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestSent
|
||||||
|
|
||||||
|
// MessageReceived "2" — the "X sent you a friend request" inbox row.
|
||||||
|
var invite = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "2"));
|
||||||
|
Assert.Equal(4, invite.Msg.GetProperty("Type").GetInt32()); // FriendInvite
|
||||||
|
Assert.Equal(sender.PlayerId, invite.Msg.GetProperty("FromPlayerId").GetInt64());
|
||||||
|
|
||||||
|
using var targetClient = ApiClient(factory, target);
|
||||||
|
(await targetClient.PostAsync($"/api/relationships/v2/acceptfriendrequest?id={sender.PlayerId}", null))
|
||||||
|
.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1")[^1]
|
||||||
|
.Msg.GetProperty("RelationshipType").GetInt32());
|
||||||
|
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1")[^1]
|
||||||
|
.Msg.GetProperty("RelationshipType").GetInt32());
|
||||||
|
var accepted = hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "2")[^1];
|
||||||
|
Assert.Equal(40, accepted.Msg.GetProperty("Type").GetInt32()); // FriendRequestAccepted
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Favorite_pushes_the_callers_updated_row_to_the_caller_only()
|
||||||
|
{
|
||||||
|
var (factory, hub) = WithCapturingHub();
|
||||||
|
using var setup = ApiClient(factory);
|
||||||
|
var me = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
var other = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
|
using var client = ApiClient(factory, me);
|
||||||
|
(await client.GetAsync($"/api/relationships/v1/favorite?id={other.PlayerId}")).EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(me.PlayerId), "1"));
|
||||||
|
Assert.Equal(other.PlayerId, frame.Msg.GetProperty("PlayerID").GetInt64());
|
||||||
|
Assert.Equal(1, frame.Msg.GetProperty("Favorited").GetInt32());
|
||||||
|
Assert.Empty(hub.To(CapturingHubContext.PlayerGroup(other.PlayerId), "1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Kick_is_the_flat_moderation_block_with_the_reason_in_VoteKickReason()
|
||||||
|
{
|
||||||
|
var (factory, hub) = WithCapturingHub();
|
||||||
|
await using var scope = factory.Services.CreateAsyncScope();
|
||||||
|
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||||
|
|
||||||
|
await notifications.KickPlayerAsync(42, "Too loud", gameSessionId: 777);
|
||||||
|
|
||||||
|
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(42), "22"));
|
||||||
|
var msg = frame.Msg;
|
||||||
|
// The handler only shows a reason for ReportCategory 10 + !IsHostKick.
|
||||||
|
Assert.Equal(10, msg.GetProperty("ReportCategory").GetInt32());
|
||||||
|
Assert.False(msg.GetProperty("IsHostKick").GetBoolean());
|
||||||
|
Assert.Equal("Too loud", msg.GetProperty("VoteKickReason").GetString());
|
||||||
|
Assert.Equal(777, msg.GetProperty("GameSessionId").GetInt64());
|
||||||
|
Assert.Equal(0, msg.GetProperty("Duration").GetInt32());
|
||||||
|
Assert.False(msg.TryGetProperty("Reason", out _)); // the old nested 2020 shape
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(NotificationService.LogoutReason.SessionTakeover, 2)]
|
||||||
|
[InlineData(NotificationService.LogoutReason.ForciblyLoggedOut, 3)]
|
||||||
|
[InlineData(NotificationService.LogoutReason.Banned, 4)]
|
||||||
|
public async Task Logout_reason_is_the_numeric_enum(NotificationService.LogoutReason reason, int wire)
|
||||||
|
{
|
||||||
|
var (factory, hub) = WithCapturingHub();
|
||||||
|
await using var scope = factory.Services.CreateAsyncScope();
|
||||||
|
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||||
|
|
||||||
|
await notifications.LogoutAsync(43, reason);
|
||||||
|
|
||||||
|
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(43), "6"));
|
||||||
|
var value = frame.Msg.GetProperty("Reason");
|
||||||
|
Assert.Equal(JsonValueKind.Number, value.ValueKind);
|
||||||
|
Assert.Equal(wire, value.GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Presence_for_subscribers_goes_to_the_subjects_presence_group_minus_excluded_connections()
|
||||||
|
{
|
||||||
|
var (factory, hub) = WithCapturingHub();
|
||||||
|
await using var scope = factory.Services.CreateAsyncScope();
|
||||||
|
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||||
|
|
||||||
|
await notifications.PresenceToSubscribersAsync(55, room: null, isOnline: true, statusVisibility: 0,
|
||||||
|
excludeConnectionIds: ["self-conn", "friend-conn"]);
|
||||||
|
|
||||||
|
var frame = Assert.Single(hub.To($"group:{NotifyHub.PresenceGroupForPlayer(55)}", "PresenceUpdate"));
|
||||||
|
Assert.Equal(["self-conn", "friend-conn"], frame.Excluded);
|
||||||
|
Assert.Equal(55, frame.Msg.GetProperty("playerId").GetInt32());
|
||||||
|
Assert.True(frame.Msg.GetProperty("isOnline").GetBoolean());
|
||||||
|
Assert.Equal(JsonValueKind.String, frame.Msg.GetProperty("appVersion").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"PlayerIds":[1,2,3]}""", new long[] { 1, 2, 3 })]
|
||||||
|
[InlineData("""{"playerIds":["4",5]}""", new long[] { 4, 5 })]
|
||||||
|
[InlineData("""[6,7]""", new long[] { 6, 7 })]
|
||||||
|
[InlineData("""{"PlayerIds":[]}""", new long[0])]
|
||||||
|
public void SubscribeToPlayers_payload_is_read_in_any_casing(string json, long[] expected)
|
||||||
|
{
|
||||||
|
Assert.True(NotifyHub.TryReadPlayerIds(JsonDocument.Parse(json).RootElement, out var ids));
|
||||||
|
Assert.Equal(expected.OrderBy(x => x), ids.OrderBy(x => x));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unreadable_SubscribeToPlayers_payload_is_rejected_not_read_as_empty()
|
||||||
|
{
|
||||||
|
Assert.False(NotifyHub.TryReadPlayerIds(JsonDocument.Parse("""{"Channels":[]}""").RootElement, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Consumable_mapping_push_has_exactly_the_clients_keys()
|
||||||
|
{
|
||||||
|
var wire = JsonSerializer.SerializeToElement(InventoryController.ConsumableMappingWire(new PlayerInventoryEntity
|
||||||
|
{
|
||||||
|
Id = 9,
|
||||||
|
ItemSlug = "some-consumable",
|
||||||
|
Quantity = 3,
|
||||||
|
}));
|
||||||
|
|
||||||
|
var keys = wire.EnumerateObject().Select(p => p.Name).ToHashSet();
|
||||||
|
Assert.Equal(
|
||||||
|
new HashSet<string> { "Id", "ConsumableItemDesc", "Count", "InitialCount", "CreatedAt", "ActiveDurationMinutes", "IsActive", "IsTransferable" },
|
||||||
|
keys);
|
||||||
|
Assert.Equal(JsonValueKind.String, wire.GetProperty("ConsumableItemDesc").ValueKind);
|
||||||
|
Assert.Equal(3, wire.GetProperty("Count").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
private (WebApplicationFactory<Program> Factory, CapturingHubContext Hub) WithCapturingHub()
|
||||||
|
{
|
||||||
|
var hub = new CapturingHubContext();
|
||||||
|
var factory = _factory.WithWebHostBuilder(builder =>
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.RemoveAll<IHubContext<NotifyHub>>();
|
||||||
|
services.AddSingleton<IHubContext<NotifyHub>>(hub);
|
||||||
|
}));
|
||||||
|
return (factory, hub);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient ApiClient(WebApplicationFactory<Program> factory, GameClientSession? session = null)
|
||||||
|
{
|
||||||
|
var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||||
|
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||||
|
if (session is not null)
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ public sealed class PrivateRoomInstanceTests : IClassFixture<DorkNetServerFactor
|
|||||||
var text = await resp.Content.ReadAsStringAsync();
|
var text = await resp.Content.ReadAsStringAsync();
|
||||||
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
|
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
|
||||||
using var json = JsonDocument.Parse(text);
|
using var json = JsonDocument.Parse(text);
|
||||||
var cloneId = json.RootElement.GetProperty("RoomId").GetInt64();
|
var cloneId = json.RootElement.GetProperty("value").GetProperty("RoomId").GetInt64();
|
||||||
|
|
||||||
await using var scope = _factory.Services.CreateAsyncScope();
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
namespace DorkNet.Server.Tests;
|
namespace DorkNet.Server.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and deserialises the
|
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and reads the reply
|
||||||
/// reply into <c>FGCPNAACHIK</c> — the SAME type <c>GET rooms/{id}</c> returns
|
/// as the <c>{success, value, error}</c> envelope (the same RecNet rooms helper
|
||||||
/// (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
|
/// as "save room"), whose <c>value</c> is <c>FGCPNAACHIK</c> — the SAME type
|
||||||
|
/// <c>GET rooms/{id}</c> returns (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
|
||||||
/// <c>Task<FGCPNAACHIK> GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
|
/// <c>Task<FGCPNAACHIK> GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
|
||||||
/// with the route literal on :4802).
|
/// with the route literal on :4802). A bare room with no <c>success</c> is
|
||||||
|
/// rejected with an empty error.
|
||||||
///
|
///
|
||||||
/// So the two responses must be shape-identical. When they drift, the server
|
/// So <c>value</c> and the GET response must be shape-identical. When they drift, the server
|
||||||
/// still answers 200 and the client throws while reading it — and because the
|
/// still answers 200 and the client throws while reading it — and because the
|
||||||
/// clone promise's exception is never observed, it surfaces from the finalizer
|
/// clone promise's exception is never observed, it surfaces from the finalizer
|
||||||
/// thread seconds later as a bare "Failed to copy room: Exception of type
|
/// thread seconds later as a bare "Failed to copy room: Exception of type
|
||||||
@@ -63,8 +65,12 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
|||||||
}
|
}
|
||||||
|
|
||||||
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
|
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
|
||||||
var cloneBody = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
var cloneEnvelope = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
||||||
|
// The client reads clone through the {success, value, error} envelope.
|
||||||
|
Assert.True(cloneEnvelope.GetProperty("success").GetBoolean(), cloneEnvelope.ToString());
|
||||||
|
Assert.Equal("", cloneEnvelope.GetProperty("error").GetString());
|
||||||
|
var cloneBody = cloneEnvelope.GetProperty("value");
|
||||||
|
|
||||||
var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||||
var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||||
@@ -157,7 +163,7 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
|||||||
|
|
||||||
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
||||||
var cloneId = clone.GetProperty("RoomId").GetInt64();
|
var cloneId = clone.GetProperty("value").GetProperty("RoomId").GetInt64();
|
||||||
|
|
||||||
await using (var scope = factory.Services.CreateAsyncScope())
|
await using (var scope = factory.Services.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
@@ -203,6 +209,24 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
|||||||
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
|
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Clone_refusal_is_a_200_envelope_with_a_message()
|
||||||
|
{
|
||||||
|
using var client = Client();
|
||||||
|
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||||
|
client.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||||
|
|
||||||
|
// No room has this id, so the clone is refused; the client shows
|
||||||
|
// `error`, which a bare 404 would leave empty.
|
||||||
|
var envelope = await ReadJsonAsync(client, HttpMethod.Post, "/rooms/987654321987/clone",
|
||||||
|
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", "NoSuchSource")]));
|
||||||
|
|
||||||
|
Assert.False(envelope.GetProperty("success").GetBoolean());
|
||||||
|
Assert.False(string.IsNullOrEmpty(envelope.GetProperty("error").GetString()));
|
||||||
|
Assert.Equal(JsonValueKind.Null, envelope.GetProperty("value").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<JsonElement> ReadJsonAsync(
|
private static async Task<JsonElement> ReadJsonAsync(
|
||||||
HttpClient client, HttpMethod method, string path, HttpContent? content = null)
|
HttpClient client, HttpMethod method, string path, HttpContent? content = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -267,8 +267,10 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
|||||||
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
|
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
|
||||||
|
|
||||||
using var json = JsonDocument.Parse(text);
|
using var json = JsonDocument.Parse(text);
|
||||||
var root = json.RootElement;
|
// {success, value, error} envelope around the full room-details
|
||||||
// Full room-details shape (FGCPNAACHIK), not a status wrapper.
|
// shape (FGCPNAACHIK).
|
||||||
|
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), text);
|
||||||
|
var root = json.RootElement.GetProperty("value");
|
||||||
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
|
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
|
||||||
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
|
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
|
||||||
return root.GetProperty("RoomId").GetInt64();
|
return root.GetProperty("RoomId").GetInt64();
|
||||||
|
|||||||
@@ -56,9 +56,12 @@ public class FreeGiftsController(DorkNetDbContext db, NotificationService notifi
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
foreach (var row in created)
|
foreach (var row in created)
|
||||||
{
|
{
|
||||||
|
// The full gift package, as GET api/avatar/v2/gifts lists it: the
|
||||||
|
// client consumes a gift by its Id, which the old hand-built
|
||||||
|
// payload didn't carry.
|
||||||
await notifications.NotifyAsync(row.RecipientPlayerId,
|
await notifications.NotifyAsync(row.RecipientPlayerId,
|
||||||
PushNotificationId.GiftPackageReceived,
|
PushNotificationId.GiftPackageReceived,
|
||||||
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message });
|
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(row.Gift));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new { Success = true, Sent = created.Count });
|
return Ok(new { Success = true, Sent = created.Count });
|
||||||
|
|||||||
@@ -1295,39 +1295,14 @@ public class ImagesController(
|
|||||||
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
||||||
.Where(v => v > 0);
|
.Where(v => v > 0);
|
||||||
|
|
||||||
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
|
/// <summary>Push the new profile image through the same account pushes
|
||||||
{
|
/// as every other profile edit: the owner's SelfAccountUpdate and an
|
||||||
var account = new DorkNet.Models.Auth.RecNetAccount
|
/// AccountUpdate to everyone. A hand-built account here left out the
|
||||||
{
|
/// emoji, banner, pronouns, identity flags and creation date, so an image
|
||||||
AccountId = (int)player.Id,
|
/// change wiped them from the owner's cached account, and nobody else saw
|
||||||
RawUsername = player.Username,
|
/// the new picture.</summary>
|
||||||
Username = player.Username,
|
private Task NotifyAccountImageChangedAsync(PlayerEntity player) =>
|
||||||
DisplayName = player.DisplayName ?? player.Username,
|
notifications.AccountChanged(player);
|
||||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
|
||||||
TreatAsJunior = player.IsJunior,
|
|
||||||
HasBirthday = true,
|
|
||||||
Platforms = 1,
|
|
||||||
};
|
|
||||||
var selfAccount = new DorkNet.Models.Auth.RecNetSelfAccount
|
|
||||||
{
|
|
||||||
AccountId = account.AccountId,
|
|
||||||
RawUsername = account.RawUsername,
|
|
||||||
Username = account.Username,
|
|
||||||
DisplayName = account.DisplayName,
|
|
||||||
ProfileImage = account.ProfileImage,
|
|
||||||
TreatAsJunior = account.TreatAsJunior,
|
|
||||||
HasBirthday = account.HasBirthday,
|
|
||||||
Platforms = account.Platforms,
|
|
||||||
Email = player.Email ?? string.Empty,
|
|
||||||
Phone = player.Phone ?? string.Empty,
|
|
||||||
Birthday = player.Birthday,
|
|
||||||
JuniorState = player.IsJunior ? 1 : 0,
|
|
||||||
ParentAccountId = null,
|
|
||||||
};
|
|
||||||
return Task.WhenAll(
|
|
||||||
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
|
|
||||||
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormaliseTaggedIds(string? csv, long exceptId)
|
private static string NormaliseTaggedIds(string? csv, long exceptId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1095,11 +1095,9 @@ public class InventionsController(
|
|||||||
});
|
});
|
||||||
inv.CheerCount += 1;
|
inv.CheerCount += 1;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
// No push: InventionModerationStateChanged ("100") means "your
|
||||||
if (inv.CreatorPlayerId != pid)
|
// inventions' moderation state changed" and takes no payload; sent
|
||||||
await notifications.NotifyAsync(inv.CreatorPlayerId,
|
// on every cheer it only made the creator refetch for nothing.
|
||||||
PushNotificationId.InventionModerationStateChanged,
|
|
||||||
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
|
|
||||||
}
|
}
|
||||||
else if (!req.Cheer && existing is not null)
|
else if (!req.Cheer && existing is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -279,11 +279,11 @@ public class InventoryController(
|
|||||||
if (row.Quantity == 0) db.PlayerInventory.Remove(row);
|
if (row.Quantity == 0) db.PlayerInventory.Remove(row);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// A consume always changes an existing mapping, so it is always
|
||||||
|
// ConsumableMappingRemoved (71) carrying the count that is left;
|
||||||
|
// Added (70) would make the client treat it as a new stack.
|
||||||
await notifications.NotifyAsync(pid,
|
await notifications.NotifyAsync(pid,
|
||||||
row.Quantity > 0
|
PushNotificationId.ConsumableMappingRemoved, ConsumableMappingWire(row));
|
||||||
? PushNotificationId.ConsumableMappingAdded
|
|
||||||
: PushNotificationId.ConsumableMappingRemoved,
|
|
||||||
new { row.Id, row.ItemSlug, row.Quantity });
|
|
||||||
return Ok(new { row.Id, Count = row.Quantity });
|
return Ok(new { row.Id, Count = row.Quantity });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,8 +332,7 @@ public class InventoryController(
|
|||||||
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
|
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await notifications.NotifyAsync(recipient,
|
await notifications.NotifyAsync(recipient,
|
||||||
PushNotificationId.ConsumableMappingAdded,
|
PushNotificationId.ConsumableMappingAdded, ConsumableMappingWire(target));
|
||||||
new { target.Id, target.ItemSlug, target.Quantity });
|
|
||||||
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
|
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +448,26 @@ public class InventoryController(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>ConsumableMapping push payload ("70" Added / "71" Removed):
|
||||||
|
/// exactly the client's mapping DTO <c>CGCALDBHLGD</c> (formatter
|
||||||
|
/// <c>JICIOMDBPDO</c>) — Id (Int64), ConsumableItemDesc (STRING, not an
|
||||||
|
/// object), Count, InitialCount, CreatedAt, ActiveDurationMinutes (Int32?),
|
||||||
|
/// IsActive, IsTransferable. The old <c>{Id, ItemSlug, Quantity}</c> parsed
|
||||||
|
/// as a mapping with no item and a count of 0.</summary>
|
||||||
|
public static object ConsumableMappingWire(PlayerInventoryEntity inv) => new
|
||||||
|
{
|
||||||
|
inv.Id,
|
||||||
|
ConsumableItemDesc = StoreService.TryGetConsumableItemDesc(inv.ItemSlug, out var desc) ? desc : inv.ItemSlug,
|
||||||
|
Count = inv.Quantity,
|
||||||
|
InitialCount = inv.Quantity, // the original purchase count isn't tracked
|
||||||
|
CreatedAt = inv.AcquiredAt,
|
||||||
|
ActiveDurationMinutes = (int?)null,
|
||||||
|
inv.IsActive,
|
||||||
|
// Same as GET api/consumables/v2/getUnlocked, so a push never flips
|
||||||
|
// what the inventory list says.
|
||||||
|
IsTransferable = false,
|
||||||
|
};
|
||||||
|
|
||||||
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
|
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
|
||||||
{
|
{
|
||||||
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
|
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
|
||||||
|
|||||||
@@ -152,8 +152,6 @@ public class InGameModerationController(
|
|||||||
foreach (var target in ids)
|
foreach (var target in ids)
|
||||||
{
|
{
|
||||||
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
|
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
|
||||||
// Push the ModerationKick shape the 2020.12 client expects:
|
|
||||||
// Msg.Reason must be a ModerationBlockDetail object.
|
|
||||||
await notifications.KickPlayerAsync(target, "Instant kick by moderator.");
|
await notifications.KickPlayerAsync(target, "Instant kick by moderator.");
|
||||||
}
|
}
|
||||||
return Ok(new { success = true, error = "" });
|
return Ok(new { success = true, error = "" });
|
||||||
@@ -249,21 +247,14 @@ public class InGameModerationController(
|
|||||||
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
|
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
|
||||||
caller, target, reason, p.BannedUntil, displayReason);
|
caller, target, reason, p.BannedUntil, displayReason);
|
||||||
|
|
||||||
// Push the ban as a ModerationKick (22) with IsBan set, NOT as
|
// Sign the banned player out with Logout "6" (Reason Banned). Not
|
||||||
// ModerationRoomBan (24): the 2023-03-21 client's moderation manager
|
// ModerationRoomBan (24): the 2023-03-21 client registers no handler
|
||||||
// registers only 22, "ModerationUnkick" and 23
|
// for it (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335 register only
|
||||||
// (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler
|
// 22, "ModerationUnkick" and 23), so that push landed nowhere. Not a
|
||||||
// for 24, so that push landed nowhere and the banned player kept playing
|
// ModerationKick either: a kick only sends the player to their dorm
|
||||||
// until their next login. The 22 handler reads Msg.Reason as a
|
// and they kept playing until their next login. The ban check refuses
|
||||||
// ModerationBlockDetail, which ModerationKickPayload already produces.
|
// that next login.
|
||||||
await notifications.KickPlayerAsync(
|
await notifications.LogoutAsync(target, NotificationService.LogoutReason.Banned);
|
||||||
target,
|
|
||||||
// `reason` is the numeric report category; DisplayReason is the
|
|
||||||
// moderator's text and is what the ban dialog shows.
|
|
||||||
string.IsNullOrWhiteSpace(displayReason)
|
|
||||||
? "You have been banned from this room."
|
|
||||||
: displayReason.Trim(),
|
|
||||||
isBan: true);
|
|
||||||
return Ok(new { success = true, error = "" });
|
return Ok(new { success = true, error = "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ namespace DorkNet.Server.Controllers.API.PlayerEvents;
|
|||||||
public class PlayerEventsController(
|
public class PlayerEventsController(
|
||||||
DorkNetDbContext db,
|
DorkNetDbContext db,
|
||||||
NotificationService notifications,
|
NotificationService notifications,
|
||||||
|
SystemNotificationService systemNotifications,
|
||||||
PlayerPresenceService presence) : ControllerBase
|
PlayerPresenceService presence) : ControllerBase
|
||||||
{
|
{
|
||||||
private long Me => this.RequireCurrentPlayerId();
|
private long Me => this.RequireCurrentPlayerId();
|
||||||
@@ -817,7 +818,7 @@ public class PlayerEventsController(
|
|||||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||||
await ApplyBodyToExtrasAsync(extras);
|
await ApplyBodyToExtrasAsync(extras);
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated);
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,10 +902,7 @@ public class PlayerEventsController(
|
|||||||
|
|
||||||
// Tell the creator someone RSVP'd. They can ignore but the
|
// Tell the creator someone RSVP'd. They can ignore but the
|
||||||
// watch will refresh their event detail card.
|
// watch will refresh their event detail card.
|
||||||
if (ev.CreatorPlayerId != Me)
|
await NotifyResponseChangedAsync(ev, row);
|
||||||
await notifications.NotifyAsync(ev.CreatorPlayerId,
|
|
||||||
PushNotificationId.PlayerEventResponseChanged,
|
|
||||||
new { ev.Id, From = Me, body.Response });
|
|
||||||
// Left as the historical shape on purpose: this route is 2020-only
|
// Left as the historical shape on purpose: this route is 2020-only
|
||||||
// (the 2023 client GETs .../responses and POSTs v1/respond instead),
|
// (the 2023 client GETs .../responses and POSTs v1/respond instead),
|
||||||
// so there is no 2023 DTO to match here.
|
// so there is no 2023 DTO to match here.
|
||||||
@@ -947,10 +945,15 @@ public class PlayerEventsController(
|
|||||||
var existing = await db.PlayerEventResponses
|
var existing = await db.PlayerEventResponses
|
||||||
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
|
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response });
|
{
|
||||||
|
existing = new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response };
|
||||||
|
db.PlayerEventResponses.Add(existing);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
existing.Response = response;
|
existing.Response = response;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
var responded = await db.PlayerEvents.FirstAsync(e => e.Id == evt);
|
||||||
|
await NotifyResponseChangedAsync(responded, existing);
|
||||||
return RespondResult(legacyForm, 0 /*Success*/);
|
return RespondResult(legacyForm, 0 /*Success*/);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,9 +971,18 @@ public class PlayerEventsController(
|
|||||||
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
||||||
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
||||||
|
|
||||||
await db.PlayerEventResponses
|
var deleted = await db.PlayerEventResponses
|
||||||
.Where(r => r.PlayerId == Me && r.EventId == evt)
|
.Where(r => r.PlayerId == Me && r.EventId == evt)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
if (deleted > 0)
|
||||||
|
{
|
||||||
|
// PlayerEventResponseDeleted "84" carries only PlayerEventId.
|
||||||
|
var creator = await db.PlayerEvents.Where(e => e.Id == evt)
|
||||||
|
.Select(e => (long?)e.CreatorPlayerId).FirstOrDefaultAsync();
|
||||||
|
if (creator is long c && c != Me)
|
||||||
|
await notifications.NotifyAsync(c, PushNotificationId.PlayerEventResponseDeleted,
|
||||||
|
new { PlayerEventId = evt });
|
||||||
|
}
|
||||||
return RespondResult(legacyForm, 0 /*Success*/);
|
return RespondResult(legacyForm, 0 /*Success*/);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -993,6 +1005,7 @@ public class PlayerEventsController(
|
|||||||
// has to echo the event that was deleted.
|
// has to echo the event that was deleted.
|
||||||
var extras = (await LoadExtrasAsync(new[] { evt }))[id];
|
var extras = (await LoadExtrasAsync(new[] { evt }))[id];
|
||||||
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
|
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
|
||||||
|
var notify = await AttendeeIdsAsync(evt);
|
||||||
|
|
||||||
db.PlayerEvents.Remove(evt);
|
db.PlayerEvents.Remove(evt);
|
||||||
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
|
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
|
||||||
@@ -1000,6 +1013,7 @@ public class PlayerEventsController(
|
|||||||
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
|
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await NotifyEventDeletedAsync(id, notify);
|
||||||
return Ok(Wrapper(evt, extras, attendees));
|
return Ok(Wrapper(evt, extras, attendees));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,6 +1035,7 @@ public class PlayerEventsController(
|
|||||||
evt.Title = Clamp(value.Trim(), 128);
|
evt.Title = Clamp(value.Trim(), 128);
|
||||||
evt.UpdatedAt = DateTime.UtcNow;
|
evt.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,6 +1049,7 @@ public class PlayerEventsController(
|
|||||||
evt.Description = Clamp(value.Trim(), 2000);
|
evt.Description = Clamp(value.Trim(), 2000);
|
||||||
evt.UpdatedAt = DateTime.UtcNow;
|
evt.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,6 +1072,7 @@ public class PlayerEventsController(
|
|||||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||||
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
|
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1072,6 +1089,7 @@ public class PlayerEventsController(
|
|||||||
if (start is null && end is null) return BadRequest("missing_time");
|
if (start is null && end is null) return BadRequest("missing_time");
|
||||||
evt.UpdatedAt = DateTime.UtcNow;
|
evt.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1085,6 +1103,7 @@ public class PlayerEventsController(
|
|||||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||||
extras.ImageName = Clamp(value.Trim(), 128);
|
extras.ImageName = Clamp(value.Trim(), 128);
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1099,6 +1118,7 @@ public class PlayerEventsController(
|
|||||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||||
extras.Accessibility = accessibility;
|
extras.Accessibility = accessibility;
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,6 +1140,7 @@ public class PlayerEventsController(
|
|||||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||||
extras.Tags = tags;
|
extras.Tags = tags;
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1158,6 +1179,7 @@ public class PlayerEventsController(
|
|||||||
?? extras.CanRequestBroadcastPermissions;
|
?? extras.CanRequestBroadcastPermissions;
|
||||||
|
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1188,6 +1210,7 @@ public class PlayerEventsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1197,6 +1220,7 @@ public class PlayerEventsController(
|
|||||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||||
if (ev is null) return NotFound();
|
if (ev is null) return NotFound();
|
||||||
if (ev.CreatorPlayerId != Me) return Forbid();
|
if (ev.CreatorPlayerId != Me) return Forbid();
|
||||||
|
var notify = await AttendeeIdsAsync(ev);
|
||||||
db.PlayerEvents.Remove(ev);
|
db.PlayerEvents.Remove(ev);
|
||||||
// Cascade-clean the responses; SQLite has no FK cascade
|
// Cascade-clean the responses; SQLite has no FK cascade
|
||||||
// configured here so do it manually.
|
// configured here so do it manually.
|
||||||
@@ -1206,6 +1230,7 @@ public class PlayerEventsController(
|
|||||||
await db.PlayerSettings
|
await db.PlayerSettings
|
||||||
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
|
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
await NotifyEventDeletedAsync(eventId, notify);
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1244,6 +1269,7 @@ public class PlayerEventsController(
|
|||||||
var failed = new List<object>();
|
var failed = new List<object>();
|
||||||
var sender = Me;
|
var sender = Me;
|
||||||
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
|
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
|
||||||
|
var eventWire = await ToWireOneAsync(ev);
|
||||||
foreach (var rid in req.InvitedPlayerIds.Distinct())
|
foreach (var rid in req.InvitedPlayerIds.Distinct())
|
||||||
{
|
{
|
||||||
var exists = await db.Players.AnyAsync(p => p.Id == rid);
|
var exists = await db.Players.AnyAsync(p => p.Id == rid);
|
||||||
@@ -1252,17 +1278,15 @@ public class PlayerEventsController(
|
|||||||
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
|
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
db.Messages.Add(new MessageEntity
|
// The invite as a text message from the host, stored and pushed
|
||||||
{
|
// live (it used to be stored only, so it appeared after a relog).
|
||||||
SenderPlayerId = sender,
|
await systemNotifications.SendAsync(rid,
|
||||||
RecipientPlayerId = rid,
|
SystemNotificationService.MessageType.TextMessage, fromPlayerId: sender, data: inviteText);
|
||||||
Body = inviteText,
|
// And the event itself, so it shows in the invitee's event list:
|
||||||
});
|
// PlayerEventCreated "80" carries the full event (MDCBEPJCJPO) —
|
||||||
await notifications.NotifyAsync(rid,
|
// the same object GET api/playerevents/v2/{id} returns.
|
||||||
PushNotificationId.PlayerEventCreated,
|
await notifications.NotifyAsync(rid, PushNotificationId.PlayerEventCreated, eventWire);
|
||||||
new { ev.Id, ev.Title, ev.StartsAt, From = sender });
|
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
|
||||||
return Ok(new { FailedInvites = failed, Result = 0 });
|
return Ok(new { FailedInvites = failed, Result = 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1289,21 +1313,56 @@ public class PlayerEventsController(
|
|||||||
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
|
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
|
||||||
await SaveExtrasAsync(evt, extras);
|
await SaveExtrasAsync(evt, extras);
|
||||||
|
|
||||||
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge
|
// Attendees' event cards key off BroadcastingRoomInstanceId, so send
|
||||||
// them to re-fetch instead of sending a chat message.
|
// them the updated event.
|
||||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged);
|
await NotifyEventUpdatedAsync(evt);
|
||||||
return Ok(await WrapperAsync(evt));
|
return Ok(await WrapperAsync(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id)
|
/// <summary>Everyone who answered the event with anything but NotGoing,
|
||||||
{
|
/// except its creator.</summary>
|
||||||
var recipients = await db.PlayerEventResponses
|
private async Task<List<long>> AttendeeIdsAsync(PlayerEventEntity evt) =>
|
||||||
|
(await db.PlayerEventResponses
|
||||||
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
|
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
|
||||||
.Select(r => r.PlayerId)
|
.Select(r => r.PlayerId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToListAsync();
|
.ToListAsync())
|
||||||
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId))
|
.Where(p => p != evt.CreatorPlayerId)
|
||||||
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title });
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>PlayerEventUpdated "81" to the attendees, carrying the full
|
||||||
|
/// event (MDCBEPJCJPO, formatter CENEMCMGDKG) — the same object
|
||||||
|
/// GET api/playerevents/v2/{id} returns; the client replaces its cached
|
||||||
|
/// event with it. The old <c>{PlayerEventId, Title}</c> overwrote the
|
||||||
|
/// cached event with an empty name and default times, and the
|
||||||
|
/// PlayerEventStateChanged (85) used for broadcasts has no client handler
|
||||||
|
/// at all.</summary>
|
||||||
|
private async Task NotifyEventUpdatedAsync(PlayerEventEntity evt)
|
||||||
|
{
|
||||||
|
var recipients = await AttendeeIdsAsync(evt);
|
||||||
|
if (recipients.Count == 0) return;
|
||||||
|
var wire = await ToWireOneAsync(evt);
|
||||||
|
foreach (var playerId in recipients)
|
||||||
|
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventUpdated, wire);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PlayerEventDeleted "82" ({PlayerEventId}) to the players who
|
||||||
|
/// were attending, collected before the rows were removed.</summary>
|
||||||
|
private async Task NotifyEventDeletedAsync(long eventId, IEnumerable<long> recipients)
|
||||||
|
{
|
||||||
|
foreach (var playerId in recipients)
|
||||||
|
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventDeleted,
|
||||||
|
new { PlayerEventId = eventId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PlayerEventResponseChanged "83" to the event's creator:
|
||||||
|
/// <c>{PlayerEvent, PlayerEventResponse}</c> (COAGAJPELCG), each the same
|
||||||
|
/// object the event and response REST routes return.</summary>
|
||||||
|
private async Task NotifyResponseChangedAsync(PlayerEventEntity evt, PlayerEventResponseEntity response)
|
||||||
|
{
|
||||||
|
if (evt.CreatorPlayerId == response.PlayerId) return;
|
||||||
|
await notifications.NotifyAsync(evt.CreatorPlayerId, PushNotificationId.PlayerEventResponseChanged,
|
||||||
|
new { PlayerEvent = await ToWireOneAsync(evt), PlayerEventResponse = ResponseWire(response) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Report ───────────────────────────────────────────────────────────
|
// ── Report ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ namespace DorkNet.Server.Controllers.API.Relationships.V2;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings) : ControllerBase
|
public class RelationshipsController(
|
||||||
|
DorkNetDbContext db,
|
||||||
|
NotificationService notifications,
|
||||||
|
SystemNotificationService systemNotifications,
|
||||||
|
ServerSettingsService settings) : ControllerBase
|
||||||
{
|
{
|
||||||
// RelationshipType — int values match Relationship.RelationshipType
|
// RelationshipType — int values match Relationship.RelationshipType
|
||||||
// in the patched dump.cs.
|
// in the patched dump.cs.
|
||||||
@@ -226,7 +230,9 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await notifications.FriendRequestReceived(id, me);
|
await PushPairAsync(me, id);
|
||||||
|
// The "X sent you a friend request" row in the target's notifications.
|
||||||
|
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendInvite, fromPlayerId: me);
|
||||||
return Ok(BuildRelationship(row, me));
|
return Ok(BuildRelationship(row, me));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +257,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
rel.UpdatedAt = DateTime.UtcNow;
|
rel.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await notifications.FriendRequestAccepted(id, me);
|
await PushPairAsync(me, id);
|
||||||
|
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
|
||||||
return Ok(BuildRelationship(rel, me));
|
return Ok(BuildRelationship(rel, me));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +292,7 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
|
|
||||||
db.Relationships.RemoveRange(removable);
|
db.Relationships.RemoveRange(removable);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await notifications.FriendRemoved(id, me);
|
await PushPairAsync(me, id);
|
||||||
|
|
||||||
var remaining = pair.Except(removable).ToList();
|
var remaining = pair.Except(removable).ToList();
|
||||||
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining));
|
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining));
|
||||||
@@ -333,6 +340,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
existing.UpdatedAt = DateTime.UtcNow;
|
existing.UpdatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await PushPairAsync(me, id);
|
||||||
|
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
|
||||||
return Ok(BuildRelationship(existing, me));
|
return Ok(BuildRelationship(existing, me));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +420,10 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
if (ignored is bool i) row.Ignored = i;
|
if (ignored is bool i) row.Ignored = i;
|
||||||
row.UpdatedAt = DateTime.UtcNow;
|
row.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
// The HTTP reply is only an ack; the updated row reaches the client
|
||||||
|
// through RelationshipChanged. A preference is the caller's own bit,
|
||||||
|
// so only the caller is told.
|
||||||
|
await PushPairAsync(me, otherId, notifyOther: false);
|
||||||
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
|
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +524,36 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Push RelationshipChanged for the pair (<paramref name="me"/>,
|
||||||
|
/// <paramref name="other"/>) after a change, each side getting its OWN view
|
||||||
|
/// of the relationship. Without this a friend request, accept or unfriend
|
||||||
|
/// only showed up after the friends list was refetched, and the client
|
||||||
|
/// never re-subscribed to the new friend's presence.</summary>
|
||||||
|
private async Task PushPairAsync(long me, long other, bool notifyOther = true)
|
||||||
|
{
|
||||||
|
var pair = await db.Relationships
|
||||||
|
.Where(r => (r.RequesterId == me && r.TargetId == other) ||
|
||||||
|
(r.RequesterId == other && r.TargetId == me))
|
||||||
|
.ToListAsync();
|
||||||
|
var globalFriends = await settings.IsGlobalFriendsEnabledAsync();
|
||||||
|
|
||||||
|
await notifications.RelationshipChanged(me, ViewOf(me, other, pair, globalFriends));
|
||||||
|
if (notifyOther)
|
||||||
|
await notifications.RelationshipChanged(other, ViewOf(other, me, pair, globalFriends));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The relationship from <paramref name="viewer"/>'s side, the way
|
||||||
|
/// <c>v2/get</c> would list it.</summary>
|
||||||
|
private static Dictionary<string, object> ViewOf(
|
||||||
|
long viewer, long other, List<RelationshipEntity> pair, bool globalFriends)
|
||||||
|
{
|
||||||
|
if (globalFriends
|
||||||
|
&& other != RelationshipQueries.SystemAccountId
|
||||||
|
&& !pair.Any(r => r.Status == EntityStatus.Blocked))
|
||||||
|
return SynthFriend(other, pair.FirstOrDefault(r => r.RequesterId == viewer), viewer);
|
||||||
|
return pair.Count == 0 ? EmptyRelationship(other) : BuildMerged(viewer, other, pair);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<HashSet<long>> FriendIdsAsync(long playerId)
|
private async Task<HashSet<long>> FriendIdsAsync(long playerId)
|
||||||
{
|
{
|
||||||
var rows = await db.Relationships
|
var rows = await db.Relationships
|
||||||
|
|||||||
@@ -1375,13 +1375,13 @@ public class RoomsModerationController(
|
|||||||
{
|
{
|
||||||
var newName = await ReadCloneNameAsync();
|
var newName = await ReadCloneNameAsync();
|
||||||
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
if (source is null) return NotFound();
|
if (source is null) return CloneResult(null, "That room doesn't exist.");
|
||||||
// RRO/AG rooms are first-party templates meant to be cloned — the
|
// RRO/AG rooms are first-party templates meant to be cloned — the
|
||||||
// room-creation "base room" picker starts a new build by cloning one
|
// room-creation "base room" picker starts a new build by cloning one
|
||||||
// (RecCenter, MakerRoom, …). Their seed rows have CloningAllowed=false,
|
// (RecCenter, MakerRoom, …). Their seed rows have CloningAllowed=false,
|
||||||
// so without the IsAGRoom exception every base-room create 403s.
|
// so without the IsAGRoom exception every base-room create is refused.
|
||||||
if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me)
|
if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me)
|
||||||
return Forbid();
|
return CloneResult(null, "You can't clone this room!");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(newName))
|
if (string.IsNullOrWhiteSpace(newName))
|
||||||
newName = $"{source.Name}Copy";
|
newName = $"{source.Name}Copy";
|
||||||
@@ -1517,17 +1517,22 @@ public class RoomsModerationController(
|
|||||||
|
|
||||||
await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me);
|
await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me);
|
||||||
|
|
||||||
// The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN)
|
// The value is the FULL room-details object (FGCPNAACHIK), the exact
|
||||||
// deserializes the response as the FULL room-details object
|
// shape GET rooms/{id} returns for the 2023 client — not the slim
|
||||||
// (FGCPNAACHIK — the same type get-by-id / rename return on the
|
// list-room shape, which made the strict reader fail.
|
||||||
// roomserver host), NOT the slim list-room shape. This is the
|
return CloneResult(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
|
||||||
// roomserver-family path (rooms/{id}/clone), so use
|
|
||||||
// BuildRoomServerDetails — the exact shape GET rooms/{id} returns for
|
|
||||||
// the 2023 client. Returning ToWireRoom (or the wrong details variant)
|
|
||||||
// made the strict reader fail → "Failed to copy room: Failed to clone
|
|
||||||
// room".
|
|
||||||
return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
|
|
||||||
}
|
}
|
||||||
|
/// <summary>Reply for <c>rooms/{id}/clone</c>. The 2023 client sends the
|
||||||
|
/// clone through the same RecNet rooms helper as "save room" and "restore
|
||||||
|
/// subroom save" (NLDBPDCNNCF.BDBHCEGKGDC with an op name), which reads the
|
||||||
|
/// legacy <c>{success, value, error}</c> envelope. A bare room-details body
|
||||||
|
/// has no <c>success</c>, so the client rejects it with an empty error:
|
||||||
|
/// "Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown", even
|
||||||
|
/// though the room was created. Refusals are 200 with success=false and a
|
||||||
|
/// message the client shows.</summary>
|
||||||
|
private OkObjectResult CloneResult(object? room, string error = "") =>
|
||||||
|
Ok(new { success = error.Length == 0, value = room, error });
|
||||||
|
|
||||||
/// <summary>Give a freshly-cloned room its OWN copy of the source's save
|
/// <summary>Give a freshly-cloned room its OWN copy of the source's save
|
||||||
/// data instead of pointing its scenes at the source's blob.
|
/// data instead of pointing its scenes at the source's blob.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -156,9 +156,19 @@ public class StorefrontsBuyController(
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
// Push the buyer's balance update so their wallet UI refreshes.
|
// Push the buyer's balance update so their wallet UI refreshes.
|
||||||
|
// StorefrontBalancePurchase "62" is PurchaseBalanceModificationDTO
|
||||||
|
// {BalanceAddType, Delta, Balance, Platform, CurrencyType}; Balance
|
||||||
|
// is the absolute new balance, Delta only feeds the client's log.
|
||||||
await notifications.NotifyAsync(pid,
|
await notifications.NotifyAsync(pid,
|
||||||
PushNotificationId.StorefrontBalancePurchase,
|
PushNotificationId.StorefrontBalancePurchase,
|
||||||
new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug });
|
new
|
||||||
|
{
|
||||||
|
BalanceAddType = 0,
|
||||||
|
Delta = (int)-item.Price,
|
||||||
|
Balance = newBalance,
|
||||||
|
Platform = 0,
|
||||||
|
req.CurrencyType,
|
||||||
|
});
|
||||||
|
|
||||||
if (isGift)
|
if (isGift)
|
||||||
{
|
{
|
||||||
@@ -166,24 +176,13 @@ public class StorefrontsBuyController(
|
|||||||
// GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts
|
// GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts
|
||||||
// and pop the "you got a gift" toast; the gift box itself
|
// and pop the "you got a gift" toast; the gift box itself
|
||||||
// appears next time the recipient opens their inventory.
|
// appears next time the recipient opens their inventory.
|
||||||
|
// GiftPackageReceived "30" carries the client's full gift package
|
||||||
|
// (FHMABOHAEED) — the same object GET api/avatar/v2/gifts lists.
|
||||||
|
// A hand-picked subset left out PlatformsToSpawnOn (-1 = every
|
||||||
|
// platform), so the box could count as spawning nowhere.
|
||||||
await notifications.NotifyAsync(recipient,
|
await notifications.NotifyAsync(recipient,
|
||||||
PushNotificationId.GiftPackageReceived,
|
PushNotificationId.GiftPackageReceived,
|
||||||
new
|
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
|
||||||
{
|
|
||||||
Id = gift.Id,
|
|
||||||
Platform = -1,
|
|
||||||
Xp = 0,
|
|
||||||
Level = 1,
|
|
||||||
GiftRarity = 0,
|
|
||||||
GiftContext = gift.GiftContext,
|
|
||||||
FromPlayerId = gift.FromPlayerId,
|
|
||||||
AvatarItemType = gift.AvatarItemType,
|
|
||||||
AvatarItemDesc = avatarItemDesc,
|
|
||||||
ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty,
|
|
||||||
EquipmentPrefabName = gift.EquipmentPrefabName,
|
|
||||||
EquipmentModificationGuid = gift.EquipmentModificationGuid,
|
|
||||||
Message = gift.Message ?? string.Empty,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For gift purchases the buyer doesn't get a box — their flow
|
// For gift purchases the buyer doesn't get a box — their flow
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ public class AccountsController(
|
|||||||
playerId, username, summary);
|
playerId, username, summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
await notifications.KickPlayerAsync(playerId, "This account has been deleted.");
|
await notifications.LogoutAsync(playerId, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||||
presence.Clear(playerId);
|
presence.Clear(playerId);
|
||||||
|
|
||||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ namespace DorkNet.Server.Controllers.Admin;
|
|||||||
public class AdminController(
|
public class AdminController(
|
||||||
DorkNetDbContext db,
|
DorkNetDbContext db,
|
||||||
NotificationService notifications,
|
NotificationService notifications,
|
||||||
|
SystemNotificationService systemNotifications,
|
||||||
LevelService level,
|
LevelService level,
|
||||||
CommunityBoardService communityBoard,
|
CommunityBoardService communityBoard,
|
||||||
RoomBlobNormalizerService roomBlobNormalizer,
|
RoomBlobNormalizerService roomBlobNormalizer,
|
||||||
@@ -160,7 +161,9 @@ public class AdminController(
|
|||||||
await LogAsync("ban_player", "player", id, body.Reason ?? "");
|
await LogAsync("ban_player", "player", id, body.Reason ?? "");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await notifications.KickPlayerAsync(id, body.Reason ?? "You have been banned.");
|
// A ban must sign the player out (a kick only sends them to the dorm);
|
||||||
|
// the next login is refused by the ban check.
|
||||||
|
await notifications.LogoutAsync(id, NotificationService.LogoutReason.Banned);
|
||||||
playerPresence.Clear(id);
|
playerPresence.Clear(id);
|
||||||
|
|
||||||
return Ok(new { player.Id, player.BannedUntil });
|
return Ok(new { player.Id, player.BannedUntil });
|
||||||
@@ -243,7 +246,7 @@ public class AdminController(
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
|
|
||||||
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin.");
|
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||||
playerPresence.Clear(id);
|
playerPresence.Clear(id);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
@@ -1570,10 +1573,13 @@ public class AdminController(
|
|||||||
return Ok(new { ok = true });
|
return Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Push fresh RoomDetails after role/owner mutations. The
|
/// <summary>Push fresh room details after role/owner mutations, to everyone
|
||||||
/// 2020 watch caches RoomDetails, including CoOwners/Moderators/Hosts,
|
/// in the room plus the player whose role changed. The client caches the
|
||||||
/// and role-gated UI reads that cache until Rooms.OnSubscriptionUpdateRoom
|
/// room's Roles and role-gated UI reads that cache. The 2023 "RoomUpdate"
|
||||||
/// stores a replacement.</summary>
|
/// handler (IBEOONPEELF.txt:507) reads the FULL room (FGCPNAACHIK) — the
|
||||||
|
/// shape GET roomserver/rooms/{id} returns. The 2020 {Room, Scenes}
|
||||||
|
/// wrapper parsed as RoomId 0 with no roles, so role changes never
|
||||||
|
/// reached anyone until they re-entered the room.</summary>
|
||||||
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
|
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
|
||||||
{
|
{
|
||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||||
@@ -1587,7 +1593,7 @@ public class AdminController(
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
|
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
|
||||||
.BuildRoomDetails(room, scenes, roles: roles);
|
.BuildRoomServerDetails(room, scenes, roles: roles);
|
||||||
|
|
||||||
var recipients = onlinePresence.OnlinePlayerIds()
|
var recipients = onlinePresence.OnlinePlayerIds()
|
||||||
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
|
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
|
||||||
@@ -1739,7 +1745,7 @@ public class AdminController(
|
|||||||
{
|
{
|
||||||
var r = playerPresence.GetRoom(pid);
|
var r = playerPresence.GetRoom(pid);
|
||||||
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
|
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
|
||||||
await notifications.KickPlayerAsync(pid, reason);
|
await notifications.KickPlayerAsync(pid, reason, gameSessionId: instanceId);
|
||||||
playerPresence.Clear(pid);
|
playerPresence.Clear(pid);
|
||||||
kicked.Add(pid);
|
kicked.Add(pid);
|
||||||
}
|
}
|
||||||
@@ -2396,7 +2402,9 @@ public class AdminController(
|
|||||||
var reason = string.IsNullOrWhiteSpace(body?.Reason)
|
var reason = string.IsNullOrWhiteSpace(body?.Reason)
|
||||||
? "Kicked by an admin."
|
? "Kicked by an admin."
|
||||||
: body!.Reason!.Trim();
|
: body!.Reason!.Trim();
|
||||||
await notifications.KickPlayerAsync(id, reason);
|
// The room instance lets the client announce the kick to that room.
|
||||||
|
await notifications.KickPlayerAsync(id, reason,
|
||||||
|
gameSessionId: playerPresence.GetRoom(id)?.RoomInstanceId ?? 0);
|
||||||
playerPresence.Clear(id);
|
playerPresence.Clear(id);
|
||||||
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
|
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -3328,10 +3336,11 @@ public class AdminController(
|
|||||||
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
|
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
|
||||||
await LogAsync("set_password", "player", id, "");
|
await LogAsync("set_password", "player", id, "");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
// Push a logout so any active sessions on the old password get
|
// Log out any session still on the old password; the client has to
|
||||||
// dropped — the watch re-authenticates with the new password.
|
// sign in again with the new one. The reason must be the numeric
|
||||||
await notifications.NotifyAsync(id, PushNotificationId.Logout,
|
// enum: the string "AdminPasswordReset" failed the client's parse and
|
||||||
new { Reason = "AdminPasswordReset" });
|
// the push was dropped.
|
||||||
|
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||||
return Ok(new { id });
|
return Ok(new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3500,22 +3509,26 @@ public class AdminController(
|
|||||||
|
|
||||||
playerPresence.SetRoom(id, roomInstance);
|
playerPresence.SetRoom(id, roomInstance);
|
||||||
|
|
||||||
// Two pushes back-to-back: GameSession update changes the
|
// The move itself comes from the presence set above: the client's
|
||||||
// watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance
|
// next heartbeat sees it is out of sync and joins that instance.
|
||||||
// → UpdateCachedRoomInstance), and SubscriptionUpdateRoom
|
// RoomInstanceUpdate only refreshes the client's cached copy of the
|
||||||
// refreshes the watch's RoomDetails cache so the room-info
|
// instance, and RoomUpdate its cached room details, which must be
|
||||||
// panel doesn't show stale data after the warp.
|
// the full room (FGCPNAACHIK) the roomserver GET returns.
|
||||||
await notifications.NotifyAsync(id,
|
await notifications.NotifyAsync(id,
|
||||||
PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
|
PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
|
||||||
|
var forceJoinScenes = await db.RoomScenes
|
||||||
|
.Where(sc => sc.RoomId == room.Id)
|
||||||
|
.OrderBy(sc => sc.OrderIndex)
|
||||||
|
.ToListAsync();
|
||||||
|
// With every role grant: the client replaces its cached room with
|
||||||
|
// this, so a payload without them would drop everyone's roles.
|
||||||
|
var forceJoinRoles = await db.RoomRoles
|
||||||
|
.Where(r => r.RoomId == room.Id)
|
||||||
|
.ToListAsync();
|
||||||
await notifications.NotifyAsync(id,
|
await notifications.NotifyAsync(id,
|
||||||
PushNotificationId.SubscriptionUpdateRoom,
|
PushNotificationId.SubscriptionUpdateRoom,
|
||||||
new
|
DorkNet.Server.Controllers.API.Rooms.V2.RoomsController.BuildRoomServerDetails(
|
||||||
{
|
room, forceJoinScenes, roles: forceJoinRoles));
|
||||||
Room = new { Id = room.Id, Name = room.Name },
|
|
||||||
RoomId = room.Id,
|
|
||||||
SubRoomId = subRoomId,
|
|
||||||
Reason = "AdminForceJoin",
|
|
||||||
});
|
|
||||||
|
|
||||||
await LogAsync("force_join", "player", id,
|
await LogAsync("force_join", "player", id,
|
||||||
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
|
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
|
||||||
@@ -3532,20 +3545,46 @@ public class AdminController(
|
|||||||
|
|
||||||
// ── System ───────────────────────────────────────────────────────────
|
// ── System ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public sealed record SetMotdRequest(string Message);
|
public sealed record SetMotdRequest(string? Message, int? StartsInMinutes);
|
||||||
|
|
||||||
/// <summary>Push a server-maintenance broadcast to every connected
|
/// <summary>Broadcast to every connected player.
|
||||||
/// player. The MOTD config value is changed via appsettings.json
|
/// <list type="bullet">
|
||||||
/// (server restart required); this is for ad-hoc announcements.</summary>
|
/// <item><c>Message</c>: a coach message (MessageReceived, Type 100, from
|
||||||
|
/// the system account) in each online player's notifications —
|
||||||
|
/// the client's channel for free text from the server.</item>
|
||||||
|
/// <item><c>StartsInMinutes</c>: the client's own maintenance countdown
|
||||||
|
/// (ServerMaintenance "25", which reads only
|
||||||
|
/// <c>StartsInMinutes</c>; 0 cancels a running countdown).</item>
|
||||||
|
/// </list>
|
||||||
|
/// ServerMaintenance used to carry the text as <c>{Message}</c>, which the
|
||||||
|
/// client never reads: the text was never shown, and StartsInMinutes 0
|
||||||
|
/// cancelled any countdown instead.</summary>
|
||||||
[HttpPost("broadcast")]
|
[HttpPost("broadcast")]
|
||||||
public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body)
|
public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty message");
|
var message = body.Message?.Trim() ?? string.Empty;
|
||||||
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
|
if (message.Length == 0 && body.StartsInMinutes is null)
|
||||||
new { Message = body.Message });
|
return BadRequest("empty message");
|
||||||
await LogAsync("broadcast", "system", 0, body.Message);
|
if (body.StartsInMinutes is < 0)
|
||||||
|
return BadRequest("StartsInMinutes must be 0 or more");
|
||||||
|
|
||||||
|
var recipients = onlinePresence.OnlinePlayerIds().ToList();
|
||||||
|
if (message.Length > 0)
|
||||||
|
{
|
||||||
|
foreach (var pid in recipients)
|
||||||
|
await systemNotifications.SendAsync(pid,
|
||||||
|
SystemNotificationService.MessageType.CoachMessage,
|
||||||
|
fromPlayerId: PlayerService.SystemAccountId,
|
||||||
|
data: message);
|
||||||
|
}
|
||||||
|
if (body.StartsInMinutes is int minutes)
|
||||||
|
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
|
||||||
|
new { StartsInMinutes = minutes });
|
||||||
|
|
||||||
|
await LogAsync("broadcast", "system", 0,
|
||||||
|
body.StartsInMinutes is int m ? $"[maintenance in {m} min] {message}" : message);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok();
|
return Ok(new { recipients = recipients.Count });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Store catalog management ─────────────────────────────────────────
|
// ── Store catalog management ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -460,6 +460,20 @@ public class ChatController(
|
|||||||
var removed = await db.ChatThreadMembers
|
var removed = await db.ChatThreadMembers
|
||||||
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
|
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
if (removed > 0)
|
||||||
|
{
|
||||||
|
// "PlayerLeftChat" is an Action<ChatMessage> (DLDKCILCKNA.txt:721);
|
||||||
|
// the handler only reads ChatThreadId and SenderPlayerId (the
|
||||||
|
// player who left) and drops them from that thread's members.
|
||||||
|
// Sent to the remaining members, whose member lists otherwise
|
||||||
|
// stayed stale, and to the leaver's own other sessions.
|
||||||
|
var thread = await EnsureThreadRowAsync(chatThreadId);
|
||||||
|
var left = new ChatMessageEntity { ThreadKey = chatThreadId, SenderPlayerId = Me, Body = string.Empty };
|
||||||
|
var recipients = await ThreadRecipientsAsync(chatThreadId);
|
||||||
|
recipients.Add(Me);
|
||||||
|
foreach (var rid in recipients.Distinct())
|
||||||
|
await notifications.NotifyTypedAsync(rid, "PlayerLeftChat", ToWireMessage(left, thread.Id));
|
||||||
|
}
|
||||||
return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound);
|
return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public class GoToController(
|
|||||||
RoomService rooms,
|
RoomService rooms,
|
||||||
DorkNet.Server.Data.DorkNetDbContext db,
|
DorkNet.Server.Data.DorkNetDbContext db,
|
||||||
NotificationService notifications,
|
NotificationService notifications,
|
||||||
|
SystemNotificationService systemNotifications,
|
||||||
JoinTimeoutService joinTimeouts,
|
JoinTimeoutService joinTimeouts,
|
||||||
ServerSettingsService serverSettings,
|
ServerSettingsService serverSettings,
|
||||||
PresenceBroadcastService presenceFanout,
|
PresenceBroadcastService presenceFanout,
|
||||||
@@ -877,11 +878,15 @@ public class GoToController(
|
|||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting",
|
"[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting",
|
||||||
callerId, playerId, theirRoom.RoomInstanceId);
|
callerId, playerId, theirRoom.RoomInstanceId);
|
||||||
// Notify the OWNER that someone tried to follow them in.
|
// Tell the OWNER someone tried to follow them in, as the
|
||||||
// Helps surface the rejection so the owner knows there's
|
// client's own "wants to join" message (RequestGameInvite,
|
||||||
// an interested party — they can choose to send an invite.
|
// from the caller), so they can send an invite. At most once
|
||||||
await notifications.PrivateInstanceJoinRejected(
|
// per requester, owner and instance per cooldown, so retrying
|
||||||
ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0);
|
// doesn't fill the owner's inbox.
|
||||||
|
if (callerId is long requester
|
||||||
|
&& JoinRequestCooldown.TryStart(requester, playerId, theirRoom.RoomInstanceId))
|
||||||
|
await systemNotifications.SendAsync(playerId,
|
||||||
|
SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester);
|
||||||
return Ok(new MatchmakingResponseDto
|
return Ok(new MatchmakingResponseDto
|
||||||
{
|
{
|
||||||
ErrorCode = 26, // RoomInstanceIsPrivate
|
ErrorCode = 26, // RoomInstanceIsPrivate
|
||||||
@@ -1668,3 +1673,35 @@ public class RoomInstanceDto
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Rate limit for "wants to join" messages: one per (requester,
|
||||||
|
/// owner, room instance) per <see cref="Window"/>. Later retries and other
|
||||||
|
/// instances still go through. Per replica, which is enough to stop a
|
||||||
|
/// retry loop from flooding an inbox.</summary>
|
||||||
|
internal static class JoinRequestCooldown
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan Window = TimeSpan.FromMinutes(5);
|
||||||
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<(long, long, long), DateTime> Last = new();
|
||||||
|
|
||||||
|
/// <summary>True when no request for this key was sent within the window
|
||||||
|
/// (and records this one); false while it is cooling down.</summary>
|
||||||
|
public static bool TryStart(long requester, long owner, long roomInstanceId)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (Last.Count > 10_000)
|
||||||
|
foreach (var (key, at) in Last)
|
||||||
|
if (now - at > Window) Last.TryRemove(key, out _);
|
||||||
|
|
||||||
|
var k = (requester, owner, roomInstanceId);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (!Last.TryGetValue(k, out var prev))
|
||||||
|
{
|
||||||
|
if (Last.TryAdd(k, now)) return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (now - prev < Window) return false;
|
||||||
|
if (Last.TryUpdate(k, now, prev)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,17 +69,15 @@ public class MatchPlayerController(
|
|||||||
if (replaced)
|
if (replaced)
|
||||||
{
|
{
|
||||||
log.LogWarning(
|
log.LogWarning(
|
||||||
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push",
|
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; logging old session(s) out",
|
||||||
logScope, playerId, takeOverExclusiveSession);
|
logScope, playerId, takeOverExclusiveSession);
|
||||||
// The new session hasn't opened its SignalR socket yet
|
// The new session hasn't opened its SignalR socket yet
|
||||||
// (negotiate fires AFTER /player/login on the watch's
|
// (negotiate fires AFTER /player/login on the boot
|
||||||
// boot sequence — verified in the live trace), so the
|
// sequence — verified in the live trace), so the Logout
|
||||||
// ModerationKick push hits only the OLDER session's
|
// push (Reason SessionTakeover) reaches only the OLDER
|
||||||
// existing connections. They'll show the "you've been
|
// session, which shows the client's own "signed in
|
||||||
// kicked" dialog and gracefully disconnect via the
|
// elsewhere" message and returns to the boot scene.
|
||||||
// watch's LocalModerationKickSelf path.
|
await notifications.LogoutStaleSession(playerId);
|
||||||
await notifications.KickStaleSession(playerId,
|
|
||||||
"Your account was signed in on another device.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Ok(Array.Empty<object>());
|
return Ok(Array.Empty<object>());
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Controllers.Site;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The client builds CI publishes, for the site's Downloads page and the
|
||||||
|
/// Quest page's "latest build" install. Files are streamed from the downloads
|
||||||
|
/// bucket (<see cref="DownloadsService"/>) with Range support, so the browser
|
||||||
|
/// installer can read an .xapk's zip directory and entries without fetching
|
||||||
|
/// the whole file. Only files the manifest lists are served.
|
||||||
|
///
|
||||||
|
/// The builds contain Rec Room's own game files, so the routes answer only on
|
||||||
|
/// the site's own host (the apex) and 404 on every other host the server
|
||||||
|
/// accepts: the game's API/CDN subdomains are typically left open for the
|
||||||
|
/// client, while the site sits behind an access proxy (e.g. Cloudflare Access).
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Route("api/site/v1/downloads")]
|
||||||
|
public class DownloadsController(DownloadsService downloads, DomainConfig domain) : ControllerBase
|
||||||
|
{
|
||||||
|
private bool Available => downloads.IsConfigured
|
||||||
|
&& string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>GET /api/site/v1/downloads — the manifest as published
|
||||||
|
/// (snake_case keys), or 404 when downloads aren't set up or the request
|
||||||
|
/// isn't for the site's host.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetManifest(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." });
|
||||||
|
var manifest = await downloads.GetManifestAsync(ct);
|
||||||
|
Response.Headers.CacheControl = "no-cache";
|
||||||
|
return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() })
|
||||||
|
.ToJsonString(), "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET /api/site/v1/downloads/files/{path} — one listed file,
|
||||||
|
/// streamed; honours Range (206).</summary>
|
||||||
|
[HttpGet("files/{**path}")]
|
||||||
|
public async Task GetFile(string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
|
||||||
|
var manifest = await downloads.GetManifestAsync(ct);
|
||||||
|
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
|
||||||
|
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
|
||||||
|
|
||||||
|
using var upstream = await downloads.GetFileAsync(path, Request.Headers.Range.ToString(), ct);
|
||||||
|
Response.StatusCode = (int)upstream.StatusCode;
|
||||||
|
if (!upstream.IsSuccessStatusCode) return;
|
||||||
|
|
||||||
|
var content = upstream.Content.Headers;
|
||||||
|
if (content.ContentLength is long length) Response.ContentLength = length;
|
||||||
|
if (content.ContentRange is not null) Response.Headers.ContentRange = content.ContentRange.ToString();
|
||||||
|
Response.Headers.AcceptRanges = "bytes";
|
||||||
|
if (upstream.Headers.ETag is not null) Response.Headers.ETag = upstream.Headers.ETag.ToString();
|
||||||
|
Response.Headers.CacheControl = "private, no-cache";
|
||||||
|
Response.ContentType = "application/octet-stream";
|
||||||
|
var name = (string?)entry["name"] ?? Path.GetFileName(path);
|
||||||
|
Response.Headers.ContentDisposition = new System.Net.Mime.ContentDisposition { FileName = name }.ToString();
|
||||||
|
|
||||||
|
await using var body = await upstream.Content.ReadAsStreamAsync(ct);
|
||||||
|
await body.CopyToAsync(Response.Body, 1 << 16, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<!-- RenderedCompactJsonFormatter for stdout JSON logs in production
|
<!-- RenderedCompactJsonFormatter for stdout JSON logs in production
|
||||||
(Coolify-friendly). The host pipeline reads stdout line-by-line,
|
(Coolify-friendly). The host pipeline reads stdout line-by-line,
|
||||||
and structured JSON lets a future Loki/Datadog ingest parse fields
|
and structured JSON lets a future Loki/Datadog ingest parse fields
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
account-creation flow), and PlayerPresenceService heartbeats all
|
account-creation flow), and PlayerPresenceService heartbeats all
|
||||||
need shared state across replicas. -->
|
need shared state across replicas. -->
|
||||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.13" />
|
||||||
<!-- S3-compatible object storage. Production target: Garage in
|
<!-- S3-compatible object storage. Production target: Garage in
|
||||||
Coolify (one-click). Dev fallback: local MinIO for parity
|
Coolify (one-click). Dev fallback: local MinIO for parity
|
||||||
testing. The SDK speaks plain S3, so swapping endpoints
|
testing. The SDK speaks plain S3, so swapping endpoints
|
||||||
@@ -37,8 +37,8 @@
|
|||||||
<!-- Password hashing for the real account auth flow. BCrypt embeds
|
<!-- Password hashing for the real account auth flow. BCrypt embeds
|
||||||
cost+salt in the output string so we only need one column. -->
|
cost+salt in the output string so we only need one column. -->
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.3.1" />
|
||||||
<!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N
|
<!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N
|
||||||
on profile thumbnails). The 2020 client treats this as part of the
|
on profile thumbnails). The 2020 client treats this as part of the
|
||||||
image CDN contract — it uploads a full-frame capture and expects the
|
image CDN contract — it uploads a full-frame capture and expects the
|
||||||
|
|||||||
@@ -27,15 +27,16 @@ namespace DorkNet.Server.Hubs;
|
|||||||
/// Pre-PR-2 used a local <c>static ConcurrentDictionary</c> which only
|
/// Pre-PR-2 used a local <c>static ConcurrentDictionary</c> which only
|
||||||
/// reflected this replica's connections.
|
/// reflected this replica's connections.
|
||||||
///
|
///
|
||||||
/// Hub methods invoked by the client (verified by string-literal scan
|
/// Hub methods the 2023-03-21 client sends (fire-and-forget, RecNet.Runtime
|
||||||
/// of metadata at addresses 0x39b2470, 0x39b24c8, 0x39b4ba8):
|
/// OMHPBIEHOHN):
|
||||||
/// "Subscribe" - generic topic subscribe
|
/// "SubscribeToPlayers" - {PlayerIds}: players to get presence for
|
||||||
/// "Unsubscribe" - generic topic unsubscribe
|
/// "SubscribeTo" - {Channels}: always empty in this build
|
||||||
/// "SubscribeToPlayers" - subscribe to per-player events (used by
|
/// "UnsubscribeFrom" - {Channels}: always empty in this build
|
||||||
/// RecNet.NotificationManager / SubscribeToPlayers)
|
/// There is no UnsubscribeFromPlayers; SubscribeToPlayers always carries the
|
||||||
/// All accept JsonElement so we don't lock ourselves into a parameter
|
/// complete set. All accept JsonElement and tolerate casing differences.
|
||||||
/// schema we haven't verified yet, and return Task so the client's
|
///
|
||||||
/// awaited Invoke completes cleanly.
|
/// The client reconnects at most 3 times per login (its attempt counter is
|
||||||
|
/// never reset), so the server must not drop healthy connections.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class NotifyHub(
|
public class NotifyHub(
|
||||||
@@ -65,28 +66,25 @@ public class NotifyHub(
|
|||||||
// Instead: at the moment the SECOND session's WebSocket
|
// Instead: at the moment the SECOND session's WebSocket
|
||||||
// arrives, take a snapshot of the EXISTING connection ids
|
// arrives, take a snapshot of the EXISTING connection ids
|
||||||
// (which still belong to the original session — we haven't
|
// (which still belong to the original session — we haven't
|
||||||
// joined the group yet) and push a targeted ModerationKick
|
// joined the group yet) and send those ids a Logout with
|
||||||
// to those ids. The watch's OnModerationKick handler at
|
// Reason SessionTakeover. The client shows its own "signed in
|
||||||
// PlayerReporting.txt:1857 shows the standard moderation
|
// elsewhere" message and returns to the boot scene. (A
|
||||||
// dialog and routes through LocalModerationKickSelf, the
|
// ModerationKick only sent the old session to its dorm, still
|
||||||
// same graceful disconnect path the original Rec Room used
|
// signed in.)
|
||||||
// for real moderator kicks.
|
|
||||||
//
|
//
|
||||||
// Targeting specific connection ids (not the group) means
|
// Targeting specific connection ids (not the group) means
|
||||||
// the new connection — about to join the group on the next
|
// the new connection — about to join the group on the next
|
||||||
// line — never receives the kick, even with Redis backplane
|
// line — never receives it, even with Redis backplane
|
||||||
// routing across replicas.
|
// routing across replicas.
|
||||||
var existing = presence.GetConnections(id);
|
var existing = presence.GetConnections(id);
|
||||||
if (existing.Count > 0)
|
if (existing.Count > 0)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"[hub] second-session connect for player={PlayerId} (conn={NewConn}); kicking {OldCount} existing connection(s)",
|
"[hub] second-session connect for player={PlayerId} (conn={NewConn}); logging out {OldCount} existing connection(s)",
|
||||||
id, Context.ConnectionId, existing.Count);
|
id, Context.ConnectionId, existing.Count);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await notifications.KickConnectionsAsync(
|
await notifications.LogoutConnectionsAsync(existing);
|
||||||
existing,
|
|
||||||
"Your account was signed in on another device.");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -156,36 +154,111 @@ public class NotifyHub(
|
|||||||
await base.OnDisconnectedAsync(exception);
|
await base.OnDisconnectedAsync(exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SubscribeToPlayers(JsonElement payload)
|
/// <summary>SignalR group a connection joins to receive one player's
|
||||||
|
/// presence. Only presence is sent here — a subscriber must never receive
|
||||||
|
/// the subject's own pushes (DMs, gifts, …).</summary>
|
||||||
|
public static string PresenceGroupForPlayer(long playerId) => $"presence:{playerId}";
|
||||||
|
|
||||||
|
private const string SubscriptionsKey = "presence-subscriptions";
|
||||||
|
|
||||||
|
/// <summary>Most players one connection may subscribe to. The client sends
|
||||||
|
/// its relationships plus the players around it (dozens); anything far
|
||||||
|
/// past that is not a real client, and each id costs a group membership
|
||||||
|
/// on the backplane.</summary>
|
||||||
|
public const int MaxPresenceSubscriptions = 5000;
|
||||||
|
|
||||||
|
/// <summary><c>SubscribeToPlayers(PlayerSubscriptionListDTO {PlayerIds:
|
||||||
|
/// List<int>})</c> (RecNet.Runtime OMHPBIEHOHN.txt:3430): the full set
|
||||||
|
/// of players this client wants presence for — its relationships, the
|
||||||
|
/// players around it, and itself. Sent on every (re)connect and whenever
|
||||||
|
/// the set changes, always as the complete set, so it REPLACES the
|
||||||
|
/// connection's subscriptions. Presence for those players is then pushed
|
||||||
|
/// to <see cref="PresenceGroupForPlayer"/> by
|
||||||
|
/// <see cref="PresenceBroadcastService"/>, so non-friends the client is
|
||||||
|
/// looking at (people in the room, an open profile) stay current too.
|
||||||
|
///
|
||||||
|
/// The caller's own id is skipped: a presence frame about the local player
|
||||||
|
/// is applied as a correction and moves them. An unreadable payload
|
||||||
|
/// leaves the current subscriptions alone rather than clearing them.
|
||||||
|
/// Groups live per connection on its own replica, are routed cluster-wide
|
||||||
|
/// by the Redis backplane, and are dropped by SignalR on disconnect.</summary>
|
||||||
|
public async Task SubscribeToPlayers(JsonElement payload)
|
||||||
|
{
|
||||||
|
var me = TryGetPlayerId();
|
||||||
|
if (!TryReadPlayerIds(payload, out var requested))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"[hub] SubscribeToPlayers unreadable payload player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||||
|
me, Context.ConnectionId, RawPayload(payload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (me is long self) requested.Remove(self);
|
||||||
|
if (requested.Count > MaxPresenceSubscriptions)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"[hub] SubscribeToPlayers over the {Max} limit player={PlayerId} conn={ConnectionId} count={Count}; kept the current set",
|
||||||
|
MaxPresenceSubscriptions, me, Context.ConnectionId, requested.Count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = Context.Items.TryGetValue(SubscriptionsKey, out var existing) && existing is HashSet<long> set
|
||||||
|
? set
|
||||||
|
: new HashSet<long>();
|
||||||
|
var added = requested.Except(current).ToList();
|
||||||
|
var removed = current.Except(requested).ToList();
|
||||||
|
foreach (var id in added)
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
|
||||||
|
foreach (var id in removed)
|
||||||
|
await Groups.RemoveFromGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
|
||||||
|
Context.Items[SubscriptionsKey] = requested;
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} subscribed={Count} added={Added} removed={Removed}",
|
||||||
|
me, Context.ConnectionId, requested.Count, added.Count, removed.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>SubscribeTo({Channels: List<string>})</c>. The
|
||||||
|
/// 2023-03-21 client composes it on every connect but its channel set is
|
||||||
|
/// always empty, so there is nothing to subscribe to; logged only.</summary>
|
||||||
|
public Task SubscribeTo(JsonElement payload)
|
||||||
{
|
{
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} payload={Payload}",
|
"[hub] SubscribeTo player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||||
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
|
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task UnsubscribeFromPlayers(JsonElement payload)
|
/// <summary>Counterpart of <see cref="SubscribeTo"/>; logged only.</summary>
|
||||||
|
public Task UnsubscribeFrom(JsonElement payload)
|
||||||
{
|
{
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"[hub] UnsubscribeFromPlayers player={PlayerId} conn={ConnectionId} payload={Payload}",
|
"[hub] UnsubscribeFrom player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||||
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
|
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Subscribe(JsonElement payload)
|
/// <summary>Reads <c>{PlayerIds:[…]}</c> (any key casing), or a bare
|
||||||
|
/// array, as player ids; numbers or numeric strings.</summary>
|
||||||
|
public static bool TryReadPlayerIds(JsonElement payload, out HashSet<long> ids)
|
||||||
{
|
{
|
||||||
logger.LogInformation(
|
ids = new HashSet<long>();
|
||||||
"[hub] Subscribe player={PlayerId} conn={ConnectionId} payload={Payload}",
|
var list = payload;
|
||||||
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
|
if (payload.ValueKind == JsonValueKind.Object)
|
||||||
return Task.CompletedTask;
|
{
|
||||||
}
|
var match = payload.EnumerateObject()
|
||||||
|
.Where(prop => string.Equals(prop.Name, "PlayerIds", StringComparison.OrdinalIgnoreCase))
|
||||||
public Task Unsubscribe(JsonElement payload)
|
.Select(prop => (JsonElement?)prop.Value)
|
||||||
{
|
.FirstOrDefault();
|
||||||
logger.LogInformation(
|
if (match is not JsonElement value) return false;
|
||||||
"[hub] Unsubscribe player={PlayerId} conn={ConnectionId} payload={Payload}",
|
list = value;
|
||||||
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
|
}
|
||||||
return Task.CompletedTask;
|
if (list.ValueKind != JsonValueKind.Array) return false;
|
||||||
|
foreach (var item in list.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var n) && n > 0) ids.Add(n);
|
||||||
|
else if (item.ValueKind == JsonValueKind.String && long.TryParse(item.GetString(), out var p) && p > 0) ids.Add(p);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private long? TryGetPlayerId()
|
private long? TryGetPlayerId()
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the client builds CI publishes (<c>ci/downloads/publish.py</c>): a
|
||||||
|
/// <c>manifest.json</c> plus the files it lists, in a bucket served over plain
|
||||||
|
/// HTTP (e.g. Garage's web endpoint on the internal network). The site's
|
||||||
|
/// Downloads and Quest pages fetch them through the server, so the bucket
|
||||||
|
/// itself never has to be reachable from outside.
|
||||||
|
///
|
||||||
|
/// Configured with <c>DorkNet:Downloads:BaseUrl</c> (the folder holding
|
||||||
|
/// <c>manifest.json</c>) and optionally <c>DorkNet:Downloads:Host</c> (the
|
||||||
|
/// Host header to send, for web endpoints that pick the bucket by host name).
|
||||||
|
/// Without a base URL <see cref="IsConfigured"/> is false and the site hides
|
||||||
|
/// both features.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DownloadsService
|
||||||
|
{
|
||||||
|
public const string HttpClientName = "downloads";
|
||||||
|
|
||||||
|
// How long the bucket may take to start answering. The body then streams
|
||||||
|
// with no limit (the client has none; the caller's abort token ends it).
|
||||||
|
private static readonly TimeSpan HeaderTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly Uri? _baseUri;
|
||||||
|
private readonly string? _host;
|
||||||
|
|
||||||
|
public DownloadsService(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<DownloadsService> log)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
var baseUrl = config["DorkNet:Downloads:BaseUrl"]?.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(baseUrl) && Uri.TryCreate(baseUrl.TrimEnd('/') + "/", UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||||
|
{
|
||||||
|
_baseUri = uri;
|
||||||
|
}
|
||||||
|
var host = config["DorkNet:Downloads:Host"]?.Trim();
|
||||||
|
_host = string.IsNullOrEmpty(host) ? null : host;
|
||||||
|
|
||||||
|
if (_baseUri is null)
|
||||||
|
log.LogInformation("[downloads] no DorkNet:Downloads:BaseUrl; the Downloads page and Quest latest-build install are off");
|
||||||
|
else
|
||||||
|
log.LogInformation("[downloads] reading builds from {BaseUrl}{Host}", _baseUri, _host is null ? "" : $" (Host: {_host})");
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsConfigured => _baseUri is not null;
|
||||||
|
|
||||||
|
/// <summary>The published manifest, or null when none has been published yet.</summary>
|
||||||
|
public async Task<JsonObject?> GetManifestAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var res = await SendAsync("manifest.json", range: null, ct);
|
||||||
|
if (res.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
return JsonNode.Parse(await res.Content.ReadAsStringAsync(ct)) as JsonObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A manifest entry by its bucket path, or null when the manifest
|
||||||
|
/// doesn't list it. Only listed files are ever served.</summary>
|
||||||
|
public static JsonObject? FindFile(JsonObject manifest, string path)
|
||||||
|
{
|
||||||
|
if (manifest["releases"] is not JsonArray releases) return null;
|
||||||
|
return releases.OfType<JsonObject>()
|
||||||
|
.SelectMany(r => r["files"] is JsonArray files ? files.OfType<JsonObject>() : [])
|
||||||
|
.FirstOrDefault(f => string.Equals((string?)f["path"], path, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET a file, forwarding the caller's Range header. The caller
|
||||||
|
/// owns the response and streams its content.</summary>
|
||||||
|
public Task<HttpResponseMessage> GetFileAsync(string path, string? range, CancellationToken ct)
|
||||||
|
=> SendAsync(path, range, ct);
|
||||||
|
|
||||||
|
private async Task<HttpResponseMessage> SendAsync(string path, string? range, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_baseUri is null) throw new InvalidOperationException("Downloads are not configured.");
|
||||||
|
var url = new Uri(_baseUri, string.Join('/', path.Split('/').Select(Uri.EscapeDataString)));
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
if (_host is not null) req.Headers.Host = _host;
|
||||||
|
if (!string.IsNullOrEmpty(range) && RangeHeaderValue.TryParse(range, out var r)) req.Headers.Range = r;
|
||||||
|
var client = _httpClientFactory.CreateClient(HttpClientName);
|
||||||
|
// The timeout covers only getting the headers: with ResponseHeadersRead
|
||||||
|
// the token is released once they arrive, and disposing the source
|
||||||
|
// here stops its timer before the body is streamed.
|
||||||
|
using var headers = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
headers.CancelAfter(HeaderTimeout);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, headers.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new TimeoutException($"The downloads bucket didn't answer within {HeaderTimeout.TotalSeconds:0} s.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications
|
|||||||
player.Level = LevelForXp(player.XP);
|
player.Level = LevelForXp(player.XP);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Live level bar: "PlayerProgressionLevelUpdate" {PlayerId, Level, XP}
|
||||||
|
// (handler PBOGNAPEBAI.txt:263, DTO DGFGIFIHCAJ) — the same values
|
||||||
|
// api/players/v2/progression returns. Without it the watch kept the
|
||||||
|
// level and XP it loaded at login until the next relog.
|
||||||
|
await notifications.NotifyTypedAsync(playerId, "PlayerProgressionLevelUpdate",
|
||||||
|
new { PlayerId = (int)playerId, player.Level, player.XP });
|
||||||
|
|
||||||
if (player.Level > oldLevel)
|
if (player.Level > oldLevel)
|
||||||
{
|
{
|
||||||
// Level-up grants a small currency reward. The wallet UI
|
// Level-up grants a small currency reward. The wallet UI
|
||||||
@@ -82,9 +89,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications
|
|||||||
row.UpdatedAt = DateTime.UtcNow;
|
row.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// StorefrontBalanceUpdate "61" is BalanceResponseDTO {Balance,
|
||||||
|
// CurrencyType, Platform}; the client sets that (CurrencyType,
|
||||||
|
// Platform) bucket to Balance. Platform 0 matches every balance REST
|
||||||
|
// response, so the buckets line up.
|
||||||
await notifications.NotifyAsync(playerId,
|
await notifications.NotifyAsync(playerId,
|
||||||
PushNotificationId.StorefrontBalanceUpdate,
|
PushNotificationId.StorefrontBalanceUpdate,
|
||||||
new { CurrencyType = currencyType, row.Balance, Reason = reason });
|
new { row.Balance, CurrencyType = currencyType, Platform = 0 });
|
||||||
return row.Balance;
|
return row.Balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -212,25 +212,17 @@ public class NotificationService(
|
|||||||
|
|
||||||
// ── Higher-level event helpers ─────────────────────────────────────
|
// ── Higher-level event helpers ─────────────────────────────────────
|
||||||
|
|
||||||
public Task FriendRequestReceived(long targetPlayerId, long fromPlayerId) =>
|
/// <summary>Push one relationship row to <paramref name="recipientPlayerId"/>.
|
||||||
NotifyAsync(targetPlayerId, PushNotificationId.RelationshipChanged,
|
/// <paramref name="relationship"/> must be the recipient's OWN view of the
|
||||||
new { Reason = "FriendRequestReceived", From = fromPlayerId });
|
/// pair — the same <c>{PlayerID, RelationshipType, Muted, Ignored,
|
||||||
|
/// Favorited}</c> object <c>api/relationships/v2/get</c> returns. The 2023
|
||||||
public Task FriendRequestAccepted(long requesterPlayerId, long byPlayerId) =>
|
/// handler (<c>EEGNOHOELBG.FOCBJAIDCBB</c>, Relationship
|
||||||
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged,
|
/// <c>ECGNEHMCGCN</c>) stores it under its <c>PlayerID</c> and raises the
|
||||||
new { Reason = "FriendRequestAccepted", By = byPlayerId });
|
/// changed event, which refreshes the friends list and the client's
|
||||||
|
/// SubscribeToPlayers set. Any other shape parses as PlayerID 0 and the
|
||||||
public Task FriendRequestDeclined(long requesterPlayerId, long byPlayerId) =>
|
/// real row never updates.</summary>
|
||||||
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged,
|
public Task RelationshipChanged(long recipientPlayerId, object relationship) =>
|
||||||
new { Reason = "FriendRequestDeclined", By = byPlayerId });
|
NotifyAsync(recipientPlayerId, PushNotificationId.RelationshipChanged, relationship);
|
||||||
|
|
||||||
public Task FriendRemoved(long otherPlayerId, long byPlayerId) =>
|
|
||||||
NotifyAsync(otherPlayerId, PushNotificationId.RelationshipChanged,
|
|
||||||
new { Reason = "FriendRemoved", By = byPlayerId });
|
|
||||||
|
|
||||||
public Task FriendBlocked(long blockedPlayerId, long byPlayerId) =>
|
|
||||||
NotifyAsync(blockedPlayerId, PushNotificationId.RelationshipChanged,
|
|
||||||
new { Reason = "FriendBlocked", By = byPlayerId });
|
|
||||||
|
|
||||||
/// <summary>Push a presence delta so subscribed friends re-render
|
/// <summary>Push a presence delta so subscribed friends re-render
|
||||||
/// their friend-list row without waiting for the watch's 15-45 s
|
/// their friend-list row without waiting for the watch's 15-45 s
|
||||||
@@ -270,6 +262,23 @@ public class NotificationService(
|
|||||||
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
|
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Push one player's presence to every connection that subscribed
|
||||||
|
/// to them with <c>SubscribeToPlayers</c>, except
|
||||||
|
/// <paramref name="excludeConnectionIds"/> — the subject's own connections
|
||||||
|
/// (a frame about the local player moves them) and anyone already sent the
|
||||||
|
/// frame through their friend group.</summary>
|
||||||
|
public async Task PresenceToSubscribersAsync(
|
||||||
|
long playerId,
|
||||||
|
RoomInstanceDto? room,
|
||||||
|
bool isOnline,
|
||||||
|
int statusVisibility,
|
||||||
|
IReadOnlyList<string> excludeConnectionIds)
|
||||||
|
{
|
||||||
|
var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility);
|
||||||
|
await hub.Clients.GroupExcept(NotifyHub.PresenceGroupForPlayer(playerId), excludeConnectionIds)
|
||||||
|
.SendAsync(HubMethod, payload);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The PresenceUpdate envelope for one player, as a JSON string.
|
/// <summary>The PresenceUpdate envelope for one player, as a JSON string.
|
||||||
///
|
///
|
||||||
/// <paramref name="isOnline"/> is independent of the room: the 2023
|
/// <paramref name="isOnline"/> is independent of the room: the 2023
|
||||||
@@ -349,86 +358,85 @@ public class NotificationService(
|
|||||||
public Task PlayerEnteredRoom(IEnumerable<long> friendIds, long playerId, RoomInstanceDto room) =>
|
public Task PlayerEnteredRoom(IEnumerable<long> friendIds, long playerId, RoomInstanceDto room) =>
|
||||||
PlayerPresenceChanged(friendIds, playerId, room);
|
PlayerPresenceChanged(friendIds, playerId, room);
|
||||||
|
|
||||||
/// <summary>Force every currently-connected SignalR session for
|
/// <summary>Client logout reasons (<c>Logout</c> "6" payload <c>Reason</c>,
|
||||||
/// <paramref name="playerId"/> to disconnect via the watch's
|
/// enum <c>MPJOEALLGHE</c>, read by formatter <c>PBEDEFHAHID</c>). The
|
||||||
/// ModerationKick flow. Wire path verified against decompilation:
|
/// value must go out as a NUMBER: release builds obfuscate enum names, so a
|
||||||
/// <c>PushNotificationId.ModerationKick = 22</c> is registered
|
/// string like "AdminPasswordReset" fails the parse and the whole push is
|
||||||
/// by <c>LCCEEFHOBEN.OFFLOPLJBBG</c> via the enum overload.
|
/// dropped. <c>SessionManager.JNNAOHMNGEM</c> shows its own "signed in
|
||||||
/// <c>LCCEEFHOBEN.KEIAFMLIMFA(Dictionary)</c> reads
|
/// elsewhere" text for <see cref="SessionTakeover"/>, then returns the
|
||||||
/// <c>Msg.Reason</c> as a ModerationBlockDetail object, then
|
/// client to the boot scene for every reason.</summary>
|
||||||
/// calls <c>PODFHPNOHGB</c> / <c>BootLocalPlayerToDormRoom</c>.
|
public enum LogoutReason
|
||||||
///
|
{
|
||||||
/// Used by <see cref="Controllers.Match.MatchPlayerController.PlayerLogin"/>
|
Unknown = 0,
|
||||||
/// to enforce single-session-per-account: when a second login on
|
UserInitiated = 1,
|
||||||
/// the same account replaces the LoginLock, the older SignalR
|
SessionTakeover = 2,
|
||||||
/// connections are kicked. The new session hasn't called
|
ForciblyLoggedOut = 3,
|
||||||
/// <c>negotiate</c> yet (it happens AFTER /player/login on the
|
Banned = 4,
|
||||||
/// boot sequence), so the kick only hits old sessions.
|
}
|
||||||
/// </summary>
|
|
||||||
public Task KickStaleSession(long playerId, string reason) =>
|
|
||||||
NotifyAsync(playerId, PushNotificationId.ModerationKick,
|
|
||||||
ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
|
|
||||||
|
|
||||||
public Task KickPlayerAsync(long playerId, string reason, bool isBan = false) =>
|
/// <summary>Log every session of <paramref name="playerId"/> out to the
|
||||||
NotifyAsync(playerId, PushNotificationId.ModerationKick,
|
/// boot scene. Use this, not a kick, when the player must not stay signed
|
||||||
ModerationKickPayload(reason, isBan, source: "admin_kick"));
|
/// in: a ModerationKick only sends them to their dorm.</summary>
|
||||||
|
public Task LogoutAsync(long playerId, LogoutReason reason) =>
|
||||||
|
NotifyAsync(playerId, PushNotificationId.Logout, new { Reason = (int)reason });
|
||||||
|
|
||||||
/// <summary>Push a ModerationKick to a specific set of SignalR
|
/// <summary>Single-session-per-account: when a second login replaces the
|
||||||
/// connection ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/>
|
/// LoginLock, log the older session out. The new session hasn't called
|
||||||
/// to evict the previous session when a new device connects on the
|
/// <c>negotiate</c> yet (it happens AFTER /player/login on the boot
|
||||||
/// same account. Targets specific ids (not the player group) so
|
/// sequence), so only the old session's connections receive it.</summary>
|
||||||
/// the brand-new connection joining the group right after this
|
public Task LogoutStaleSession(long playerId) =>
|
||||||
/// returns can't accidentally receive its own eviction notice.
|
LogoutAsync(playerId, LogoutReason.SessionTakeover);
|
||||||
/// Works across replicas because SignalR's Redis backplane routes
|
|
||||||
|
/// <summary>The same takeover logout, sent to specific SignalR connection
|
||||||
|
/// ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/>
|
||||||
|
/// to evict the previous session when a new device connects on the same
|
||||||
|
/// account. Targets ids (not the player group) so the brand-new
|
||||||
|
/// connection, which joins the group right after, never receives it. Works
|
||||||
|
/// across replicas because the Redis backplane routes
|
||||||
/// <c>Clients.Clients(ids)</c> to whichever node owns each id.</summary>
|
/// <c>Clients.Clients(ids)</c> to whichever node owns each id.</summary>
|
||||||
public Task KickConnectionsAsync(IReadOnlyCollection<string> connectionIds, string reason)
|
public Task LogoutConnectionsAsync(IReadOnlyCollection<string> connectionIds)
|
||||||
{
|
{
|
||||||
if (connectionIds.Count == 0) return Task.CompletedTask;
|
if (connectionIds.Count == 0) return Task.CompletedTask;
|
||||||
var payload = Serialize(PushNotificationId.ModerationKick,
|
var payload = Serialize(PushNotificationId.Logout, new { Reason = (int)LogoutReason.SessionTakeover });
|
||||||
ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
|
|
||||||
return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload);
|
return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object ModerationKickPayload(string reason, bool isBan, string source)
|
/// <summary>Kick <paramref name="playerId"/> back to their dorm with
|
||||||
|
/// <paramref name="reason"/> shown in the kick dialog. The player stays
|
||||||
|
/// signed in; use <see cref="LogoutAsync"/> for bans and deletions.
|
||||||
|
/// Pass the room instance the player is in as
|
||||||
|
/// <paramref name="gameSessionId"/> to have the client announce the kick
|
||||||
|
/// to that room.</summary>
|
||||||
|
public Task KickPlayerAsync(long playerId, string reason, long gameSessionId = 0) =>
|
||||||
|
NotifyAsync(playerId, PushNotificationId.ModerationKick, ModerationKickPayload(reason, gameSessionId));
|
||||||
|
|
||||||
|
/// <summary>ModerationKick "22" payload: the FLAT moderation block
|
||||||
|
/// <c>PIBLFGPOCIB</c> (formatter <c>LAIJIAIHGCO</c>). The kick handler
|
||||||
|
/// (<c>SessionManager</c> HandleModerationKicked) starts from its own
|
||||||
|
/// "Kicked to Dormroom" text and only adds a reason as
|
||||||
|
/// a "Reason: " line from <c>VoteKickReason</c> when <c>ReportCategory</c> is 10
|
||||||
|
/// (VoteKick) and <c>IsHostKick</c> is false; <c>Message</c> is never
|
||||||
|
/// shown. When <c>GameSessionId</c> is the kicked player's current room
|
||||||
|
/// instance it also broadcasts the kick to that room, and
|
||||||
|
/// <c>Duration</c> > 0 starts a timeout, so it stays 0. The old nested
|
||||||
|
/// <c>{Reason:{…}}</c> shape (from the 2020 client) parsed to all defaults,
|
||||||
|
/// which booted the player but dropped every reason.</summary>
|
||||||
|
private static object ModerationKickPayload(string reason, long gameSessionId)
|
||||||
{
|
{
|
||||||
var message = string.IsNullOrWhiteSpace(reason)
|
var message = string.IsNullOrWhiteSpace(reason) ? "Kicked to Dormroom" : reason.Trim();
|
||||||
? "Kicked to Dormroom"
|
|
||||||
: reason.Trim();
|
|
||||||
|
|
||||||
var blockDetail = new
|
|
||||||
{
|
|
||||||
ReportCategory = 10, // PlayerReporting.ReportCategory.VoteKick
|
|
||||||
Duration = 0,
|
|
||||||
GameSessionId = 0L,
|
|
||||||
Message = message,
|
|
||||||
IsHostKick = true,
|
|
||||||
PlayerIdReporter = 1,
|
|
||||||
IsBan = isBan,
|
|
||||||
VoteKickReason = message,
|
|
||||||
};
|
|
||||||
|
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
Reason = blockDetail,
|
ReportCategory = 10, // VoteKick: the only category that shows a reason
|
||||||
DisplayReason = message,
|
Duration = 0,
|
||||||
BannedByPlayerId = 1,
|
GameSessionId = gameSessionId,
|
||||||
Source = source,
|
IsHostKick = false,
|
||||||
|
Message = message,
|
||||||
|
PlayerIdReporter = (int?)null,
|
||||||
|
IsBan = false,
|
||||||
|
IsVoiceModAutoban = false,
|
||||||
|
IsWarning = false,
|
||||||
|
VoteKickReason = message,
|
||||||
|
TimeoutStartedAt = (DateTime?)null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Notify a private-instance owner that someone tried to
|
|
||||||
/// follow them in via "Go to Player" but was rejected for not
|
|
||||||
/// being on the invite list. Reuses the MessageReceived channel
|
|
||||||
/// since the watch already renders that as an inbox toast — no
|
|
||||||
/// new client-side handler needed. The body wording surfaces it
|
|
||||||
/// as "Player X tried to join your private match" so the owner
|
|
||||||
/// knows to send an explicit invite if they want to let them in.</summary>
|
|
||||||
public Task PrivateInstanceJoinRejected(long ownerPlayerId, long attemptedByPlayerId) =>
|
|
||||||
NotifyAsync(ownerPlayerId, PushNotificationId.MessageReceived,
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Reason = "PrivateInstanceJoinRejected",
|
|
||||||
AttemptedBy = attemptedByPlayerId,
|
|
||||||
Preview = $"Player {attemptedByPlayerId} tried to join your private match",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,34 @@ public class OnlinePresenceService
|
|||||||
lock (set) return set.ToArray();
|
lock (set) return set.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Connection ids of every player in <paramref name="playerIds"/>,
|
||||||
|
/// in one go: the Redis lookups are issued together, so the multiplexer
|
||||||
|
/// pipelines them into one round trip instead of one per player.</summary>
|
||||||
|
public async Task<IReadOnlyCollection<string>> GetConnectionsAsync(IEnumerable<long> playerIds)
|
||||||
|
{
|
||||||
|
var ids = playerIds.Distinct().ToArray();
|
||||||
|
if (ids.Length == 0) return Array.Empty<string>();
|
||||||
|
if (_redis is { } mux)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var db = mux.GetDatabase();
|
||||||
|
var sets = await Task.WhenAll(ids.Select(id => db.SetMembersAsync(ConnsKey(id))));
|
||||||
|
return sets.SelectMany(s => s).Select(v => v.ToString()).ToArray();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Redis sick → fall through to the local fallback, as
|
||||||
|
// GetConnections does.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var result = new List<string>();
|
||||||
|
foreach (var id in ids)
|
||||||
|
if (_localConns.TryGetValue(id, out var set))
|
||||||
|
lock (set) result.AddRange(set);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Snapshot of currently-online player ids across the
|
/// <summary>Snapshot of currently-online player ids across the
|
||||||
/// cluster. Returns an empty array when nobody's online; never null.
|
/// cluster. Returns an empty array when nobody's online; never null.
|
||||||
/// Callers typically <c>.ToHashSet()</c> the result for membership
|
/// Callers typically <c>.ToHashSet()</c> the result for membership
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ public static class PresenceVisibility
|
|||||||
/// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into
|
/// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into
|
||||||
/// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync
|
/// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync
|
||||||
/// correction (:18467). A frame about the LOCAL player therefore moves that player, so
|
/// correction (:18467). A frame about the LOCAL player therefore moves that player, so
|
||||||
/// these frames go to friends only, never to the subject.
|
/// these frames never go to the subject. They go to the subject's friends and to every
|
||||||
|
/// other connection that subscribed to the subject with SubscribeToPlayers (see
|
||||||
|
/// NotifyHub.SubscribeToPlayers); non-friends only see the real presence when it is
|
||||||
|
/// Public.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PresenceBroadcastService(
|
public sealed class PresenceBroadcastService(
|
||||||
IServiceScopeFactory scopes,
|
IServiceScopeFactory scopes,
|
||||||
@@ -58,7 +61,7 @@ public sealed class PresenceBroadcastService(
|
|||||||
presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0;
|
presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0;
|
||||||
|
|
||||||
/// <summary>Push the player's current presence (online state + committed
|
/// <summary>Push the player's current presence (online state + committed
|
||||||
/// room) to every friend.</summary>
|
/// room) to every friend and subscriber.</summary>
|
||||||
public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false);
|
public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false);
|
||||||
|
|
||||||
/// <summary>Push an offline frame regardless of any lingering markers —
|
/// <summary>Push an offline frame regardless of any lingering markers —
|
||||||
@@ -105,7 +108,6 @@ public sealed class PresenceBroadcastService(
|
|||||||
.Where(id => id != playerId)
|
.Where(id => id != playerId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
if (friendIds.Count == 0) return;
|
|
||||||
|
|
||||||
var online = !forceOffline && IsOnline(playerId);
|
var online = !forceOffline && IsOnline(playerId);
|
||||||
var room = online ? presence.GetRoom(playerId) : null;
|
var room = online ? presence.GetRoom(playerId) : null;
|
||||||
@@ -134,6 +136,23 @@ public sealed class PresenceBroadcastService(
|
|||||||
await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility);
|
await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility);
|
||||||
if (hiddenFrom.Count > 0)
|
if (hiddenFrom.Count > 0)
|
||||||
await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility);
|
await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility);
|
||||||
|
|
||||||
|
// Everyone else who subscribed to this player (SubscribeToPlayers:
|
||||||
|
// people in the same room, an open profile). They are not friends,
|
||||||
|
// so they see the real presence only when it is Public, and the
|
||||||
|
// offline frame otherwise. Friends were served above and the
|
||||||
|
// subject must never get a frame about themselves, so their
|
||||||
|
// connections are excluded.
|
||||||
|
// Only friends who are online have connections; one call for the
|
||||||
|
// online set, then one pipelined lookup for all of them.
|
||||||
|
var onlineIds = connections.OnlinePlayerIds().ToHashSet();
|
||||||
|
var exclude = (await connections.GetConnectionsAsync(
|
||||||
|
friendIds.Where(onlineIds.Contains).Append(playerId)))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var publicToAll = visibility == PresenceVisibility.Public && online;
|
||||||
|
await notifications.PresenceToSubscribersAsync(
|
||||||
|
playerId, publicToAll ? room : null, publicToAll, visibility, exclude);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS
|
|||||||
/// listed; add more as needed.</summary>
|
/// listed; add more as needed.</summary>
|
||||||
public static class MessageType
|
public static class MessageType
|
||||||
{
|
{
|
||||||
|
public const int FriendInvite = 4;
|
||||||
|
public const int RequestGameInvite = 10;
|
||||||
public const int TextMessage = 30;
|
public const int TextMessage = 30;
|
||||||
public const int FriendRequestAccepted = 40;
|
public const int FriendRequestAccepted = 40;
|
||||||
public const int FriendCodeUsed = 41;
|
public const int FriendCodeUsed = 41;
|
||||||
@@ -39,6 +41,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS
|
|||||||
public const int CreatorPublishedNewRoom = 70;
|
public const int CreatorPublishedNewRoom = 70;
|
||||||
public const int PlayerAttendingEvent = 80;
|
public const int PlayerAttendingEvent = 80;
|
||||||
public const int PlayerEventInvitation = 81;
|
public const int PlayerEventInvitation = 81;
|
||||||
|
public const int CoachMessage = 100;
|
||||||
|
public const int FriendIntroduction = 130;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Persist and push a system notification to
|
/// <summary>Persist and push a system notification to
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ namespace DorkNet.Server.Startup;
|
|||||||
/// <item>SQLite — <c>Database.Migrate()</c> after
|
/// <item>SQLite — <c>Database.Migrate()</c> after
|
||||||
/// <see cref="BaselineExistingSchemaIfNeeded"/> reconciles any legacy
|
/// <see cref="BaselineExistingSchemaIfNeeded"/> reconciles any legacy
|
||||||
/// migration-history state.</item>
|
/// migration-history state.</item>
|
||||||
/// <item>Postgres — <c>EnsureCreated</c> behind a transaction-scoped
|
/// <item>Postgres — <c>EnsureCreated</c>, with the whole bootstrap
|
||||||
/// advisory lock so concurrent replica boots don't race on
|
/// (schema and seeds) behind a session advisory lock so concurrent
|
||||||
/// <c>CREATE TABLE</c>. Connect retries cover the
|
/// replica boots take turns instead of racing. Connect retries cover the
|
||||||
/// container-orchestrated boot window where Postgres DNS isn't
|
/// container-orchestrated boot window where Postgres DNS isn't
|
||||||
/// resolvable yet.</item>
|
/// resolvable yet.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
@@ -40,61 +40,110 @@ public static class DatabaseBootstrap
|
|||||||
await ConfigureSqliteForLocalConcurrencyAsync(db);
|
await ConfigureSqliteForLocalConcurrencyAsync(db);
|
||||||
BaselineExistingSchemaIfNeeded(db);
|
BaselineExistingSchemaIfNeeded(db);
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
|
await RunUpgradesAndSeedsAsync(app, scope, db);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
// Postgres path: schema generated from the EF Core model via
|
||||||
|
// EnsureCreated, then the same upgrades and seeds as SQLite. The
|
||||||
|
// whole bootstrap holds a session-level advisory lock, so replicas
|
||||||
|
// booting against one database take turns: the first creates the
|
||||||
|
// schema and seeds, the rest find everything in place. Holding it
|
||||||
|
// only around EnsureCreated left the seeders racing (duplicate keys
|
||||||
|
// on a fresh database).
|
||||||
|
//
|
||||||
|
// Lock key 0x444F524B (ascii "DORK") is hard-coded; it just needs
|
||||||
|
// to be the same int64 across replicas and not collide with
|
||||||
|
// anything else in the database. It is released in the finally
|
||||||
|
// below, and Postgres drops it if the replica's connection dies, so
|
||||||
|
// a crashed replica can't wedge the next boot.
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
|
||||||
|
// Retry initial Postgres connect — on container-orchestrated boots
|
||||||
|
// (Coolify, docker-compose) the Postgres service's DNS often isn't
|
||||||
|
// resolvable yet when this app starts, surfacing as SocketException
|
||||||
|
// errno=11 EAGAIN from Npgsql's Dns.GetHostEntryOrAddressesCore.
|
||||||
|
// Postgres itself can also be up-but-not-accepting-connections for
|
||||||
|
// a few seconds while it replays WAL. 15 attempts × ~2s backoff
|
||||||
|
// covers the typical 5-30s warmup window without looping forever
|
||||||
|
// on a permanently broken config.
|
||||||
|
var connectLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("DorkNet.Server.Bootstrap");
|
||||||
|
for (var attempt = 1; ; attempt++)
|
||||||
{
|
{
|
||||||
// Postgres path: schema generated from the EF Core model via
|
try { await conn.OpenAsync(); break; }
|
||||||
// EnsureCreated. Wrapped in a transaction-scoped advisory lock
|
catch (Exception ex) when (attempt < 15 &&
|
||||||
// so concurrent replicas booting against the same database
|
(ex is System.Net.Sockets.SocketException
|
||||||
// don't race on CREATE TABLE — only the first replica to take
|
|| ex is Npgsql.NpgsqlException
|
||||||
// the lock runs EnsureCreated, the rest see "schema already
|
|| ex is System.Data.Common.DbException))
|
||||||
// exists" and proceed.
|
|
||||||
//
|
|
||||||
// Lock key 0x444F524B (ascii "DORK") is hard-coded; it just
|
|
||||||
// needs to be the same int64 across replicas and not collide
|
|
||||||
// with anything else in the database. pg_advisory_xact_lock
|
|
||||||
// releases automatically on COMMIT, so a crashed replica can't
|
|
||||||
// wedge the next boot.
|
|
||||||
var conn = db.Database.GetDbConnection();
|
|
||||||
|
|
||||||
// Retry initial Postgres connect — on container-orchestrated boots
|
|
||||||
// (Coolify, docker-compose) the Postgres service's DNS often isn't
|
|
||||||
// resolvable yet when this app starts, surfacing as SocketException
|
|
||||||
// errno=11 EAGAIN from Npgsql's Dns.GetHostEntryOrAddressesCore.
|
|
||||||
// Postgres itself can also be up-but-not-accepting-connections for
|
|
||||||
// a few seconds while it replays WAL. 15 attempts × ~2s backoff
|
|
||||||
// covers the typical 5-30s warmup window without looping forever
|
|
||||||
// on a permanently broken config.
|
|
||||||
var connectLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
|
||||||
.CreateLogger("DorkNet.Server.Bootstrap");
|
|
||||||
for (var attempt = 1; ; attempt++)
|
|
||||||
{
|
{
|
||||||
try { await conn.OpenAsync(); break; }
|
var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5));
|
||||||
catch (Exception ex) when (attempt < 15 &&
|
connectLogger.LogWarning(
|
||||||
(ex is System.Net.Sockets.SocketException
|
"[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s",
|
||||||
|| ex is Npgsql.NpgsqlException
|
attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds);
|
||||||
|| ex is System.Data.Common.DbException))
|
await Task.Delay(delay);
|
||||||
{
|
|
||||||
var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5));
|
|
||||||
connectLogger.LogWarning(
|
|
||||||
"[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s",
|
|
||||||
attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds);
|
|
||||||
await Task.Delay(delay);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await using var tx = await conn.BeginTransactionAsync();
|
|
||||||
await using (var lockCmd = conn.CreateCommand())
|
|
||||||
{
|
|
||||||
lockCmd.Transaction = tx;
|
|
||||||
lockCmd.CommandText = "SELECT pg_advisory_xact_lock(1146246987);";
|
|
||||||
await lockCmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
db.Database.EnsureCreated();
|
|
||||||
await tx.CommitAsync();
|
|
||||||
|
|
||||||
await conn.CloseAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The connection stays open until the finally, so every step below
|
||||||
|
// (they all share this scope's DbContext) runs on the session that
|
||||||
|
// holds the lock. Waiting for it has no command timeout: another
|
||||||
|
// replica may be seeding a fresh database (thumbnail downloads
|
||||||
|
// included) for well over the default 30 s.
|
||||||
|
connectLogger.LogInformation("[bootstrap] waiting for the database bootstrap lock");
|
||||||
|
var previousTimeout = db.Database.GetCommandTimeout();
|
||||||
|
db.Database.SetCommandTimeout(0);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_lock(1146246987);");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
db.Database.SetCommandTimeout(previousTimeout);
|
||||||
|
}
|
||||||
|
connectLogger.LogInformation("[bootstrap] holding the database bootstrap lock");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Begin the transaction through EF, not on the raw connection:
|
||||||
|
// EnsureCreated runs its commands through the migration executor,
|
||||||
|
// which starts its own transaction unless EF already has one, and
|
||||||
|
// fails on a fresh database with "A transaction is already in
|
||||||
|
// progress" when the open one is invisible to it.
|
||||||
|
await using (var tx = await db.Database.BeginTransactionAsync())
|
||||||
|
{
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
await tx.CommitAsync();
|
||||||
|
}
|
||||||
|
await RunUpgradesAndSeedsAsync(app, scope, db);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Explicit: Npgsql pools connections, so closing alone would not
|
||||||
|
// end the session that holds the lock. If the connection is
|
||||||
|
// already broken the unlock fails too; that must not replace the
|
||||||
|
// real bootstrap error, and the connection still gets closed
|
||||||
|
// (Postgres then drops the lock with the session).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_unlock(1146246987);");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
connectLogger.LogWarning(ex, "[bootstrap] releasing the database bootstrap lock failed");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await conn.CloseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The idempotent post-schema sequence both providers run:
|
||||||
|
/// column/table upgrades, system account and backfills, legacy data
|
||||||
|
/// transforms, canonical rooms and the per-feature seeders. Flips the
|
||||||
|
/// <c>/healthz</c> readiness flag at the end.</summary>
|
||||||
|
private static async Task RunUpgradesAndSeedsAsync(WebApplication app, IServiceScope scope, DorkNetDbContext db)
|
||||||
|
{
|
||||||
// Signup-code + pending-device tables. These post-date the
|
// Signup-code + pending-device tables. These post-date the
|
||||||
// consolidated Initial migration, and the Postgres path is
|
// consolidated Initial migration, and the Postgres path is
|
||||||
// EnsureCreated-only (no migration replay), so neither provider
|
// EnsureCreated-only (no migration replay), so neither provider
|
||||||
|
|||||||
@@ -238,6 +238,9 @@ public static class RequestTracingExtensions
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
var path = request.Path.Value ?? "";
|
var path = request.Path.Value ?? "";
|
||||||
|
// Client builds are streamed through (several GB): never buffer them.
|
||||||
|
if (path.StartsWith("/api/site/v1/downloads/files/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
var ext = Path.GetExtension(path);
|
var ext = Path.GetExtension(path);
|
||||||
if (string.IsNullOrEmpty(ext))
|
if (string.IsNullOrEmpty(ext))
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -217,6 +217,13 @@ public static class ServiceCollectionExtensions
|
|||||||
// so a deployment with no GitHub credentials behaves normally.
|
// so a deployment with no GitHub credentials behaves normally.
|
||||||
services.AddSingleton<IGitHubIssues, GitHubIssueService>();
|
services.AddSingleton<IGitHubIssues, GitHubIssueService>();
|
||||||
services.AddHostedService<TestCaseIssueReconciler>();
|
services.AddHostedService<TestCaseIssueReconciler>();
|
||||||
|
// CI-published client builds for the site's Downloads / Quest pages.
|
||||||
|
// Off (IsConfigured=false) without DorkNet:Downloads:BaseUrl. No client
|
||||||
|
// timeout: a multi-GB file streams for longer than the default 100 s.
|
||||||
|
// 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
|
// Scoped (per-request) — these services hold DbContext references and
|
||||||
// must share the request's scope to participate in the request's
|
// must share the request's scope to participate in the request's
|
||||||
// transaction lifecycle. Pre-PR-3 they were Singleton with in-process
|
// transaction lifecycle. Pre-PR-3 they were Singleton with in-process
|
||||||
@@ -416,15 +423,18 @@ public static class ServiceCollectionExtensions
|
|||||||
kopts.Limits.MinResponseDataRate = null;
|
kopts.Limits.MinResponseDataRate = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keepalive cadence: server pings every 10s, gives the client 60s
|
// Keepalive cadence: the server pings every 10s, which keeps frame
|
||||||
// to ack before declaring the connection dead. The 10s ping forces
|
// traffic well below every proxy timeout we've seen in the path
|
||||||
// frame traffic well below every proxy timeout we've seen in the
|
// (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik 180s,
|
||||||
// path (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik
|
// Kestrel 130s). The 2023 client pings every 15s, but from its main
|
||||||
// 180s, Kestrel 130s).
|
// thread, so a long room-load hitch pauses its pings. It has no
|
||||||
|
// timeout of its own and reconnects at most 3 times per login, so a
|
||||||
|
// healthy socket the server drops can leave it without live
|
||||||
|
// notifications for the rest of the session. Give it 180s.
|
||||||
var signalR = builder.Services.AddSignalR(opts =>
|
var signalR = builder.Services.AddSignalR(opts =>
|
||||||
{
|
{
|
||||||
opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
|
opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
|
||||||
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
|
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(180);
|
||||||
opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
|
opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
|
||||||
// Allow large gift / community-board payloads (default 32 KB
|
// Allow large gift / community-board payloads (default 32 KB
|
||||||
// truncates anything bigger and disconnects the client).
|
// truncates anything bigger and disconnects the client).
|
||||||
|
|||||||
@@ -5,18 +5,32 @@ import { useToast } from '../components/Toast';
|
|||||||
|
|
||||||
export function Broadcast() {
|
export function Broadcast() {
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
const [minutes, setMinutes] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
const startsInMinutes = minutes.trim() === '' ? null : Number(minutes);
|
||||||
|
const minutesValid = startsInMinutes === null || (Number.isInteger(startsInMinutes) && startsInMinutes >= 0);
|
||||||
|
const canSend = !busy && minutesValid && (message.trim() !== '' || startsInMinutes !== null);
|
||||||
|
|
||||||
const send = async (e: React.FormEvent) => {
|
const send = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!message.trim()) return;
|
if (!canSend) return;
|
||||||
if (!confirm(`Broadcast this to every connected player?\n\n"${message}"`)) return;
|
const parts = [
|
||||||
|
message.trim() && `"${message.trim()}"`,
|
||||||
|
startsInMinutes !== null &&
|
||||||
|
(startsInMinutes === 0 ? 'Cancel the maintenance countdown' : `Maintenance countdown: ${startsInMinutes} min`),
|
||||||
|
].filter(Boolean);
|
||||||
|
if (!confirm(`Broadcast this to every connected player?\n\n${parts.join('\n')}`)) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api('/broadcast', { method: 'POST', body: { Message: message } });
|
const res = await api<{ recipients: number }>('/broadcast', {
|
||||||
toast.push('Broadcast sent', 'success');
|
method: 'POST',
|
||||||
|
body: { Message: message.trim() || null, StartsInMinutes: startsInMinutes },
|
||||||
|
});
|
||||||
|
toast.push(`Broadcast sent to ${res.recipients} player(s)`, 'success');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
setMinutes('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.push((e as Error).message, 'error');
|
toast.push((e as Error).message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -28,7 +42,7 @@ export function Broadcast() {
|
|||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Broadcast"
|
title="Broadcast"
|
||||||
blurb="Push a server-maintenance notification to every connected player. The watch shows it as a system toast."
|
blurb="Message every connected player. The message lands in their in-game notifications from the system account; the optional countdown starts the game's own maintenance timer."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<form onSubmit={send} className="card !p-5 max-w-2xl">
|
<form onSubmit={send} className="card !p-5 max-w-2xl">
|
||||||
@@ -39,14 +53,33 @@ export function Broadcast() {
|
|||||||
rows={5}
|
rows={5}
|
||||||
className="input mt-1.5 font-mono text-sm"
|
className="input mt-1.5 font-mono text-sm"
|
||||||
placeholder="Server going down for maintenance in 5 minutes — log out cleanly to avoid losing your dorm save."
|
placeholder="Server going down for maintenance in 5 minutes — log out cleanly to avoid losing your dorm save."
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 text-xs text-ink-400">
|
<div className="mt-2 text-xs text-ink-400">
|
||||||
{message.length} characters
|
{message.length} characters
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="label mt-4">Maintenance countdown (minutes, optional)</label>
|
||||||
|
<input
|
||||||
|
value={minutes}
|
||||||
|
onChange={e => setMinutes(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
className="input mt-1.5 w-40 text-sm"
|
||||||
|
placeholder="e.g. 5"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 text-xs text-ink-400">
|
||||||
|
{minutesValid ? 'Leave empty for no countdown; 0 cancels a running one.' : 'Enter a whole number of minutes.'}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex gap-2 justify-end">
|
<div className="mt-4 flex gap-2 justify-end">
|
||||||
<button type="button" onClick={() => setMessage('')} className="btn-ghost text-xs" disabled={busy || !message}>Clear</button>
|
<button
|
||||||
<button className="btn-primary text-xs" disabled={busy || !message.trim()}>
|
type="button"
|
||||||
|
onClick={() => { setMessage(''); setMinutes(''); }}
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
disabled={busy || (!message && !minutes)}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary text-xs" disabled={!canSend}>
|
||||||
{busy ? 'Sending…' : 'Send broadcast'}
|
{busy ? 'Sending…' : 'Send broadcast'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Players } from './pages/Players';
|
|||||||
import { PlayerProfile } from './pages/PlayerProfile';
|
import { PlayerProfile } from './pages/PlayerProfile';
|
||||||
import { Rooms } from './pages/Rooms';
|
import { Rooms } from './pages/Rooms';
|
||||||
import { Join } from './pages/Join';
|
import { Join } from './pages/Join';
|
||||||
|
import { Downloads } from './pages/Downloads';
|
||||||
import { NotFound } from './pages/NotFound';
|
import { NotFound } from './pages/NotFound';
|
||||||
|
|
||||||
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest.
|
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest.
|
||||||
@@ -27,6 +28,7 @@ export function App() {
|
|||||||
<Route path="/rooms" element={<Rooms />} />
|
<Route path="/rooms" element={<Rooms />} />
|
||||||
<Route path="/join" element={<Join />} />
|
<Route path="/join" element={<Join />} />
|
||||||
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
|
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
|
||||||
|
<Route path="/downloads" element={<Downloads />} />
|
||||||
{/* Legacy paths from the vanilla-JS feed.localhost site */}
|
{/* Legacy paths from the vanilla-JS feed.localhost site */}
|
||||||
<Route path="/p/:id" element={<RedirectPhoto />} />
|
<Route path="/p/:id" element={<RedirectPhoto />} />
|
||||||
<Route path="/u/:id" element={<RedirectPlayer />} />
|
<Route path="/u/:id" element={<RedirectPlayer />} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NavLink, Link, useLocation, useNavigate } from 'react-router';
|
import { NavLink, Link, useLocation, useNavigate } from 'react-router';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { BrandMark } from './BrandMark';
|
import { BrandMark } from './BrandMark';
|
||||||
|
import { getDownloads } from '../lib/downloads';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/', label: 'Home', exact: true },
|
{ to: '/', label: 'Home', exact: true },
|
||||||
@@ -10,6 +11,8 @@ const NAV = [
|
|||||||
{ to: '/join', label: 'Join' },
|
{ to: '/join', label: 'Join' },
|
||||||
{ to: '/quest', label: 'Quest' },
|
{ to: '/quest', label: 'Quest' },
|
||||||
];
|
];
|
||||||
|
// Shown only when the server offers downloads (DorkNet:Downloads:BaseUrl).
|
||||||
|
const DOWNLOADS = { to: '/downloads', label: 'Downloads', exact: false };
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -17,6 +20,12 @@ export function Header() {
|
|||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [hasDownloads, setHasDownloads] = useState(false);
|
||||||
|
const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getDownloads().then((m) => setHasDownloads(m !== null)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Close the mobile drawer on route change so tapping a nav item
|
// Close the mobile drawer on route change so tapping a nav item
|
||||||
// doesn't leave a half-open overlay covering the page.
|
// doesn't leave a half-open overlay covering the page.
|
||||||
@@ -37,7 +46,7 @@ export function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center gap-1 ml-2">
|
<nav className="hidden md:flex items-center gap-1 ml-2">
|
||||||
{NAV.map(n => (
|
{navItems.map(n => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={n.to}
|
key={n.to}
|
||||||
to={n.to}
|
to={n.to}
|
||||||
@@ -85,7 +94,7 @@ export function Header() {
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<nav className="flex flex-col gap-1">
|
<nav className="flex flex-col gap-1">
|
||||||
{NAV.map(n => (
|
{navItems.map(n => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={n.to}
|
key={n.to}
|
||||||
to={n.to}
|
to={n.to}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// The client builds CI publishes (ci/downloads/publish.py), served through
|
||||||
|
// the server's DownloadsController. The manifest keeps the snake_case keys
|
||||||
|
// publish.py writes.
|
||||||
|
import { ApiError, get } from './api';
|
||||||
|
|
||||||
|
export interface DownloadFile {
|
||||||
|
id: string; // pc-full | pc-patch | quest | quest-installer
|
||||||
|
platform: 'pc' | 'quest' | string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
sha256: string;
|
||||||
|
build?: string;
|
||||||
|
commit?: string;
|
||||||
|
built_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadRelease {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
game_version?: string;
|
||||||
|
pc_exe?: string;
|
||||||
|
files: DownloadFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadsManifest {
|
||||||
|
releases: DownloadRelease[];
|
||||||
|
updated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: Promise<DownloadsManifest | null> | null = null;
|
||||||
|
|
||||||
|
/** The manifest, or null when this server doesn't offer downloads. Fetched
|
||||||
|
* once per page load; pass `fresh` to re-read it. */
|
||||||
|
export function getDownloads(fresh = false): Promise<DownloadsManifest | null> {
|
||||||
|
if (!cached || fresh) {
|
||||||
|
cached = get<DownloadsManifest>('/downloads').catch((e) => {
|
||||||
|
if (e instanceof ApiError && e.status === 404) return null;
|
||||||
|
cached = null;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fileUrl = (f: DownloadFile) =>
|
||||||
|
`/api/site/v1/downloads/files/${f.path.split('/').map(encodeURIComponent).join('/')}`;
|
||||||
|
|
||||||
|
export interface QuestBuild { release: DownloadRelease; file: DownloadFile }
|
||||||
|
|
||||||
|
/** A published Quest package: the one at `path` when given, otherwise the
|
||||||
|
* newest release's. Null when there's no match. */
|
||||||
|
export function questBuild(m: DownloadsManifest | null, path?: string | null): QuestBuild | null {
|
||||||
|
for (const release of m?.releases ?? []) {
|
||||||
|
const file = release.files.find((f) => f.id === 'quest' && (!path || f.path === path));
|
||||||
|
if (file) return { release, file };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ export async function installXapk(
|
|||||||
let done = 0;
|
let done = 0;
|
||||||
const total = xapk.apk.size;
|
const total = xapk.apk.size;
|
||||||
onProgress({ phase: 'app', label: 'Installing the app', done, total });
|
onProgress({ phase: 'app', label: 'Installing the app', done, total });
|
||||||
const stream = counted(await entryStream(xapk.file, xapk.apk), (n) => {
|
const stream = counted(await entryStream(xapk.source, xapk.apk), (n) => {
|
||||||
done += n;
|
done += n;
|
||||||
onProgress({ phase: 'app', label: 'Installing the app', done, total });
|
onProgress({ phase: 'app', label: 'Installing the app', done, total });
|
||||||
});
|
});
|
||||||
@@ -124,7 +124,7 @@ export async function installXapk(
|
|||||||
|
|
||||||
let done = 0;
|
let done = 0;
|
||||||
onProgress({ phase: 'data', label: 'Copying game data', done, total });
|
onProgress({ phase: 'data', label: 'Copying game data', done, total });
|
||||||
const stream = counted(await entryStream(xapk.file, entry), (n) => {
|
const stream = counted(await entryStream(xapk.source, entry), (n) => {
|
||||||
done += n;
|
done += n;
|
||||||
onProgress({ phase: 'data', label: 'Copying game data', done, total });
|
onProgress({ phase: 'data', label: 'Copying game data', done, total });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,59 @@
|
|||||||
// Read a DorkNet .xapk (zip) straight from a File the player picked, without
|
// Read a DorkNet .xapk (zip) without loading it into memory, either from a
|
||||||
// loading it into memory. DorkNet.Quest/scripts/package.ps1 stores the APK and
|
// File the player picked or from a URL that answers HTTP Range requests (the
|
||||||
// the OBB uncompressed, so those entries are plain byte ranges of the file and
|
// server's latest published build). DorkNet.Quest/scripts/build.py stores the
|
||||||
// stream out with Blob.slice(); manifest.json is deflated and goes through
|
// APK and the OBB uncompressed, so those entries are plain byte ranges that
|
||||||
|
// stream straight through; manifest.json is deflated and goes through
|
||||||
// DecompressionStream. Handles zip64 so packages over 4 GB still open.
|
// DecompressionStream. Handles zip64 so packages over 4 GB still open.
|
||||||
|
|
||||||
|
/** Random access to the package's bytes; `end` is exclusive. */
|
||||||
|
export interface ByteSource {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
read(start: number, end: number): Promise<ArrayBuffer>;
|
||||||
|
stream(start: number, end: number): Promise<ReadableStream<Uint8Array<ArrayBuffer>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileSource(file: File): ByteSource {
|
||||||
|
return {
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
read: (start, end) => file.slice(start, end).arrayBuffer(),
|
||||||
|
stream: async (start, end) => file.slice(start, end).stream(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A package on the server. `size` comes from the downloads manifest; every
|
||||||
|
* read is one Range request. */
|
||||||
|
export function urlSource(url: string, name: string, size: number): ByteSource {
|
||||||
|
const get = async (start: number, end: number): Promise<Response> => {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { headers: { Range: `bytes=${start}-${end - 1}` }, cache: 'no-store' });
|
||||||
|
} catch {
|
||||||
|
// A cross-origin redirect (an expired sign-in at an access proxy) also lands here.
|
||||||
|
throw new Error("Couldn't reach the server. Reload the page (you may need to sign in again) and try again.");
|
||||||
|
}
|
||||||
|
if (res.status !== 206) {
|
||||||
|
throw new Error(res.status === 200
|
||||||
|
? "The server doesn't support partial downloads, so the package can't be read in place."
|
||||||
|
: `Downloading the package failed (HTTP ${res.status}).`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
const empty = () => new ReadableStream<Uint8Array<ArrayBuffer>>({ start: (c) => c.close() });
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
read: async (start, end) => (end <= start ? new ArrayBuffer(0) : (await get(start, end)).arrayBuffer()),
|
||||||
|
stream: async (start, end) => {
|
||||||
|
if (end <= start) return empty();
|
||||||
|
const body = (await get(start, end)).body;
|
||||||
|
if (!body) throw new Error('The server sent an empty response.');
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface XapkEntry {
|
export interface XapkEntry {
|
||||||
name: string;
|
name: string;
|
||||||
method: number; // 0 = stored, 8 = deflate
|
method: number; // 0 = stored, 8 = deflate
|
||||||
@@ -26,7 +76,7 @@ export interface XapkManifest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Xapk {
|
export interface Xapk {
|
||||||
file: File;
|
source: ByteSource;
|
||||||
manifest: XapkManifest;
|
manifest: XapkManifest;
|
||||||
entries: Map<string, XapkEntry>;
|
entries: Map<string, XapkEntry>;
|
||||||
apk: XapkEntry;
|
apk: XapkEntry;
|
||||||
@@ -39,18 +89,18 @@ const ZIP64_EOCD = 0x06064b50;
|
|||||||
const CENTRAL = 0x02014b50;
|
const CENTRAL = 0x02014b50;
|
||||||
const LOCAL = 0x04034b50;
|
const LOCAL = 0x04034b50;
|
||||||
|
|
||||||
async function bytes(file: Blob, start: number, end: number): Promise<DataView> {
|
async function bytes(src: ByteSource, start: number, end: number): Promise<DataView> {
|
||||||
return new DataView(await file.slice(start, end).arrayBuffer());
|
return new DataView(await src.read(start, end));
|
||||||
}
|
}
|
||||||
|
|
||||||
function u64(v: DataView, at: number): number {
|
function u64(v: DataView, at: number): number {
|
||||||
return Number(v.getBigUint64(at, true));
|
return Number(v.getBigUint64(at, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openXapk(file: File): Promise<Xapk> {
|
export async function openXapk(src: ByteSource): Promise<Xapk> {
|
||||||
// End of central directory: last 22 bytes plus up to 64 KiB of comment.
|
// End of central directory: last 22 bytes plus up to 64 KiB of comment.
|
||||||
const tailStart = Math.max(0, file.size - (22 + 0xffff + 20));
|
const tailStart = Math.max(0, src.size - (22 + 0xffff + 20));
|
||||||
const tail = await bytes(file, tailStart, file.size);
|
const tail = await bytes(src, tailStart, src.size);
|
||||||
let eocd = -1;
|
let eocd = -1;
|
||||||
for (let i = tail.byteLength - 22; i >= 0; i--) {
|
for (let i = tail.byteLength - 22; i >= 0; i--) {
|
||||||
if (tail.getUint32(i, true) === EOCD) { eocd = i; break; }
|
if (tail.getUint32(i, true) === EOCD) { eocd = i; break; }
|
||||||
@@ -65,14 +115,14 @@ export async function openXapk(file: File): Promise<Xapk> {
|
|||||||
const loc = eocd - 20;
|
const loc = eocd - 20;
|
||||||
if (loc < 0 || tail.getUint32(loc, true) !== ZIP64_LOCATOR) throw new Error('Unsupported zip64 layout.');
|
if (loc < 0 || tail.getUint32(loc, true) !== ZIP64_LOCATOR) throw new Error('Unsupported zip64 layout.');
|
||||||
const z64At = u64(tail, loc + 8);
|
const z64At = u64(tail, loc + 8);
|
||||||
const z64 = await bytes(file, z64At, z64At + 56);
|
const z64 = await bytes(src, z64At, z64At + 56);
|
||||||
if (z64.getUint32(0, true) !== ZIP64_EOCD) throw new Error('Unsupported zip64 layout.');
|
if (z64.getUint32(0, true) !== ZIP64_EOCD) throw new Error('Unsupported zip64 layout.');
|
||||||
count = u64(z64, 32);
|
count = u64(z64, 32);
|
||||||
cdSize = u64(z64, 40);
|
cdSize = u64(z64, 40);
|
||||||
cdOffset = u64(z64, 48);
|
cdOffset = u64(z64, 48);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cd = await bytes(file, cdOffset, cdOffset + cdSize);
|
const cd = await bytes(src, cdOffset, cdOffset + cdSize);
|
||||||
const entries = new Map<string, XapkEntry>();
|
const entries = new Map<string, XapkEntry>();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let p = 0;
|
let p = 0;
|
||||||
@@ -109,7 +159,7 @@ export async function openXapk(file: File): Promise<Xapk> {
|
|||||||
|
|
||||||
const manifestEntry = entries.get('manifest.json');
|
const manifestEntry = entries.get('manifest.json');
|
||||||
if (!manifestEntry) throw new Error('Not an .xapk: manifest.json is missing.');
|
if (!manifestEntry) throw new Error('Not an .xapk: manifest.json is missing.');
|
||||||
const manifest = JSON.parse(await new Response(await entryStream(file, manifestEntry)).text()) as XapkManifest;
|
const manifest = JSON.parse(await new Response(await entryStream(src, manifestEntry)).text()) as XapkManifest;
|
||||||
if (!manifest.package_name) throw new Error('manifest.json has no package_name.');
|
if (!manifest.package_name) throw new Error('manifest.json has no package_name.');
|
||||||
|
|
||||||
const apk = entries.get(`${manifest.package_name}.apk`)
|
const apk = entries.get(`${manifest.package_name}.apk`)
|
||||||
@@ -122,15 +172,15 @@ export async function openXapk(file: File): Promise<Xapk> {
|
|||||||
return { entry, installPath: x.install_path };
|
return { entry, installPath: x.install_path };
|
||||||
});
|
});
|
||||||
|
|
||||||
return { file, manifest, entries, apk, expansions };
|
return { source: src, manifest, entries, apk, expansions };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The entry's uncompressed bytes as a stream. */
|
/** The entry's uncompressed bytes as a stream. */
|
||||||
export async function entryStream(file: File, entry: XapkEntry): Promise<ReadableStream<Uint8Array>> {
|
export async function entryStream(src: ByteSource, entry: XapkEntry): Promise<ReadableStream<Uint8Array<ArrayBuffer>>> {
|
||||||
const local = await bytes(file, entry.localHeaderOffset, entry.localHeaderOffset + 30);
|
const local = await bytes(src, entry.localHeaderOffset, entry.localHeaderOffset + 30);
|
||||||
if (local.getUint32(0, true) !== LOCAL) throw new Error(`Corrupt entry ${entry.name}.`);
|
if (local.getUint32(0, true) !== LOCAL) throw new Error(`Corrupt entry ${entry.name}.`);
|
||||||
const start = entry.localHeaderOffset + 30 + local.getUint16(26, true) + local.getUint16(28, true);
|
const start = entry.localHeaderOffset + 30 + local.getUint16(26, true) + local.getUint16(28, true);
|
||||||
const raw = file.slice(start, start + entry.compressedSize).stream();
|
const raw = await src.stream(start, start + entry.compressedSize);
|
||||||
if (entry.method === 0) return raw;
|
if (entry.method === 0) return raw;
|
||||||
if (entry.method === 8) return raw.pipeThrough(new DecompressionStream('deflate-raw'));
|
if (entry.method === 8) return raw.pipeThrough(new DecompressionStream('deflate-raw'));
|
||||||
throw new Error(`Unsupported compression in ${entry.name}.`);
|
throw new Error(`Unsupported compression in ${entry.name}.`);
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { fileUrl, getDownloads, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
|
||||||
|
|
||||||
|
const fmtSize = (n: number) =>
|
||||||
|
n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 2 ** 10))} KB`;
|
||||||
|
const fmtDate = (iso?: string) => {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(+d) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATFORMS = [
|
||||||
|
{ id: 'pc', title: 'PC', hint: 'Windows, with or without a VR headset.' },
|
||||||
|
{ id: 'quest', title: 'Meta Quest', hint: 'Needs developer mode on your headset and a USB cable.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// What each file is for, in players' terms. Keyed by the ids publish.py writes.
|
||||||
|
function describe(f: DownloadFile, rel: DownloadRelease): { title: string; primary?: boolean; body: React.ReactNode } {
|
||||||
|
switch (f.id) {
|
||||||
|
case 'pc-full':
|
||||||
|
return {
|
||||||
|
title: 'Full game', primary: true,
|
||||||
|
body: <>Rec Room {rel.title} with DorkNet already set up. Unzip it anywhere and
|
||||||
|
run {rel.pc_exe ? <code className="text-ink-200">{rel.pc_exe}</code> : 'the game'}.</>,
|
||||||
|
};
|
||||||
|
case 'pc-patch':
|
||||||
|
return {
|
||||||
|
title: 'Patch only',
|
||||||
|
body: <>Just the DorkNet part (MelonLoader and the DorkNet mod), for a copy of the game you already have. The
|
||||||
|
DorkNet launcher installs this for you; skip it if you got the full game.</>,
|
||||||
|
};
|
||||||
|
case 'quest':
|
||||||
|
return {
|
||||||
|
title: 'Quest app', primary: true,
|
||||||
|
body: <>Install it straight from this site: plug in your headset and use the <Link className="text-brand-300 hover:text-brand-200" to="/quest">Quest
|
||||||
|
page</Link> (Chrome or Edge). Or download it for SideQuest.</>,
|
||||||
|
};
|
||||||
|
case 'quest-installer':
|
||||||
|
return {
|
||||||
|
title: 'Install scripts',
|
||||||
|
body: <>Prefer the command line? Unzip these next to the Quest app and run <code className="text-ink-200">install.cmd</code> (Windows)
|
||||||
|
or <code className="text-ink-200">install.sh</code> (macOS / Linux). Needs adb.</>,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { title: f.name, body: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) {
|
||||||
|
const d = describe(f, rel);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(f.sha256);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch { /* clipboard blocked: the full hash is in the tooltip */ }
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<article className={`card !p-5 flex flex-col gap-3 ${d.primary ? 'border-brand-500/50 ring-2 ring-brand-500/15' : ''}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-ink-50">{d.title}</h3>
|
||||||
|
{d.primary && <span className="badge bg-brand-500/15 text-brand-200">Start here</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-ink-300">{d.body}</p>
|
||||||
|
<div className="font-mono text-xs text-ink-400 break-all">{f.name}</div>
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-ink-400">
|
||||||
|
<span>{fmtSize(f.size)}</span>
|
||||||
|
{f.built_at && <span>Updated {fmtDate(f.built_at)}</span>}
|
||||||
|
{f.build && <span>Build {f.build}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto flex flex-wrap items-center gap-2 pt-1">
|
||||||
|
{f.id === 'quest' ? (
|
||||||
|
<>
|
||||||
|
<Link className="btn-primary" to={`/quest?file=${encodeURIComponent(f.path)}`}>Install on Quest</Link>
|
||||||
|
<a className="btn-secondary" href={fileUrl(f)} download={f.name}>Download</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<a className={d.primary ? 'btn-primary' : 'btn-secondary'} href={fileUrl(f)} download={f.name}>Download</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copy}
|
||||||
|
title={`Copy SHA-256: ${f.sha256}`}
|
||||||
|
className={`rounded-md border border-dashed px-2 py-1 font-mono text-xs transition-colors ${
|
||||||
|
copied ? 'border-success text-success' : 'border-ink-700 text-ink-400 hover:text-ink-100'}`}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied' : `SHA-256 ${f.sha256.slice(0, 12)}…`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Downloads() {
|
||||||
|
const [m, setM] = useState<DownloadsManifest | null | undefined>(undefined);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getDownloads(true).then(setM).catch((e) => setErr((e as Error).message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const releases = (m?.releases ?? []).filter((r) => r.files.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-ink-50">Downloads</h1>
|
||||||
|
<p className="text-sm text-ink-400">
|
||||||
|
Rec Room for DorkNet, built from the DorkNet source by the build server. The SHA-256 next to each file
|
||||||
|
lets you check your download.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">Couldn't load the builds: {err}</div>}
|
||||||
|
{m === undefined && !err && <p className="text-sm text-ink-400">Loading builds…</p>}
|
||||||
|
{m === null && <p className="text-sm text-ink-400">This server doesn't offer downloads.</p>}
|
||||||
|
{m && !releases.length && <p className="text-sm text-ink-400">No builds have been published yet.</p>}
|
||||||
|
|
||||||
|
{releases.map((rel) => (
|
||||||
|
<section key={rel.id} className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3">
|
||||||
|
<h2 className="text-lg font-semibold text-ink-50">Rec Room {rel.title}</h2>
|
||||||
|
{rel.game_version && <span className="text-xs text-ink-400">Game build of {fmtDate(`${rel.game_version}T12:00:00Z`)}</span>}
|
||||||
|
</div>
|
||||||
|
{PLATFORMS.map((p) => {
|
||||||
|
const files = rel.files.filter((f) => f.platform === p.id);
|
||||||
|
if (!files.length) return null;
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="label">{p.title}</h3>
|
||||||
|
<p className="text-xs text-ink-400">{p.hint}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{files.map((f) => <FileCard key={f.id} f={f} rel={rel} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{releases.length > 0 && (
|
||||||
|
<p className="border-t border-ink-800 pt-4 text-xs text-ink-400">
|
||||||
|
These builds contain Rec Room's own game files. They're for this community only: please don't re-share them.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { openXapk, type Xapk } from '../lib/xapk';
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk';
|
||||||
import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall';
|
import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall';
|
||||||
|
import { fileUrl, getDownloads, questBuild, type QuestBuild } from '../lib/downloads';
|
||||||
|
|
||||||
const fmtSize = (n: number) =>
|
const fmtSize = (n: number) =>
|
||||||
n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`;
|
n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`;
|
||||||
@@ -43,15 +45,36 @@ export function Quest() {
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
// The server's latest published Quest build, if it offers downloads.
|
||||||
|
const [latest, setLatest] = useState<QuestBuild | null>(null);
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
|
||||||
const pickFile = async (f: File | undefined) => {
|
const open = async (load: () => Promise<Xapk>) => {
|
||||||
if (!f) return;
|
|
||||||
setErr(null); setDone(false); setProgress({}); setBusy('file');
|
setErr(null); setDone(false); setProgress({}); setBusy('file');
|
||||||
try { setXapk(await openXapk(f)); }
|
try { setXapk(await load()); }
|
||||||
catch (e) { setXapk(null); setErr((e as Error).message); }
|
catch (e) { setXapk(null); setErr((e as Error).message); }
|
||||||
finally { setBusy(null); }
|
finally { setBusy(null); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pickFile = async (f: File | undefined) => {
|
||||||
|
if (f) await open(() => openXapk(fileSource(f)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const 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 () => {
|
const connect = async () => {
|
||||||
setErr(null); setBusy('connect');
|
setErr(null); setBusy('connect');
|
||||||
try { setHeadset(await connectHeadset()); }
|
try { setHeadset(await connectHeadset()); }
|
||||||
@@ -108,19 +131,32 @@ export function Quest() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Step n={1} title="Choose the .xapk file" done={!!xapk}>
|
<Step n={1} title={latest ? 'Pick the build' : 'Choose the .xapk file'} done={!!xapk}>
|
||||||
<p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p>
|
{latest ? (
|
||||||
|
<p className="text-sm text-ink-400">
|
||||||
|
Install the latest build straight from this server: nothing to download first, it streams to the
|
||||||
|
headset during the install. Or pick a <code className="text-ink-200">.xapk</code> you already have.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p>
|
||||||
|
)}
|
||||||
<input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} />
|
<input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} />
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<button className="btn-secondary" disabled={busy !== null} onClick={() => fileRef.current?.click()}>
|
{latest && (
|
||||||
{busy === 'file' ? 'Reading…' : xapk ? 'Choose a different file' : 'Choose file'}
|
<button className="btn-primary" disabled={busy !== null} onClick={() => pickBuild(latest)}>
|
||||||
</button>
|
{busy === 'file' ? 'Reading…' : `Latest build: ${latest.release.title}${latest.file.build ? ` (build ${latest.file.build})` : ''}`}
|
||||||
{xapk && (
|
</button>
|
||||||
<span className="text-sm text-ink-300">
|
|
||||||
<span className="text-ink-100 font-medium">{name}</span> {xapk.manifest.version_name} · {fmtSize(totalSize)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
<button className="btn-secondary" disabled={busy !== null} onClick={() => fileRef.current?.click()}>
|
||||||
|
{busy === 'file' && !latest ? 'Reading…' : xapk && !latest ? 'Choose a different file' : 'Choose file'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{xapk && (
|
||||||
|
<p className="text-sm text-ink-300">
|
||||||
|
Selected: <span className="text-ink-100 font-medium">{name}</span> {xapk.manifest.version_name} · {fmtSize(totalSize)}
|
||||||
|
<span className="text-ink-500"> · {xapk.source.name}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
<Step n={2} title="Connect your headset" done={!!headset}>
|
<Step n={2} title="Connect your headset" done={!!headset}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
Vendored
+240
@@ -0,0 +1,240 @@
|
|||||||
|
// 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).
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// On a push, only the platforms whose code changed are rebuilt. 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.
|
||||||
|
|
||||||
|
// The builds this branch targets. Any other archived build can be picked per
|
||||||
|
// run with the *_SNAPSHOT_TAGS parameters.
|
||||||
|
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' }
|
||||||
|
|
||||||
|
options {
|
||||||
|
timestamps()
|
||||||
|
ansiColor('xterm')
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
// Full clients and xapks are several GB each: keep artifacts for the
|
||||||
|
// last few runs only.
|
||||||
|
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '3'))
|
||||||
|
timeout(time: 3, unit: 'HOURS')
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
choice(name: 'PLATFORMS', choices: ['both', 'pc', 'quest'],
|
||||||
|
description: 'What to build on a manual run. Push builds rebuild whatever changed.')
|
||||||
|
booleanParam(name: 'PC_FULL_CLIENT', defaultValue: true,
|
||||||
|
description: 'Also build the full PC client (stock build + MelonLoader + mod + config). The patcher zip is always built.')
|
||||||
|
string(name: 'PC_SNAPSHOT_TAGS', defaultValue: '',
|
||||||
|
description: "restic tags picking the stock PC build, comma = AND. Empty: ${PC_DEFAULT_TAGS}")
|
||||||
|
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.')
|
||||||
|
string(name: 'QUEST_REPO', defaultValue: '',
|
||||||
|
description: 'Quest restic repository folder on the FTP. Empty: ARCHIVE_QUEST_REPO set on the agent.')
|
||||||
|
booleanParam(name: 'LIST_SNAPSHOTS', defaultValue: false,
|
||||||
|
description: 'Only list the snapshots matching the tags in both repositories (to find a build), then stop.')
|
||||||
|
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 {
|
||||||
|
stage('Plan') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def pushed = !currentBuild.getBuildCauses('com.cloudbees.jenkins.GitHubPushCause').isEmpty() ||
|
||||||
|
!currentBuild.getBuildCauses('hudson.triggers.SCMTrigger$SCMTriggerCause').isEmpty()
|
||||||
|
if (params.LIST_SNAPSHOTS) {
|
||||||
|
buildPc = false
|
||||||
|
buildQuest = false
|
||||||
|
} else if (pushed) {
|
||||||
|
def changed = []
|
||||||
|
for (cs in currentBuild.changeSets) {
|
||||||
|
for (entry in cs.items) changed.addAll(entry.affectedPaths)
|
||||||
|
}
|
||||||
|
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']
|
||||||
|
buildQuest = params.PLATFORMS in ['both', 'quest']
|
||||||
|
}
|
||||||
|
echo "Build PC: ${buildPc}, Quest: ${buildQuest}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('List snapshots') {
|
||||||
|
when { expression { params.LIST_SNAPSHOTS } }
|
||||||
|
steps {
|
||||||
|
withArchive {
|
||||||
|
withEnv(["PC_REPO_OVERRIDE=${params.PC_REPO}", "QUEST_REPO_OVERRIDE=${params.QUEST_REPO}",
|
||||||
|
"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}}"
|
||||||
|
echo "== PC: $pc"; archive-restore list "$pc" "$PC_TAGS"
|
||||||
|
echo "== Quest: $quest"; archive-restore list "$quest" "$QUEST_TAGS"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('PC patcher zip') {
|
||||||
|
when { expression { buildPc } }
|
||||||
|
steps {
|
||||||
|
sh "python3 tools/build_pc.py patcher --version-key ${PC_VERSION_KEY} --output-dir out/pc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('PC full client') {
|
||||||
|
when { expression { buildPc && params.PC_FULL_CLIENT } }
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def base = withArchive {
|
||||||
|
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}}"
|
||||||
|
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/pc" --exclude /.DepotDownloader
|
||||||
|
''').trim().readLines().last()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withCredentials([file(credentialsId: 'clientmod-config', variable: 'CLIENTMOD_CONFIG')]) {
|
||||||
|
withEnv(["BASE_CLIENT=${base}"]) {
|
||||||
|
sh """
|
||||||
|
python3 tools/build_pc.py client \\
|
||||||
|
--base-client "\$BASE_CLIENT" \\
|
||||||
|
--patcher-zip out/pc/dorknet-clientpatch-${PC_VERSION_KEY}.zip \\
|
||||||
|
--config "\$CLIENTMOD_CONFIG" \\
|
||||||
|
--output-zip out/pc/dorknet-client-${PC_VERSION_KEY}.zip
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Quest') {
|
||||||
|
when { expression { buildQuest } }
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def stock = withArchive {
|
||||||
|
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}}"
|
||||||
|
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/quest"
|
||||||
|
''').trim().readLines().last()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withCredentials([
|
||||||
|
file(credentialsId: 'quest-keystore', variable: 'QUEST_KEYSTORE'),
|
||||||
|
string(credentialsId: 'quest-keystore-password', variable: 'QUEST_KEYSTORE_PASSWORD'),
|
||||||
|
file(credentialsId: 'quest-config', variable: 'QUEST_CONFIG'),
|
||||||
|
]) {
|
||||||
|
withEnv(["QUEST_STOCK=${stock}"]) {
|
||||||
|
// The hook table for the build's versionCode must already be
|
||||||
|
// resolved and committed under DorkNet.Quest/targets/; build.py
|
||||||
|
// checks it against the APK's libil2cpp.so.
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
obb="$(ls "$QUEST_STOCK"/obbs/main.*.obb | head -n1)"
|
||||||
|
code="$(basename "$obb" | cut -d. -f2)"
|
||||||
|
table="$(ls DorkNet.Quest/targets/*-"$code".json 2>/dev/null | head -n1 || true)"
|
||||||
|
if [ -z "$table" ]; then
|
||||||
|
echo "No resolved hook table for versionCode $code in DorkNet.Quest/targets/." >&2
|
||||||
|
echo "Resolve one locally with build.py build --dump-cs and commit it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p out/quest
|
||||||
|
python3 DorkNet.Quest/scripts/build.py build \
|
||||||
|
--apk "$QUEST_STOCK/app.apk" --obb "$obb" \
|
||||||
|
--targets-json "$table" \
|
||||||
|
--config "$QUEST_CONFIG" \
|
||||||
|
--keystore "$QUEST_KEYSTORE" --keystore-pass "$QUEST_KEYSTORE_PASSWORD" \
|
||||||
|
--work-dir "$WORKSPACE/quest-work" \
|
||||||
|
--out-apk "$WORKSPACE/out/quest/dorknet-recroom-$code.apk"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
cleanup {
|
||||||
|
// Runs after archiving. Keeps the restic cache and restored stock
|
||||||
|
// builds ($DORKNET_CACHE on the agent, outside the workspace); drops
|
||||||
|
// the workspace's build output and scratch.
|
||||||
|
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FTP + restic credentials for archive-restore.
|
||||||
|
def withArchive(Closure body) {
|
||||||
|
withCredentials([
|
||||||
|
usernamePassword(credentialsId: 'archive-ftp', usernameVariable: 'ARCHIVE_FTP_USER', passwordVariable: 'ARCHIVE_FTP_PASSWORD'),
|
||||||
|
string(credentialsId: 'archive-ftp-host', variable: 'ARCHIVE_FTP_HOST'),
|
||||||
|
string(credentialsId: 'archive-restic-password', variable: 'RESTIC_PASSWORD'),
|
||||||
|
]) {
|
||||||
|
return body()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,10 +38,12 @@ What the 2023 client gets here that the 2020 branches don't have:
|
|||||||
`DorkNet.Server.Tests/ClientRouteCoverageTests.cs` fails the build if one
|
`DorkNet.Server.Tests/ClientRouteCoverageTests.cs` fails the build if one
|
||||||
regresses. Routes, verbs and wire shapes are in
|
regresses. Routes, verbs and wire shapes are in
|
||||||
[docs/recroom-2023-client-api-complete.md](docs/recroom-2023-client-api-complete.md).
|
[docs/recroom-2023-client-api-complete.md](docs/recroom-2023-client-api-complete.md).
|
||||||
- **Realtime over the SignalR hub** (`hub/v1` on the notify host): numeric
|
- **Realtime over the SignalR hub** (`hub/v1` on the notify host): every
|
||||||
push ids, presence updates to friends that honour each player's
|
push matches the client's own reader — friends, messages, kicks and
|
||||||
online-status visibility, and account updates when someone edits their
|
logouts, gifts, balances, consumables, player events, level-ups and
|
||||||
profile.
|
account updates — and presence reaches both friends and any player who
|
||||||
|
subscribed to you, honouring your online-status visibility:
|
||||||
|
[docs/recroom-2023-notifications.md](docs/recroom-2023-notifications.md).
|
||||||
- **Room saves and sub-rooms** through the storage host, plus cloning and
|
- **Room saves and sub-rooms** through the storage host, plus cloning and
|
||||||
private instances:
|
private instances:
|
||||||
[docs/recroom-2023-room-save.md](docs/recroom-2023-room-save.md).
|
[docs/recroom-2023-room-save.md](docs/recroom-2023-room-save.md).
|
||||||
@@ -62,7 +64,7 @@ What the 2023 client gets here that the 2020 branches don't have:
|
|||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
**1. Install .NET 9 SDK** and (optionally) `dotnet-ef`:
|
**1. Install .NET 10 SDK** and (optionally) `dotnet-ef`:
|
||||||
|
|
||||||
```pwsh
|
```pwsh
|
||||||
dotnet tool install --global dotnet-ef
|
dotnet tool install --global dotnet-ef
|
||||||
@@ -99,7 +101,7 @@ cd DorkNet
|
|||||||
dotnet run --project DorkNet.Server
|
dotnet run --project DorkNet.Server
|
||||||
```
|
```
|
||||||
|
|
||||||
First boot creates `bin/Debug/net9.0/data/dorknet.db`, runs every
|
First boot creates `bin/Debug/net10.0/data/dorknet.db`, runs every
|
||||||
`LegacyUpgrades` pass, seeds canonical rooms / store catalog / club
|
`LegacyUpgrades` pass, seeds canonical rooms / store catalog / club
|
||||||
categories / playlists, and starts listening. You should see
|
categories / playlists, and starts listening. You should see
|
||||||
`Application started.` in the log; `curl http://localhost:8080/healthz`
|
`Application started.` in the log; `curl http://localhost:8080/healthz`
|
||||||
@@ -214,6 +216,16 @@ keep the prefab value) trade per-frame size for bandwidth if a high FPS
|
|||||||
saturates the link. Only the player *sharing* needs the override. Default
|
saturates the link. Only the player *sharing* needs the override. Default
|
||||||
`0` (off).
|
`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 quest team size (opt-in)
|
||||||
|
|
||||||
RRO quests ship with a baked 4-player `GameConfigurationAsset` team cap.
|
RRO quests ship with a baked 4-player `GameConfigurationAsset` team cap.
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
#!/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()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Shell scripts run inside the Linux image; keep them LF on every checkout.
|
||||||
|
*.sh text eol=lf
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# DorkNet build agent: a Jenkins inbound agent with everything the PC and
|
||||||
|
# Quest client builds need. It runs next to Jenkins on Coolify, connects to
|
||||||
|
# the controller over WebSocket, and runs the jobs labelled "dorknet-builder"
|
||||||
|
# (the Jenkinsfile at the repo root). Jenkins itself stays on the stock image.
|
||||||
|
#
|
||||||
|
# PC Python + .NET SDK tools/build_pc.py
|
||||||
|
# Quest Python/LIEF + JDK + Android NDK, build-tools, apktool
|
||||||
|
# DorkNet.Quest/scripts/build.py
|
||||||
|
# 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}
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# ---- base tools + .NET SDK (Microsoft's Debian feed for the image's own
|
||||||
|
# Debian release) -----------------------------------------------------------
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl wget unzip zip bzip2 python3 python3-venv \
|
||||||
|
&& . /etc/os-release \
|
||||||
|
&& wget -q "https://packages.microsoft.com/config/debian/${VERSION_ID}/packages-microsoft-prod.deb" -O /tmp/ms.deb \
|
||||||
|
&& dpkg -i /tmp/ms.deb && rm /tmp/ms.deb \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends dotnet-sdk-10.0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ---- Python with LIEF (DT_NEEDED injection into libmain.so) ---------------
|
||||||
|
# build.py runs its helpers with -I (isolated), which ignores PYTHONPATH and
|
||||||
|
# user site-packages, so LIEF goes into the interpreter that is first on PATH.
|
||||||
|
RUN python3 -m venv /opt/py \
|
||||||
|
&& /opt/py/bin/pip install --no-cache-dir lief==0.15.1
|
||||||
|
ENV PATH="/opt/py/bin:${PATH}"
|
||||||
|
|
||||||
|
# ---- Android SDK: build-tools (zipalign, apksigner, aapt2) + NDK ----------
|
||||||
|
ENV ANDROID_SDK_ROOT=/opt/android-sdk
|
||||||
|
ARG CMDLINE_TOOLS=11076708
|
||||||
|
ARG BUILD_TOOLS=34.0.0
|
||||||
|
ARG NDK=27.2.12479018
|
||||||
|
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
|
||||||
|
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip -O /tmp/clt.zip \
|
||||||
|
&& unzip -q /tmp/clt.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && rm /tmp/clt.zip \
|
||||||
|
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest \
|
||||||
|
&& (yes || true) | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null \
|
||||||
|
&& ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install \
|
||||||
|
"build-tools;${BUILD_TOOLS}" "ndk;${NDK}" > /dev/null \
|
||||||
|
&& chown -R jenkins:jenkins ${ANDROID_SDK_ROOT}
|
||||||
|
ENV ANDROID_NDK=${ANDROID_SDK_ROOT}/ndk/${NDK}
|
||||||
|
|
||||||
|
# ---- apktool ---------------------------------------------------------------
|
||||||
|
ARG APKTOOL=2.9.3
|
||||||
|
RUN wget -q https://github.com/iBotPeaches/Apktool/releases/download/v${APKTOOL}/apktool_${APKTOOL}.jar \
|
||||||
|
-O ${ANDROID_SDK_ROOT}/apktool.jar
|
||||||
|
ENV APKTOOL_JAR=${ANDROID_SDK_ROOT}/apktool.jar
|
||||||
|
|
||||||
|
# ---- restic + rclone (restic reaches the FTP through rclone) --------------
|
||||||
|
ARG RESTIC=0.18.1
|
||||||
|
ARG RCLONE=1.68.2
|
||||||
|
RUN wget -q https://github.com/restic/restic/releases/download/v${RESTIC}/restic_${RESTIC}_linux_amd64.bz2 -O /tmp/restic.bz2 \
|
||||||
|
&& bunzip2 /tmp/restic.bz2 \
|
||||||
|
&& install -m 0755 /tmp/restic /usr/local/bin/restic && rm /tmp/restic \
|
||||||
|
&& wget -q https://downloads.rclone.org/v${RCLONE}/rclone-v${RCLONE}-linux-amd64.zip -O /tmp/rclone.zip \
|
||||||
|
&& unzip -q /tmp/rclone.zip -d /tmp/rclone \
|
||||||
|
&& install -m 0755 /tmp/rclone/rclone-v${RCLONE}-linux-amd64/rclone /usr/local/bin/rclone \
|
||||||
|
&& rm -rf /tmp/rclone /tmp/rclone.zip
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
|
||||||
|
DOTNET_NOLOGO=1
|
||||||
|
|
||||||
|
USER jenkins
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# Client builds on Jenkins
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|---|---|---|
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Where the stock builds come from
|
||||||
|
|
||||||
|
The stock builds live in restic repositories on the archive FTP. They are the
|
||||||
|
same repositories as the local archive's `v2/steam-v2` and `v2/quest-v2`. restic
|
||||||
|
reaches the FTP through its rclone backend (`ci/jenkins/archive-restore.sh`).
|
||||||
|
|
||||||
|
A build is picked by **snapshot tags**:
|
||||||
|
|
||||||
|
| Repository | Tags (examples) |
|
||||||
|
|---|---|
|
||||||
|
| Steam | `manifest=<depot manifest id>`, `date=…`, `branch=public` |
|
||||||
|
| Quest | `version=20230317`, `version-code=2128210` |
|
||||||
|
|
||||||
|
The job defaults to the builds this branch targets: Steam manifest
|
||||||
|
`7490748483298966814` (2023-03-21) and Quest `version-code=2128210`. Any other
|
||||||
|
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 Quest build needs a resolved hook table for its versionCode, committed under
|
||||||
|
`DorkNet.Quest/targets/`. Only `20230317-2128210.json` exists today. The build
|
||||||
|
checks the table against the APK's `libil2cpp.so` and stops on a mismatch.
|
||||||
|
Resolve a table for another build locally with `build.py build --dump-cs`.
|
||||||
|
|
||||||
|
## Triggers
|
||||||
|
|
||||||
|
- **Push** to the branch (GitHub webhook) rebuilds only what changed:
|
||||||
|
- `DorkNet.ClientMod/` and the PC scripts rebuild PC;
|
||||||
|
- `DorkNet.Quest/` rebuilds Quest;
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Jenkins itself
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- **Java 25.** The file defaults to `jenkins/jenkins:lts-jdk25`; set
|
||||||
|
`JENKINS_IMAGE` to pin a version, e.g. `jenkins/jenkins:2.587-jdk25`.
|
||||||
|
Jenkins supports Java 25 until September 2029, Java 21 only until September
|
||||||
|
2027, and Java 17 not at all. Keep the agent on the same Java major version;
|
||||||
|
its image defaults to Java 25.
|
||||||
|
- **`restart: unless-stopped`** is in the file: a restart from the Jenkins UI
|
||||||
|
exits the container with code 5 and relies on Docker to start it again.
|
||||||
|
- **Turn on "Connect to predefined network"** (Advanced) so the agent can
|
||||||
|
reach Jenkins by its container name, `jenkins-<uuid>`.
|
||||||
|
- **Run no builds on it:** **Manage Jenkins → Nodes → Built-In Node →
|
||||||
|
Configure**, set **Number of executors** to `0`.
|
||||||
|
|
||||||
|
Earlier versions of this file built the controller from `ci/jenkins/Dockerfile`,
|
||||||
|
which is now the agent's image. The service and data volume keep their names,
|
||||||
|
so an existing Jenkins deployed from this file keeps its jobs and credentials
|
||||||
|
when redeployed; it switches to the stock image, and its builds wait until
|
||||||
|
the agent (step 3) is connected.
|
||||||
|
|
||||||
|
That redeploy also upgrades Jenkins, and from Java 17 (the old default) to
|
||||||
|
Java 25, unless `JENKINS_IMAGE` pins your current image. Before it:
|
||||||
|
|
||||||
|
1. **Back up the data volume** (`jenkins_home`), e.g. with a Coolify volume
|
||||||
|
backup or `docker run --rm -v <volume>:/v -v "$PWD":/b alpine tar czf
|
||||||
|
/b/jenkins_home.tgz -C /v .`, so you can go back to the old image.
|
||||||
|
2. After it starts, open **Manage Jenkins**. Plugins that need a newer
|
||||||
|
version show up there as failed to load; update them under **Plugins →
|
||||||
|
Updates** and restart.
|
||||||
|
3. Run the job once (**LIST_SNAPSHOTS** is a quick check) before relying on it.
|
||||||
|
|
||||||
|
### 2. The agent node
|
||||||
|
|
||||||
|
**Manage Jenkins → Nodes → New Node**, a permanent agent:
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| Name | `dorknet-builder` |
|
||||||
|
| Labels | `dorknet-builder` |
|
||||||
|
| Remote root directory | `/home/jenkins/agent` |
|
||||||
|
| Launch method | Launch agent by connecting it to the controller, with **Use WebSocket** ticked |
|
||||||
|
| Number of executors | 1 |
|
||||||
|
|
||||||
|
Save, open the node, and copy its **secret**.
|
||||||
|
|
||||||
|
### 3. Deploy the agent on Coolify
|
||||||
|
|
||||||
|
1. **Create a Docker Compose application** from this repository: build pack
|
||||||
|
Docker Compose, base directory **`/ci/jenkins`**, compose location
|
||||||
|
**`/docker-compose.agent.yml`**. The base directory is the build context, so with
|
||||||
|
`/` the build would pick up the server's `Dockerfile` at the repo root. It
|
||||||
|
needs no domain. Under Advanced, turn on **Connect to predefined network**.
|
||||||
|
2. **Set its environment:**
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
|---|---|
|
||||||
|
| `JENKINS_URL` | Jenkins' **internal** address, `http://jenkins-<service uuid>:8080/`. Not the public URL, which is behind Cloudflare Access. |
|
||||||
|
| `JENKINS_SECRET` | the node secret from step 2 |
|
||||||
|
| `JENKINS_AGENT_NAME` | optional, default `dorknet-builder` |
|
||||||
|
| `ARCHIVE_PC_REPO` | Steam repository folder on the FTP, e.g. `Rec Room Versions/steam-v2` (plain text, not URL-encoded) |
|
||||||
|
| `ARCHIVE_QUEST_REPO` | Quest repository folder, e.g. `Rec Room Versions/quest-v2` |
|
||||||
|
| `ARCHIVE_FTP_TLS` | `explicit` (default), `implicit` or `none` |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
Both Jenkins and the agent join the server's predefined Coolify network, so
|
||||||
|
the agent reaches Jenkins by container name. Check the name under the Jenkins
|
||||||
|
service's containers before using it.
|
||||||
|
|
||||||
|
### 4. Plugins
|
||||||
|
|
||||||
|
The job needs these plugins:
|
||||||
|
|
||||||
|
- Pipeline
|
||||||
|
- Git
|
||||||
|
- GitHub (for the push trigger)
|
||||||
|
- Credentials Binding
|
||||||
|
- Timestamper
|
||||||
|
- AnsiColor
|
||||||
|
- Workspace Cleanup
|
||||||
|
|
||||||
|
Most are part of the suggested set.
|
||||||
|
|
||||||
|
### 5. Credentials
|
||||||
|
|
||||||
|
Add these under **Manage Jenkins → Credentials**, with exactly these IDs:
|
||||||
|
|
||||||
|
| ID | Kind | Value |
|
||||||
|
|---|---|---|
|
||||||
|
| `archive-ftp` | Username with password | the FTP login |
|
||||||
|
| `archive-ftp-host` | Secret text | FTP host name only (no `ftp://`, no user, no path) |
|
||||||
|
| `archive-restic-password` | Secret text | the archive's restic password |
|
||||||
|
| `quest-keystore` | Secret file | the Quest signing keystore |
|
||||||
|
| `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
|
||||||
|
|
||||||
|
1. Create a **New Item → Pipeline** (e.g. `dorknet-client`).
|
||||||
|
2. Under **Pipeline**, choose **Pipeline script from SCM**:
|
||||||
|
- Git URL `https://github.com/DorkSquadRR/DorkNet.git`
|
||||||
|
- branch `*/march-2023-03-21`
|
||||||
|
- script path `Jenkinsfile`
|
||||||
|
3. Under **Triggers**, tick **GitHub hook trigger for GITScm polling**.
|
||||||
|
4. Run it once with **Build** so Jenkins learns the parameters. Later runs
|
||||||
|
offer **Build with Parameters**.
|
||||||
|
|
||||||
|
### 7. GitHub webhook
|
||||||
|
|
||||||
|
In the repository's **Settings → Webhooks**:
|
||||||
|
|
||||||
|
- Payload URL: `https://<your jenkins>/github-webhook/`
|
||||||
|
- 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.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# archive-restore — pull a Rec Room build out of the restic build archive.
|
||||||
|
#
|
||||||
|
# archive-restore list <repo-path> [tag[,tag…]]
|
||||||
|
# archive-restore restore <repo-path> <tag[,tag…]> <cache-dir> [restic restore args…]
|
||||||
|
#
|
||||||
|
# <repo-path> is the restic repository's folder on the archive FTP, e.g.
|
||||||
|
# "Rec Room Versions/steam-v2". Snapshots are picked by tag, e.g.
|
||||||
|
# "manifest=7490748483298966814" (Steam) or "version-code=2128210" (Quest);
|
||||||
|
# several tags separated by commas must all match. The newest match wins.
|
||||||
|
#
|
||||||
|
# `restore` writes the snapshot to <cache-dir>/<snapshot-id>/ and prints that
|
||||||
|
# path. A snapshot already restored there is reused, so a multi-GB build is
|
||||||
|
# only downloaded once. Extra arguments go to `restic restore` (e.g.
|
||||||
|
# --exclude /.DepotDownloader).
|
||||||
|
#
|
||||||
|
# restic reaches the FTP through its rclone backend. Everything secret comes
|
||||||
|
# from the environment (Jenkins injects it from credentials):
|
||||||
|
# ARCHIVE_FTP_HOST FTP host name
|
||||||
|
# ARCHIVE_FTP_USER FTP user
|
||||||
|
# ARCHIVE_FTP_PASSWORD FTP password
|
||||||
|
# RESTIC_PASSWORD restic repository password
|
||||||
|
# Optional: ARCHIVE_FTP_PORT (21), ARCHIVE_FTP_TLS (none | explicit | implicit,
|
||||||
|
# default explicit), RESTIC_CACHE_DIR.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mode="${1:?usage: archive-restore list|restore <repo-path> …}"
|
||||||
|
repo_path="${2:?repo path required}"
|
||||||
|
tags="${3:-}"
|
||||||
|
|
||||||
|
: "${ARCHIVE_FTP_HOST:?ARCHIVE_FTP_HOST is not set}"
|
||||||
|
: "${ARCHIVE_FTP_USER:?ARCHIVE_FTP_USER is not set}"
|
||||||
|
: "${ARCHIVE_FTP_PASSWORD:?ARCHIVE_FTP_PASSWORD is not set}"
|
||||||
|
: "${RESTIC_PASSWORD:?RESTIC_PASSWORD is not set}"
|
||||||
|
|
||||||
|
# rclone remote "rrarchive", configured purely through its env-var config.
|
||||||
|
export RCLONE_CONFIG_RRARCHIVE_TYPE=ftp
|
||||||
|
export RCLONE_CONFIG_RRARCHIVE_HOST="$ARCHIVE_FTP_HOST"
|
||||||
|
export RCLONE_CONFIG_RRARCHIVE_USER="$ARCHIVE_FTP_USER"
|
||||||
|
export RCLONE_CONFIG_RRARCHIVE_PORT="${ARCHIVE_FTP_PORT:-21}"
|
||||||
|
RCLONE_CONFIG_RRARCHIVE_PASS="$(printf '%s' "$ARCHIVE_FTP_PASSWORD" | rclone obscure -)"
|
||||||
|
export RCLONE_CONFIG_RRARCHIVE_PASS
|
||||||
|
case "${ARCHIVE_FTP_TLS:-explicit}" in
|
||||||
|
explicit) export RCLONE_CONFIG_RRARCHIVE_EXPLICIT_TLS=true ;;
|
||||||
|
implicit) export RCLONE_CONFIG_RRARCHIVE_TLS=true ;;
|
||||||
|
none) ;;
|
||||||
|
*) echo "archive-restore: ARCHIVE_FTP_TLS must be none, explicit or implicit" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
export RESTIC_REPOSITORY="rclone:rrarchive:${repo_path}"
|
||||||
|
|
||||||
|
tag_args=()
|
||||||
|
[ -n "$tags" ] && tag_args=(--tag "$tags")
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
list)
|
||||||
|
restic snapshots --no-lock "${tag_args[@]}"
|
||||||
|
;;
|
||||||
|
restore)
|
||||||
|
cache="${4:?cache dir required}"
|
||||||
|
[ -n "$tags" ] || { echo "archive-restore: restore needs a tag filter" >&2; exit 2; }
|
||||||
|
shift 4
|
||||||
|
id="$(restic snapshots --no-lock --json "${tag_args[@]}" \
|
||||||
|
| python3 -c 'import json,sys; s=sorted(json.load(sys.stdin), key=lambda x: x["time"]); print(s[-1]["short_id"] if s else "")')"
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "archive-restore: no snapshot in '$repo_path' has tags '$tags'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
dest="$cache/$id"
|
||||||
|
if [ -f "$dest.restored" ]; then
|
||||||
|
echo "archive-restore: reusing $id from $dest" >&2
|
||||||
|
else
|
||||||
|
echo "archive-restore: restoring $id ($tags) from '$repo_path'" >&2
|
||||||
|
rm -rf "$dest" "$dest.restored"
|
||||||
|
mkdir -p "$dest"
|
||||||
|
restic restore --no-lock "$id" --target "$dest" "$@" >&2
|
||||||
|
touch "$dest.restored"
|
||||||
|
fi
|
||||||
|
echo "$dest"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "archive-restore: unknown mode '$mode'" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 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:
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# Earlier versions of this file built the controller from ci/jenkins/Dockerfile,
|
||||||
|
# which is now the agent's image. The service name and data volume are the
|
||||||
|
# same, so redeploying an existing Jenkins from this file keeps its jobs and
|
||||||
|
# credentials; its builds then wait for the agent.
|
||||||
|
services:
|
||||||
|
jenkins:
|
||||||
|
# Pin a version with e.g. jenkins/jenkins:2.587-jdk25. Keep the agent on
|
||||||
|
# the same Java major version (its image defaults to Java 25).
|
||||||
|
image: ${JENKINS_IMAGE:-jenkins/jenkins:lts-jdk25}
|
||||||
|
# A restart from the Jenkins UI exits with code 5 and relies on Docker to
|
||||||
|
# start it again.
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
volumes:
|
||||||
|
- jenkins_home:/var/jenkins_home
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jenkins_home:
|
||||||
+14
-14
@@ -35,20 +35,20 @@ DorkNet.sln
|
|||||||
|
|
||||||
| Project | Responsibility | Target |
|
| Project | Responsibility | Target |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DorkNet.Server` | Shared REST/controller/service stack, database bootstrap, standalone fallback, monolith fallback | .NET 9 |
|
| `DorkNet.Server` | Shared REST/controller/service stack, database bootstrap, standalone fallback, monolith fallback | .NET 10 |
|
||||||
| `DorkNet.Models` | DTOs the wire protocol speaks. No project dependencies. | .NET 9 |
|
| `DorkNet.Models` | DTOs the wire protocol speaks. No project dependencies. | .NET 10 |
|
||||||
| `DorkNet.Contracts` | Service names, route ownership, service-map options, health responses, probe responses | .NET 9 |
|
| `DorkNet.Contracts` | Service names, route ownership, service-map options, health responses, probe responses | .NET 10 |
|
||||||
| `DorkNet.ServiceDefaults` | Common service health checks, JSON defaults, HTTP clients, service identity, route guard | .NET 9 |
|
| `DorkNet.ServiceDefaults` | Common service health checks, JSON defaults, HTTP clients, service identity, route guard | .NET 10 |
|
||||||
| `DorkNet.Gateway` | Edge reverse proxy; routes host/path slices to services and exposes service health probes | .NET 9 |
|
| `DorkNet.Gateway` | Edge reverse proxy; routes host/path slices to services and exposes service health probes | .NET 10 |
|
||||||
| `DorkNet.Services.Identity` | Accounts, auth, platform login, JWT issuance route slice | .NET 9 |
|
| `DorkNet.Services.Identity` | Accounts, auth, platform login, JWT issuance route slice | .NET 10 |
|
||||||
| `DorkNet.Services.Rooms` | Rooms, room keys, matchmaking, discovery route slice | .NET 9 |
|
| `DorkNet.Services.Rooms` | Rooms, room keys, matchmaking, discovery route slice | .NET 10 |
|
||||||
| `DorkNet.Services.Notify` | SignalR edge, messages/chat, notification fan-out route slice | .NET 9 |
|
| `DorkNet.Services.Notify` | SignalR edge, messages/chat, notification fan-out route slice | .NET 10 |
|
||||||
| `DorkNet.Services.Content` | CDN paths, image/photo APIs, uploads, S3-backed blobs | .NET 9 |
|
| `DorkNet.Services.Content` | CDN paths, image/photo APIs, uploads, S3-backed blobs | .NET 10 |
|
||||||
| `DorkNet.Services.Social` | Clubs, groups, announcements, player events, subscriptions | .NET 9 |
|
| `DorkNet.Services.Social` | Clubs, groups, announcements, player events, subscriptions | .NET 10 |
|
||||||
| `DorkNet.Services.Commerce` | Catalog, storefronts, econ, inventory, inventions | .NET 9 |
|
| `DorkNet.Services.Commerce` | Catalog, storefronts, econ, inventory, inventions | .NET 10 |
|
||||||
| `DorkNet.Services.Platform` | Service URLs, config, version checks, geo, strings, telemetry-style surfaces | .NET 9 |
|
| `DorkNet.Services.Platform` | Service URLs, config, version checks, geo, strings, telemetry-style surfaces | .NET 10 |
|
||||||
| `DorkNet.Services.Moderation` | Bug reporting, player reporting, sanitize, path-routed admin API, testcase routes | .NET 9 |
|
| `DorkNet.Services.Moderation` | Bug reporting, player reporting, sanitize, path-routed admin API, testcase routes | .NET 10 |
|
||||||
| `DorkNet.Services.Web` | Public site, admin static host, same-origin admin browser API, feed static host, site API | .NET 9 |
|
| `DorkNet.Services.Web` | Public site, admin static host, same-origin admin browser API, feed static host, site API | .NET 10 |
|
||||||
| `DorkNet.ClientMod` | MelonLoader 0.6.x IL2CPP mod — the client patcher, JSON config | .NET 6 |
|
| `DorkNet.ClientMod` | MelonLoader 0.6.x IL2CPP mod — the client patcher, JSON config | .NET 6 |
|
||||||
| `DorkNet.Quest` | native hook library + build scripts for the Meta Quest client | C++/arm64 |
|
| `DorkNet.Quest` | native hook library + build scripts for the Meta Quest client | C++/arm64 |
|
||||||
|
|
||||||
|
|||||||
@@ -412,6 +412,39 @@ for building and installing the patched APK.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Client downloads
|
||||||
|
|
||||||
|
Optional. When CI publishes the client builds to a bucket
|
||||||
|
(`ci/downloads/publish.py`, see `ci/jenkins/README.md`), the server can offer
|
||||||
|
them on the public site:
|
||||||
|
|
||||||
|
- a **Downloads** page (`/downloads`) listing every published file with its
|
||||||
|
size and SHA-256;
|
||||||
|
- **Latest build** on the **Quest** page, which installs the newest Quest
|
||||||
|
package straight from the server: the browser reads it with HTTP Range
|
||||||
|
requests, so nothing is downloaded first.
|
||||||
|
|
||||||
|
The server reads `manifest.json` and the files over plain HTTP, from inside
|
||||||
|
your network, and streams them to the browser. Only files the manifest lists
|
||||||
|
are served. The bucket itself never needs to be public.
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Where manifest.json is: e.g. Garage's web endpoint on the internal network.
|
||||||
|
DorkNet__Downloads__BaseUrl=http://garage-<service uuid>:3902
|
||||||
|
# Optional Host header, for web endpoints that pick the bucket by host name
|
||||||
|
# (Garage serves the bucket whose name matches it).
|
||||||
|
DorkNet__Downloads__Host=<bucket name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Unset, the site hides both features. The builds contain Rec Room's own game
|
||||||
|
files: only turn this on when the site is behind an access proxy (e.g.
|
||||||
|
Cloudflare Access) that admits just your community. The downloads routes
|
||||||
|
answer only on the apex host, where the site lives, and return 404 on every
|
||||||
|
other host the server accepts, so leaving the game's own subdomains open for
|
||||||
|
the client doesn't expose the files. Put the apex behind the proxy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Photon Cloud
|
## Photon Cloud
|
||||||
|
|
||||||
Use your own Photon AppIds. Rec Room's original Photon AppIds reject
|
Use your own Photon AppIds. Rec Room's original Photon AppIds reject
|
||||||
|
|||||||
+19
-1
@@ -55,6 +55,17 @@ an account, never matched by the cached-login lookup
|
|||||||
players are keyed by `deviceId` alone, exactly like the PC Steam-emulator path.
|
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.
|
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
|
Thanks to [RecFlare](https://github.com/recflare) for documenting this
|
||||||
pitfall in their Quest client notes.
|
pitfall in their Quest client notes.
|
||||||
|
|
||||||
@@ -74,6 +85,13 @@ pitfall in their Quest client notes.
|
|||||||
installer scripts; the app appears as **DorkNet** under Unknown Sources.
|
installer scripts; the app appears as **DorkNet** under Unknown Sources.
|
||||||
Share the `.xapk` however you like: the page reads it from the player's own
|
Share the `.xapk` however you like: the page reads it from the player's own
|
||||||
disk, so your server never hosts game files.
|
disk, so your server never hosts game files.
|
||||||
|
5. Optionally, let players install the latest CI build without downloading it
|
||||||
|
first: point the server at the downloads bucket CI publishes to
|
||||||
|
(`DorkNet__Downloads__BaseUrl`, see `deploy.md` → Client downloads). The
|
||||||
|
Quest page then offers **Latest build** next to **Choose file** and streams
|
||||||
|
the package from the server during the install, and the site gets a
|
||||||
|
**Downloads** page. The builds contain game files, so only do this with
|
||||||
|
the site behind an access proxy.
|
||||||
|
|
||||||
No server component is Quest-specific beyond the placeholder-platform-id
|
No server component is Quest-specific beyond the placeholder-platform-id
|
||||||
handling above.
|
handling above and the optional downloads proxy.
|
||||||
|
|||||||
@@ -120,17 +120,19 @@ Defect classes:
|
|||||||
## Realtime (SignalR) surface
|
## Realtime (SignalR) surface
|
||||||
|
|
||||||
Not every request is HTTP. The client opens a SignalR hub at `{0}hub/v1`
|
Not every request is HTTP. The client opens a SignalR hub at `{0}hub/v1`
|
||||||
(`RecNet.Runtime/OMHPBIEHOHN`, negotiate + `hub/v1`).
|
(`RecNet.Runtime/OMHPBIEHOHN`, negotiate + `hub/v1`). The full push
|
||||||
|
contract — every handler key, its payload, and what DorkNet sends — is in
|
||||||
|
[recroom-2023-notifications.md](recroom-2023-notifications.md).
|
||||||
|
|
||||||
- Server→client messages arrive on the hub method named **`"Notification"`**
|
- Server→client messages arrive on the hub method named **`"Notification"`**
|
||||||
(singular) carrying a **single `string`** argument whose content is JSON
|
(singular) carrying a **single `string`** argument whose content is JSON
|
||||||
`{"Id":..., "Msg":...}`. The handler is registered as `Action<string>`.
|
`{"Id":..., "Msg":...}`. The handler is registered as `Action<string>`.
|
||||||
Sending `ReceiveNotification`, or passing an object instead of a string,
|
Sending `ReceiveNotification`, or passing an object instead of a string,
|
||||||
makes every push silently vanish.
|
makes every push silently vanish.
|
||||||
- Client→server: `SubscribeToPlayers` / `UnsubscribeFromPlayers`, plus
|
- Client→server: `SubscribeToPlayers` (`{PlayerIds}`), plus
|
||||||
`"SubscribeTo"` / `"UnsubscribeFrom"` prefixes composed at runtime.
|
`SubscribeTo` / `UnsubscribeFrom` (`{Channels}`, always empty).
|
||||||
- Presence pushes are routed by the friend graph server-side, so the
|
- `SubscribeToPlayers` is load-bearing: it is how the client gets presence
|
||||||
subscription calls are advisory rather than load-bearing.
|
for players who aren't friends (people in the room, an open profile).
|
||||||
|
|
||||||
### Push dispatch keys — the numeric-vs-name trap
|
### Push dispatch keys — the numeric-vs-name trap
|
||||||
|
|
||||||
@@ -160,10 +162,11 @@ So **send the number**, except for these six, which are string-keyed:
|
|||||||
| 95 CommunityBoardUpdate | `CommunityBoardUpdate` |
|
| 95 CommunityBoardUpdate | `CommunityBoardUpdate` |
|
||||||
|
|
||||||
Ids the 2023 client registers NO handler for — sending them is a no-op:
|
Ids the 2023 client registers NO handler for — sending them is a no-op:
|
||||||
4, 15 (numeric form), 21, **24 ModerationRoomBan**, 40, 85. Room bans must
|
4, 15 (numeric form), 21, **24 ModerationRoomBan**, 40, 85. The moderation
|
||||||
therefore go out as `ModerationKick` (22) with `IsBan` set; the moderation
|
|
||||||
manager registers only 22, `ModerationUnkick` and 23
|
manager registers only 22, `ModerationUnkick` and 23
|
||||||
(`RecNet.Runtime/FPIBGPIAOBI.txt:310,323,335`).
|
(`RecNet.Runtime/FPIBGPIAOBI.txt:310,323,335`), and a kick (22) only sends
|
||||||
|
the player to their dorm, so bans go out as `Logout` (6) with Reason 4
|
||||||
|
(Banned); see recroom-2023-notifications.md.
|
||||||
|
|
||||||
Ids the client handles that this server never sends (dead client features,
|
Ids the client handles that this server never sends (dead client features,
|
||||||
available if wanted): 3, 5, 23, 32, 60, 110, 120, 121, plus a long tail of
|
available if wanted): 3, 5, 23, 32, 60, 110, 120, 121, plus a long tail of
|
||||||
@@ -173,12 +176,12 @@ string channels — `GoToFailure`, `PhotonAccessToken`, `AppVersionUpdate`,
|
|||||||
|
|
||||||
### Hub methods
|
### Hub methods
|
||||||
|
|
||||||
The client invokes exactly one server method: `SubscribeToPlayers`, with a
|
The client sends `SubscribeToPlayers` with a `{PlayerIds: List<int>}`
|
||||||
`{PlayerIds: List<int>}` payload, fire-and-forget. The `SubscribeTo`/
|
payload, fire-and-forget, on every (re)connect and whenever the set changes.
|
||||||
`UnsubscribeFrom` composition exists in the binary but has no callers in
|
The `SubscribeTo`/`UnsubscribeFrom` composition runs on connect but its
|
||||||
this build, and `UnsubscribeFromPlayers` does not appear at all — the
|
channel set is always empty, and `UnsubscribeFromPlayers` does not appear at
|
||||||
server's extra hub methods are unused but harmless. Presence is routed
|
all. The server keeps each connection's subscriptions as `presence:{id}`
|
||||||
server-side by the friend graph, so these calls are advisory.
|
SignalR groups and pushes those players' presence to them.
|
||||||
|
|
||||||
### Negotiate
|
### Negotiate
|
||||||
|
|
||||||
@@ -257,7 +260,8 @@ verified against the binary before the change, and the full test suite passes.
|
|||||||
| `playlists accessibility/levelvoting/restrictions/warning` | PUT 405, and values were discarded | PUT added and values persisted |
|
| `playlists accessibility/levelvoting/restrictions/warning` | PUT 405, and values were discarded | PUT added and values persisted |
|
||||||
| `SignalR push `Id` (notify)` | enum NAME sent for most ids; client only registered numbers — pushes vanished | numeric by default, with the six string-keyed ids listed explicitly |
|
| `SignalR push `Id` (notify)` | enum NAME sent for most ids; client only registered numbers — pushes vanished | numeric by default, with the six string-keyed ids listed explicitly |
|
||||||
| `SignalR SubscriptionUpdateRoom` | sent as `SubscriptionUpdateRoom`; client listens on `RoomUpdate` | sent as `RoomUpdate` |
|
| `SignalR SubscriptionUpdateRoom` | sent as `SubscriptionUpdateRoom`; client listens on `RoomUpdate` | sent as `RoomUpdate` |
|
||||||
| `Room ban notification` | sent id 24, for which the client has no handler | sent as ModerationKick (22) with IsBan |
|
| `Room ban notification` | sent id 24, for which the client has no handler | sent as ModerationKick (22) with IsBan (later replaced by Logout Reason 4, below) |
|
||||||
|
| `Notification payloads (Oct 2026 audit)` | RelationshipChanged sent `{Reason, From}`; ModerationKick used the nested 2020 shape and lost every reason; bans, deletes and takeovers only kicked to the dorm; Logout sent an enum NAME and was dropped; ServerMaintenance sent `{Message}`; ConsumableMapping, PlayerEvent 80/81/83, admin RoomUpdate and level-up pushes had wrong or missing payloads; SubscribeToPlayers was ignored | every payload matches the client DTO; bans/deletes/takeovers are Logout with a numeric Reason; SubscribeToPlayers drives `presence:{id}` groups; see recroom-2023-notifications.md |
|
||||||
| `Presence on SignalR reconnect` | disconnect pushed offline; nothing ever pushed back online, so a reconnect greyed the player out for the rest of the session | connect pushes presence (online even with no room); a drop while the player still heartbeats waits one activity window before pushing offline |
|
| `Presence on SignalR reconnect` | disconnect pushed offline; nothing ever pushed back online, so a reconnect greyed the player out for the rest of the session | connect pushes presence (online even with no room); a drop while the player still heartbeats waits one activity window before pushing offline |
|
||||||
| `POST matchmake/none` | re-committed the previous room (or the dorm), so the next heartbeat answered with a room while the client held its offline instance (-1): "presence out-of-sync", offline load cancelled, player dragged back | player is online with no room; any pending join is abandoned; heartbeat answers `roomInstance: null` until the next matchmake |
|
| `POST matchmake/none` | re-committed the previous room (or the dorm), so the next heartbeat answered with a room while the client held its offline instance (-1): "presence out-of-sync", offline load cancelled, player dragged back | player is online with no room; any pending join is abandoned; heartbeat answers `roomInstance: null` until the next matchmake |
|
||||||
| `Presence fan-out to friends` | only the immediate /goto commit pushed; deferred commits (matchmake/dorm, private hops), matchmake/none, logout, failed joins and statusvisibility changes were silent, and `isOnline` was derived from "has a room" | one fan-out (PresenceBroadcastService) for every transition, explicit `isOnline`, stored `statusVisibility`, never sent to the subject |
|
| `Presence fan-out to friends` | only the immediate /goto commit pushed; deferred commits (matchmake/dorm, private hops), matchmake/none, logout, failed joins and statusvisibility changes were silent, and `isOnline` was derived from "has a room" | one fan-out (PresenceBroadcastService) for every transition, explicit `isOnline`, stored `statusVisibility`, never sent to the subject |
|
||||||
@@ -659,7 +663,7 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
|
|||||||
| GET + POST | `rooms/{0}/bans` | GET: none. POST 'ban players': form fields id (repeated Int32 account ids) + banMask (Int32 enum DCDPJPHBHOA) | GET → [IBHAKOOKEEE {"AccountId": Int32, "BannedByAccountId": Int32, "BanStartTime": DateTime string}]. POST → none parsed. |
|
| GET + POST | `rooms/{0}/bans` | GET: none. POST 'ban players': form fields id (repeated Int32 account ids) + banMask (Int32 enum DCDPJPHBHOA) | GET → [IBHAKOOKEEE {"AccountId": Int32, "BannedByAccountId": Int32, "BanStartTime": DateTime string}]. POST → none parsed. |
|
||||||
| POST | `rooms/{0}/bans/import` | form field: sourceRoomId (Int64) | none parsed; return 200 |
|
| POST | `rooms/{0}/bans/import` | form field: sourceRoomId (Int64) | none parsed; return 200 |
|
||||||
| DELETE | `rooms/{0}/bans/{1}` | form field: banMask (Int32 enum) | none parsed; return 200 |
|
| DELETE | `rooms/{0}/bans/{1}` | form field: banMask (Int32 enum) | none parsed; return 200 |
|
||||||
| POST | `rooms/{0}/clone` | x-www-form-urlencoded field: name (String) — new room name | FGCPNAACHIK FULL room details of the clone (not a status wrapper); client 404→empty-body surfaces as message-less 'Failed to copy room' |
|
| POST | `rooms/{0}/clone` | x-www-form-urlencoded field: name (String) — new room name | `{success, value, error}` envelope with `value` = FGCPNAACHIK FULL room details of the clone (same helper as "save room"; a bare room has no `success` and is rejected with an empty error). Client 404→empty-body also surfaces as message-less 'Failed to copy room' |
|
||||||
| PUT | `rooms/{0}/cloning` | form field: cloningAllowed (Boolean) | FGCPNAACHIK full room details |
|
| PUT | `rooms/{0}/cloning` | form field: cloningAllowed (Boolean) | FGCPNAACHIK full room details |
|
||||||
| PUT | `rooms/{0}/comments` | form field: disable (Boolean) — key is 'disable', maps to DisableRoomComments | FGCPNAACHIK full room details |
|
| PUT | `rooms/{0}/comments` | form field: disable (Boolean) — key is 'disable', maps to DisableRoomComments | FGCPNAACHIK full room details |
|
||||||
| PUT | `rooms/{0}/creator` | form field: accountId (Int32) | FGCPNAACHIK full room details |
|
| PUT | `rooms/{0}/creator` | form field: accountId (Int32) | FGCPNAACHIK full room details |
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# 2023 client notifications (realtime push)
|
||||||
|
|
||||||
|
The contract between DorkNet and the 2023-03-21 client's notification hub.
|
||||||
|
Sources: the client's RecNet.Runtime ISIL (`C:\tmp\recroom-2023-03-21-isil`,
|
||||||
|
obfuscated names given as found there), cross-checked against RecFlare
|
||||||
|
(build 20230414) where useful. Code: `DorkNet.Server/Hubs/NotifyHub.cs`,
|
||||||
|
`Services/NotificationService.cs`, `Services/PresenceBroadcastService.cs`,
|
||||||
|
`Services/SystemNotificationService.cs`. Tests:
|
||||||
|
`DorkNet.Server.Tests/NotificationContractTests.cs`.
|
||||||
|
|
||||||
|
## Connection
|
||||||
|
|
||||||
|
- The hub is `{notify host}/hub/v1` (nameserver key `Notifications`). The
|
||||||
|
client opens it once, at login.
|
||||||
|
- **Negotiate**: `POST …/hub/v1/negotiate`, no `negotiateVersion`. The client reads
|
||||||
|
`connectionId` and `availableTransports`, and accepts only `WebSockets`.
|
||||||
|
- **WebSocket**: `…/hub/v1?id=<connectionId>`, JSON protocol
|
||||||
|
(`{"protocol":"json","version":1}`), no MessagePack.
|
||||||
|
- **Auth**: `Authorization: Bearer` on both negotiate and the upgrade. No
|
||||||
|
`access_token` query string.
|
||||||
|
- **Keep-alive**: the client pings every 15 s from its main thread and has
|
||||||
|
no server timeout of its own. The server pings every 10 s and allows 180 s
|
||||||
|
of client silence, because a long room-load hitch pauses the client's
|
||||||
|
pings.
|
||||||
|
- **Reconnects: at most 3 per login.** The client's attempt counter is never
|
||||||
|
reset after a successful reconnect. Once the budget is spent the hub stays
|
||||||
|
dead, with no message, until the next login. The server must never close a
|
||||||
|
healthy connection.
|
||||||
|
|
||||||
|
## Envelope
|
||||||
|
|
||||||
|
Every push invokes the hub method **`Notification`** with **one string
|
||||||
|
argument**, whose content is JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"Id":"<key>","Msg":{...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`Id`** is a JSON string. It is looked up case-sensitively in the
|
||||||
|
client's handler table, and a miss drops the push silently.
|
||||||
|
- Handlers registered through the enum overload use the decimal string of
|
||||||
|
the id (`"2"`).
|
||||||
|
- The rest use a name (see the table below).
|
||||||
|
- `NotificationService.WireName` maps `PushNotificationId` to the key.
|
||||||
|
- **`Msg`** is an inline JSON object of the handler's DTO, never
|
||||||
|
JSON-in-a-string.
|
||||||
|
- It is parsed strictly. Keys may be PascalCase, camelCase or all
|
||||||
|
lowercase, and unknown keys are skipped.
|
||||||
|
- A value of the wrong type (a number where a string is expected, a
|
||||||
|
string where an enum number is expected) throws, and the whole push is
|
||||||
|
dropped.
|
||||||
|
- A key that is missing takes its default value, which usually means the
|
||||||
|
push "works" but does nothing.
|
||||||
|
|
||||||
|
## Hub calls (client → server)
|
||||||
|
|
||||||
|
| Method | Payload | Server |
|
||||||
|
|---|---|---|
|
||||||
|
| `SubscribeToPlayers` | `{PlayerIds: List<int>}`: the client's relationships, the players around it, and itself. Sent on every (re)connect and whenever the set changes, always the complete set. | Replaces the connection's subscriptions. The connection is put in a `presence:{id}` group per player (own id skipped), and `PresenceBroadcastService` pushes those players' presence to those groups. Only presence, never the subject's other pushes. An unreadable payload keeps the current set. |
|
||||||
|
| `SubscribeTo` / `UnsubscribeFrom` | `{Channels: List<string>}`, always empty in this build | logged only |
|
||||||
|
|
||||||
|
There is no `UnsubscribeFromPlayers`.
|
||||||
|
|
||||||
|
## Handler table
|
||||||
|
|
||||||
|
These are the handlers the client registers. "Sent" names what DorkNet sends
|
||||||
|
today.
|
||||||
|
|
||||||
|
| Key | Msg | Sent by |
|
||||||
|
|---|---|---|
|
||||||
|
| `1` RelationshipChanged | `{PlayerID, RelationshipType, Muted, Ignored, Favorited}`: the recipient's own view, the same row `api/relationships/v2/get` lists | friend request / accept / remove / add / add-with-code (both players); favorite / mute / ignore and their reverses (caller only) |
|
||||||
|
| `2` MessageReceived | `{Id, FromPlayerId (Int32), SentTime, Type, Data (string), RoomId, PlayerEventId}`, the same row `api/messages/v2/get` lists | DMs and game invites; `SystemNotificationService` (cheers, friend request Type 4, accepted Type 40, "wants to join" Type 10, coach message Type 100, event invite text) |
|
||||||
|
| `3` MessageDeleted | `{Id}` | not sent |
|
||||||
|
| `5` RefreshLogin | none | not sent |
|
||||||
|
| `6` Logout | `{Reason}`: a **number**, enum Unknown 0, UserInitiated 1, SessionTakeover 2, ForciblyLoggedOut 3, Banned 4 | `NotificationService.LogoutAsync`: second-session takeover (2), account deleted or password reset (3), bans (4) |
|
||||||
|
| `20` / `ModerationQuitGame` | none | not sent |
|
||||||
|
| `22` ModerationKick | **flat** `{ReportCategory, Duration, GameSessionId, IsHostKick, Message, PlayerIdReporter, IsBan, IsVoiceModAutoban, IsWarning, VoteKickReason, TimeoutStartedAt}` | `KickPlayerAsync`: admin kick, close instance, in-game instant kick, join timeout |
|
||||||
|
| `ModerationUnkick` | same as 22 | not sent |
|
||||||
|
| `23` ModerationKickAttemptFailed | `{ReportCategory, YesVotes, NoVotes, PlayerIdReported}` | not sent |
|
||||||
|
| `25` ServerMaintenance | `{StartsInMinutes}`; 0 cancels the countdown | admin Broadcast, when a countdown is given |
|
||||||
|
| `30` / `31` GiftPackageReceived(Immediate) | the full gift package, as `api/avatar/v2/gifts` lists it (`AvatarGiftsController.ToWire`) | gifts, store gift purchases, free gifts, admin gifts |
|
||||||
|
| `50` RelationshipsInvalid | none (the client refetches) | admin "everyone is friends" toggle |
|
||||||
|
| `60` / `61` / `62` StorefrontBalance Add / Update / Purchase | 61 `{Balance, CurrencyType, Platform}`; 62 `{BalanceAddType, Delta, Balance, Platform, CurrencyType}` | `LevelService.GrantCurrencyAsync` (61), store purchase (62) |
|
||||||
|
| `70` / `71` ConsumableMapping Added / Removed | `{Id, ConsumableItemDesc (string), Count, InitialCount, CreatedAt, ActiveDurationMinutes, IsActive, IsTransferable}` | transfer (70 to the recipient), consume (always 71, with the remaining Count) |
|
||||||
|
| `80` / `81` PlayerEvent Created / Updated | the full player event, as `api/playerevents/v2/{id}` returns it | invites (80 to each invitee); every edit route and the broadcast setting (81 to attendees) |
|
||||||
|
| `82` / `84` PlayerEvent Deleted / ResponseDeleted | `{PlayerEventId}` | event delete (82 to attendees), RSVP removed (84 to the creator) |
|
||||||
|
| `83` PlayerEventResponseChanged | `{PlayerEvent, PlayerEventResponse}` | RSVP (to the creator) |
|
||||||
|
| `100` InventionModerationStateChanged | none | not sent |
|
||||||
|
| `AccountUpdate` / `SelfAccountUpdate` | the public / self account DTOs the account REST routes return | every profile edit, including profile image (`NotificationService.AccountChanged`) |
|
||||||
|
| `PresenceUpdate` | `{playerId, statusVisibility, deviceClass, vrMovementMode, roomInstance, isOnline, appVersion (string)}` | `PresenceBroadcastService`, to friends and subscribers, **never to the subject** (a frame about the local player moves them) |
|
||||||
|
| `RoomInstanceUpdate` | the room instance (`RoomInstanceDto`) | admin force-join / instance pull. It only refreshes the cached instance; the move itself comes from the next heartbeat. |
|
||||||
|
| `RoomUpdate` | the **full room** (`FGCPNAACHIK`, `RoomsController.BuildRoomServerDetails`), not the 2020 `{Room, Scenes}` wrapper | admin role / owner changes (everyone in the room plus the changed player), admin force-join |
|
||||||
|
| `ChatMessageReceived` | `{chatMessageId, chatThreadId, senderPlayerId, timeSent, contents}` | chat sends, to every member including the sender |
|
||||||
|
| `PlayerLeftChat` | the same chat-message shape; only `chatThreadId` and `senderPlayerId` (the leaver) are read | leaving a group chat |
|
||||||
|
| `PlayerProgressionLevelUpdate` | `{PlayerId, Level, XP}`, as `api/players/v2/progression` returns it | every XP award (`LevelService.AwardXpAsync`) |
|
||||||
|
| `CommunityBoardUpdate` | community board | community board edits |
|
||||||
|
| `ReputationUpdate`, `RoomCurrency*`, `KeepsakeInstance*`, `PhotonAccessToken`, `GoToFailure`, `AnnouncementUpdate/Delete`, `ClubMembershipUpdate`, `GameConfig.Refresh`, `PlayerSettings.Refresh`, … | see the contract notes in `recroom-2023-client-api-complete.md` | not sent |
|
||||||
|
|
||||||
|
**Not registered at all** (a push with these keys is dropped): numeric 4, 11,
|
||||||
|
12, 13, 15, 21, 24 (ModerationRoomBan), 40, 85, 90, 95.
|
||||||
|
|
||||||
|
## Kicks versus logouts
|
||||||
|
|
||||||
|
A ModerationKick only sends the player to their dorm, still signed in.
|
||||||
|
|
||||||
|
The kick handler builds the dialog text like this:
|
||||||
|
- It always starts from its own "Kicked to Dormroom".
|
||||||
|
- It appends "Reason: …" from `VoteKickReason` only when `ReportCategory`
|
||||||
|
is 10 and `IsHostKick` is false. `Message` is never shown.
|
||||||
|
- When `GameSessionId` is the kicked player's current room instance, it also
|
||||||
|
announces the kick to that room.
|
||||||
|
- A `Duration` above 0 starts a timeout follow-up.
|
||||||
|
|
||||||
|
Anything that must sign the player out uses Logout instead: bans, account
|
||||||
|
deletion, password resets and second-session takeover. The ban check then
|
||||||
|
refuses the banned player's next login.
|
||||||
|
|
||||||
|
## Admin broadcast
|
||||||
|
|
||||||
|
`POST api/admin/v1/broadcast` `{Message?, StartsInMinutes?}`:
|
||||||
|
- `Message` becomes a coach message (MessageReceived Type 100 from the
|
||||||
|
system account) in every online player's notifications.
|
||||||
|
- `StartsInMinutes` starts the client's own maintenance countdown (25).
|
||||||
|
|
||||||
|
ServerMaintenance has no text field. The old `{Message}` payload was never
|
||||||
|
shown, and it cancelled any running countdown.
|
||||||
|
|
||||||
|
## Not yet done
|
||||||
|
|
||||||
|
- **No offline queue.** A push sent while the player has no connection is lost.
|
||||||
|
Messages are stored, so they still appear in `api/messages/v2/get`.
|
||||||
|
RecFlare keeps the newest 500 per player and flushes them on connect.
|
||||||
|
- The 2020 save and restore routes (`api/rooms/v4/saveData`,
|
||||||
|
`…/restoredata`, `api/rooms/v1/datahistory/restore`) still push the 2020
|
||||||
|
`RoomUpdate` wrapper and a self PresenceUpdate. The 2023 client never
|
||||||
|
calls them; its save (`subrooms/{n}/data`) and publish routes push
|
||||||
|
nothing and return the room in the HTTP reply.
|
||||||
|
- `sendfriendintroductions` stores Type 30 text instead of FriendIntroduction
|
||||||
|
(130), and pushes nothing. The format of the 130 `Data` field is unknown.
|
||||||
@@ -89,8 +89,15 @@ body — the same `roomserver/` prefix as every other room mutation from
|
|||||||
that client. A bare-only `rooms/{id}/clone` route 404s with an empty
|
that client. A bare-only `rooms/{id}/clone` route 404s with an empty
|
||||||
body, which the client's promise layer surfaces as a message-less
|
body, which the client's promise layer surfaces as a message-less
|
||||||
`Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown`.
|
`Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown`.
|
||||||
The response must be the FULL room-details object (`FGCPNAACHIK`,
|
The response is the legacy `{success, value, error}` envelope, the same
|
||||||
same shape as `GET roomserver/rooms/{id}`), not a status wrapper.
|
one "save room" and "restore subroom save" use: the clone goes through the
|
||||||
|
same RecNet rooms helper (`NLDBPDCNNCF.BDBHCEGKGDC`, which takes an op
|
||||||
|
name). `value` is the FULL room-details object (`FGCPNAACHIK`, same shape
|
||||||
|
as `GET roomserver/rooms/{id}`). A bare room-details body has no `success`,
|
||||||
|
so the client rejects it with an empty error and shows the same
|
||||||
|
message-less `Failed to copy room`, even though the room was created.
|
||||||
|
Refusals (unknown source, cloning not allowed) are HTTP 200 with
|
||||||
|
`success: false` and the message in `error`.
|
||||||
|
|
||||||
Blob semantics on clone (`RoomsModerationController.BareClone`):
|
Blob semantics on clone (`RoomsModerationController.BareClone`):
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Builds the per-version dorknet-clientpatch-{VersionKey}.zip the
|
|
||||||
DorkNet launcher expects to find as a GitHub Release asset.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Output zip layout (see launcher/RELEASES.md on the public main
|
|
||||||
branch for the full contract):
|
|
||||||
|
|
||||||
manifest.json (schema v1, describes the install)
|
|
||||||
MelonLoader.zip (downloaded from melonloader github)
|
|
||||||
DorkNet.ClientMod.dll (built from this repo)
|
|
||||||
dorknet-clientmod.json.template (copied from DorkNet.ClientMod/)
|
|
||||||
|
|
||||||
The launcher reads manifest.json, unzips MelonLoader on top of the
|
|
||||||
user's Rec Room install root, copies the DLL into Mods/, then
|
|
||||||
renders the config template into UserData/.
|
|
||||||
|
|
||||||
Self-contained: downloads MelonLoader before the dotnet build and
|
|
||||||
feeds its DLLs into the build as references, so no local Rec Room
|
|
||||||
install is required to compile DorkNet.ClientMod. (The mod resolves
|
|
||||||
every game-side type at runtime via AccessTools.TypeByName.)
|
|
||||||
|
|
||||||
.PARAMETER VersionKey
|
|
||||||
The version key matching versions.json on the public main branch
|
|
||||||
(e.g. december_2020_12_18 or march_2020_03_10).
|
|
||||||
|
|
||||||
.PARAMETER MelonLoaderVersion
|
|
||||||
MelonLoader release tag to bundle. Defaults to the 0.6.x stable
|
|
||||||
target — that's the loader DorkNet.ClientMod compiled against.
|
|
||||||
|
|
||||||
.PARAMETER OutputDir
|
|
||||||
Where the final zip lands. Defaults to repo-root/dist.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\tools\build-patcher-zip.ps1 -VersionKey december_2020_12_18
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)][string]$VersionKey,
|
|
||||||
[string]$MelonLoaderVersion = "v0.6.6",
|
|
||||||
[string]$OutputDir = $null
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$repoRoot = Resolve-Path "$PSScriptRoot\.."
|
|
||||||
if (-not $OutputDir) { $OutputDir = Join-Path $repoRoot 'dist' }
|
|
||||||
New-Item -ItemType Directory -Force $OutputDir | Out-Null
|
|
||||||
|
|
||||||
$stage = Join-Path $env:TEMP "dorknet-patcher-$VersionKey-$([guid]::NewGuid().Guid.Substring(0,8))"
|
|
||||||
New-Item -ItemType Directory -Force $stage | Out-Null
|
|
||||||
try {
|
|
||||||
# ── 1. Fetch MelonLoader from upstream releases ──────────────────────
|
|
||||||
# We download it first so the DLLs inside can serve as compile-time
|
|
||||||
# references for DorkNet.ClientMod — keeps the build self-contained
|
|
||||||
# (no local Rec Room install required to compile the mod).
|
|
||||||
# x64 .NET 6 build targets 2020-era Rec Room. The asset name pattern
|
|
||||||
# is stable across 0.6.x: MelonLoader.x64.zip.
|
|
||||||
$mlAsset = "MelonLoader.x64.zip"
|
|
||||||
$mlUrl = "https://github.com/LavaGang/MelonLoader/releases/download/$MelonLoaderVersion/$mlAsset"
|
|
||||||
$mlZip = "$stage\MelonLoader.zip"
|
|
||||||
$mlExtract = "$stage\melonloader-extracted"
|
|
||||||
Write-Host "[fetch] downloading $mlUrl"
|
|
||||||
Invoke-WebRequest -Uri $mlUrl -OutFile $mlZip
|
|
||||||
if (-not (Test-Path $mlZip) -or (Get-Item $mlZip).Length -lt 1MB) {
|
|
||||||
throw "MelonLoader download failed or produced an unexpectedly small file"
|
|
||||||
}
|
|
||||||
Expand-Archive -Path $mlZip -DestinationPath $mlExtract -Force
|
|
||||||
# The 0.6.x release zip layout has a top-level `MelonLoader` folder
|
|
||||||
# containing net6, net35, Dependencies, Documentation. Point the
|
|
||||||
# csproj at that inner folder.
|
|
||||||
$mlRefRoot = Join-Path $mlExtract 'MelonLoader'
|
|
||||||
if (-not (Test-Path "$mlRefRoot\net6\MelonLoader.dll")) {
|
|
||||||
throw "Extracted MelonLoader is missing net6\MelonLoader.dll — release layout may have changed"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 2. Build DorkNet.ClientMod (no Rec Room install needed) ─────────
|
|
||||||
Write-Host "[build] dotnet build DorkNet.ClientMod (Release)"
|
|
||||||
dotnet build "$repoRoot\DorkNet.ClientMod\DorkNet.ClientMod.csproj" `
|
|
||||||
-c Release --nologo `
|
|
||||||
"-p:MelonLoaderDir=$mlRefRoot" | Out-Null
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "ClientMod build failed" }
|
|
||||||
$dll = Get-ChildItem "$repoRoot\DorkNet.ClientMod\bin\Release" -Recurse `
|
|
||||||
-Filter "DorkNet.ClientMod.dll" |
|
|
||||||
Sort-Object LastWriteTime -Descending |
|
|
||||||
Select-Object -First 1
|
|
||||||
if (-not $dll) { throw "DorkNet.ClientMod.dll not found post-build" }
|
|
||||||
Copy-Item $dll.FullName "$stage\DorkNet.ClientMod.dll"
|
|
||||||
Write-Host "[build] copied $($dll.FullName)"
|
|
||||||
|
|
||||||
# ── 3. Copy the config template ──────────────────────────────────────
|
|
||||||
$template = "$repoRoot\DorkNet.ClientMod\dorknet-clientmod.json.template"
|
|
||||||
if (-not (Test-Path $template)) {
|
|
||||||
throw "Config template missing: $template"
|
|
||||||
}
|
|
||||||
Copy-Item $template "$stage\dorknet-clientmod.json.template"
|
|
||||||
|
|
||||||
# ── 4. Copy the Steam stub (Goldberg) + appid ────────────────────────
|
|
||||||
# Replaces the Valve steam_api64.dll so the unwrapped exe can run
|
|
||||||
# without Steam loaded. App ID 471710 is Rec Room — same across all
|
|
||||||
# versions we ship for.
|
|
||||||
$stubDll = "$repoRoot\DorkNet.ClientMod\steam-stub\steam_api64.dll"
|
|
||||||
$stubAppid = "$repoRoot\DorkNet.ClientMod\steam-stub\steam_appid.txt"
|
|
||||||
if (-not (Test-Path $stubDll)) {
|
|
||||||
throw "Steam stub DLL missing: $stubDll"
|
|
||||||
}
|
|
||||||
if (-not (Test-Path $stubAppid)) {
|
|
||||||
throw "Steam appid file missing: $stubAppid"
|
|
||||||
}
|
|
||||||
Copy-Item $stubDll "$stage\steam_api64.dll"
|
|
||||||
Copy-Item $stubAppid "$stage\steam_appid.txt"
|
|
||||||
|
|
||||||
# ── 5. Write manifest.json ──────────────────────────────────────────
|
|
||||||
$manifest = @{
|
|
||||||
'$schema_version' = 1
|
|
||||||
'loader_archive' = 'MelonLoader.zip'
|
|
||||||
'plugin_dll' = 'DorkNet.ClientMod.dll'
|
|
||||||
'plugin_dest' = 'Mods'
|
|
||||||
'config_template' = 'dorknet-clientmod.json.template'
|
|
||||||
'config_dest' = 'MelonLoader/UserData/dorknet-clientmod.json'
|
|
||||||
'old_plugin_paths' = @(
|
|
||||||
'BepInEx/plugins/DorkNet.ClientPatch.dll'
|
|
||||||
)
|
|
||||||
'steam_stub' = @{
|
|
||||||
# 2020.12+ is an EAC build: the real Unity binary is
|
|
||||||
# RecRoom.exe (loads RecRoom_Data/Plugins/x86_64/steam_api64.dll),
|
|
||||||
# NOT Recroom_Release.exe (which is the EAC launcher wrapper
|
|
||||||
# that loads Recroom_Release_Data/Plugins/steam_api64.dll).
|
|
||||||
# Patching the wrapper's DLL did nothing because the
|
|
||||||
# launcher's data dir is never opened by the real game
|
|
||||||
# process — symptom was "Failed to initialize Steam
|
|
||||||
# platform" because the unpatched real Steam DLL in
|
|
||||||
# RecRoom_Data tried to handshake.
|
|
||||||
'api_dll' = 'steam_api64.dll'
|
|
||||||
'api_dest' = 'RecRoom_Data/Plugins/x86_64/steam_api64.dll'
|
|
||||||
'api_backup_suffix' = '.steam-original'
|
|
||||||
'appid_file' = 'steam_appid.txt'
|
|
||||||
'appid_dest' = 'steam_appid.txt'
|
|
||||||
}
|
|
||||||
} | ConvertTo-Json -Depth 4
|
|
||||||
Set-Content -LiteralPath "$stage\manifest.json" -Value $manifest
|
|
||||||
|
|
||||||
# ── 6. Bundle into the final zip ────────────────────────────────────
|
|
||||||
# Strip the extracted MelonLoader scratch dir before zipping so it
|
|
||||||
# doesn't bloat the artifact. The MelonLoader.zip itself stays.
|
|
||||||
Remove-Item -Recurse -Force $mlExtract
|
|
||||||
$outZip = Join-Path $OutputDir "dorknet-clientpatch-$VersionKey.zip"
|
|
||||||
if (Test-Path $outZip) { Remove-Item $outZip -Force }
|
|
||||||
Compress-Archive -Path "$stage\*" -DestinationPath $outZip
|
|
||||||
$sizeMb = [math]::Round((Get-Item $outZip).Length / 1MB, 1)
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Built $outZip ($sizeMb MB)" -ForegroundColor Green
|
|
||||||
Write-Host " Upload this to the v1-* GitHub Release on this branch."
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
if (Test-Path $stage) { Remove-Item -Recurse -Force $stage }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the DorkNet PC client packages.
|
||||||
|
|
||||||
|
patcher dorknet-clientpatch-<VersionKey>.zip, what the DorkNet launcher
|
||||||
|
installs: MelonLoader.zip, DorkNet.ClientMod.dll (built from this
|
||||||
|
repo), the config template, the Goldberg Steam stub and a
|
||||||
|
manifest.json saying where each piece goes. No game files.
|
||||||
|
|
||||||
|
client a ready-to-run client: a stock Rec Room build with a patcher zip
|
||||||
|
applied the way the launcher applies it, plus your config. It
|
||||||
|
contains the game's own files, so keep it private.
|
||||||
|
|
||||||
|
Runs on Windows, Linux and macOS. `patcher` needs the .NET SDK (the mod
|
||||||
|
builds against MelonLoader's own DLLs, so no Rec Room install is needed).
|
||||||
|
|
||||||
|
py -3 tools/build_pc.py patcher --version-key march_2023_03_21
|
||||||
|
py -3 tools/build_pc.py client --base-client RecRoom-2023-03-21.zip \\
|
||||||
|
--patcher-zip dist/dorknet-clientpatch-march_2023_03_21.zip \\
|
||||||
|
--config dorknet-clientmod.json
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NoReturn
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
CLIENT_MOD = REPO / "DorkNet.ClientMod"
|
||||||
|
|
||||||
|
# Where each build expects the mod's config and the Steam DLL the game actually
|
||||||
|
# loads (see the per-branch build layout notes in the README):
|
||||||
|
# 2020.03 Recroom_Release.exe is the game; Steam DLL in its Data/Plugins.
|
||||||
|
# 2020.12 RecRoom.exe is the game (Recroom_Release.exe is the EAC wrapper);
|
||||||
|
# MelonLoader keeps UserData under MelonLoader/.
|
||||||
|
# 2023.03 RecRoom.exe + RecRoom_Data; MelonLoader 0.6 keeps UserData at the
|
||||||
|
# install root.
|
||||||
|
LAYOUTS = {
|
||||||
|
"march_2020_03_10": ("MelonLoader/UserData/dorknet-clientmod.json", "Recroom_Release_Data/Plugins/steam_api64.dll"),
|
||||||
|
"december_2020_12_18": ("MelonLoader/UserData/dorknet-clientmod.json", "RecRoom_Data/Plugins/x86_64/steam_api64.dll"),
|
||||||
|
"march_2023_03_21": ("UserData/dorknet-clientmod.json", "RecRoom_Data/Plugins/x86_64/steam_api64.dll"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def step(msg: str) -> None:
|
||||||
|
print(f"==> {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg: str) -> NoReturn:
|
||||||
|
raise SystemExit(f"error: {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def need(path: Path | str, what: str) -> Path:
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
fail(f"{what} not found: {p}")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def zip_dir(src: Path, out: Path) -> None:
|
||||||
|
"""Zip the contents of src (not src itself) into out."""
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.unlink(missing_ok=True)
|
||||||
|
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as z:
|
||||||
|
for f in sorted(src.rglob("*")):
|
||||||
|
if f.is_file():
|
||||||
|
z.write(f, f.relative_to(src).as_posix())
|
||||||
|
|
||||||
|
|
||||||
|
# ---- patcher --------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_patcher(a: argparse.Namespace) -> None:
|
||||||
|
if a.version_key not in LAYOUTS:
|
||||||
|
fail(f"unknown version key '{a.version_key}'. Known: {', '.join(LAYOUTS)}")
|
||||||
|
config_dest, steam_dest = LAYOUTS[a.version_key]
|
||||||
|
out_dir = Path(a.output_dir) if a.output_dir else REPO / "dist"
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix=f"dorknet-patcher-{a.version_key}-") as tmp:
|
||||||
|
stage = Path(tmp) / "stage"
|
||||||
|
stage.mkdir()
|
||||||
|
|
||||||
|
# 1. MelonLoader. Downloaded first so its DLLs can serve as the mod's
|
||||||
|
# compile-time references: no local Rec Room install needed.
|
||||||
|
url = f"https://github.com/LavaGang/MelonLoader/releases/download/{a.melonloader_version}/MelonLoader.x64.zip"
|
||||||
|
ml_zip = stage / "MelonLoader.zip"
|
||||||
|
step(f"downloading {url}")
|
||||||
|
with urllib.request.urlopen(url) as r, open(ml_zip, "wb") as f:
|
||||||
|
shutil.copyfileobj(r, f)
|
||||||
|
if ml_zip.stat().st_size < 1 << 20:
|
||||||
|
fail("MelonLoader download failed or produced an unexpectedly small file")
|
||||||
|
ml_extract = Path(tmp) / "melonloader"
|
||||||
|
with zipfile.ZipFile(ml_zip) as z:
|
||||||
|
z.extractall(ml_extract)
|
||||||
|
# The 0.6.x release zip has a top-level MelonLoader/ folder holding
|
||||||
|
# net6, net35, Dependencies, Documentation.
|
||||||
|
ml_ref = ml_extract / "MelonLoader"
|
||||||
|
if not (ml_ref / "net6" / "MelonLoader.dll").exists():
|
||||||
|
fail("extracted MelonLoader is missing net6/MelonLoader.dll; the release layout may have changed")
|
||||||
|
|
||||||
|
# 2. the mod
|
||||||
|
step("dotnet build DorkNet.ClientMod (Release)")
|
||||||
|
r = subprocess.run(["dotnet", "build", str(CLIENT_MOD / "DorkNet.ClientMod.csproj"),
|
||||||
|
"-c", "Release", "--nologo", f"-p:MelonLoaderDir={ml_ref}"])
|
||||||
|
if r.returncode:
|
||||||
|
fail("ClientMod build failed")
|
||||||
|
dlls = sorted((CLIENT_MOD / "bin" / "Release").rglob("DorkNet.ClientMod.dll"),
|
||||||
|
key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
if not dlls:
|
||||||
|
fail("DorkNet.ClientMod.dll not found after the build")
|
||||||
|
shutil.copy2(dlls[0], stage / "DorkNet.ClientMod.dll")
|
||||||
|
print(f" built {dlls[0]}")
|
||||||
|
|
||||||
|
# 3. config template + 4. Goldberg Steam stub (App ID 471710, the same
|
||||||
|
# for every build). The stub replaces Valve's steam_api64.dll so the
|
||||||
|
# game runs without Steam.
|
||||||
|
for src, what in ((CLIENT_MOD / "dorknet-clientmod.json.template", "config template"),
|
||||||
|
(CLIENT_MOD / "steam-stub" / "steam_api64.dll", "Steam stub DLL"),
|
||||||
|
(CLIENT_MOD / "steam-stub" / "steam_appid.txt", "Steam appid file")):
|
||||||
|
shutil.copy2(need(src, what), stage / src.name)
|
||||||
|
|
||||||
|
# 5. manifest.json, read by the launcher and by `client` below.
|
||||||
|
manifest = {
|
||||||
|
"$schema_version": 1,
|
||||||
|
"loader_archive": "MelonLoader.zip",
|
||||||
|
"plugin_dll": "DorkNet.ClientMod.dll",
|
||||||
|
"plugin_dest": "Mods",
|
||||||
|
"config_template": "dorknet-clientmod.json.template",
|
||||||
|
"config_dest": config_dest,
|
||||||
|
"old_plugin_paths": ["BepInEx/plugins/DorkNet.ClientPatch.dll"],
|
||||||
|
"steam_stub": {
|
||||||
|
# 2020.12+ is an EAC build: the real Unity binary is RecRoom.exe
|
||||||
|
# (it loads RecRoom_Data/Plugins/x86_64/steam_api64.dll), NOT
|
||||||
|
# Recroom_Release.exe (the EAC launcher wrapper, whose data dir
|
||||||
|
# the real game never opens).
|
||||||
|
"api_dll": "steam_api64.dll",
|
||||||
|
"api_dest": steam_dest,
|
||||||
|
"api_backup_suffix": ".steam-original",
|
||||||
|
"appid_file": "steam_appid.txt",
|
||||||
|
"appid_dest": "steam_appid.txt",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(stage / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
# 6. bundle
|
||||||
|
out = out_dir / f"dorknet-clientpatch-{a.version_key}.zip"
|
||||||
|
zip_dir(stage, out)
|
||||||
|
print()
|
||||||
|
print(f"Built {out} ({out.stat().st_size / 2**20:.1f} MB)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- client ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_client(a: argparse.Namespace) -> None:
|
||||||
|
base = need(a.base_client, "stock client")
|
||||||
|
patcher_zip = need(a.patcher_zip, "patcher zip")
|
||||||
|
config = need(a.config, "client config") if a.config else None
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="dorknet-pc-client-") as tmp:
|
||||||
|
tmp = Path(tmp)
|
||||||
|
install, patcher = tmp / "install", tmp / "patcher"
|
||||||
|
patcher.mkdir()
|
||||||
|
|
||||||
|
# 1. stock client: a zip or a folder, the install at its root or
|
||||||
|
# inside one wrapping folder.
|
||||||
|
step(f"unpacking stock client {base}")
|
||||||
|
if base.is_dir():
|
||||||
|
unpacked = base
|
||||||
|
else:
|
||||||
|
unpacked = tmp / "base"
|
||||||
|
with zipfile.ZipFile(base) as z:
|
||||||
|
z.extractall(unpacked)
|
||||||
|
root = unpacked
|
||||||
|
top = list(root.iterdir())
|
||||||
|
if len(top) == 1 and top[0].is_dir():
|
||||||
|
root = top[0]
|
||||||
|
games = [p.name for p in root.glob("*.exe") if (root / f"{p.stem}_Data").is_dir()]
|
||||||
|
if not games:
|
||||||
|
fail(f"no game exe with a matching _Data folder at the root of {base}")
|
||||||
|
print(f" game: {', '.join(games)}")
|
||||||
|
shutil.copytree(root, install, ignore=shutil.ignore_patterns(".DepotDownloader"))
|
||||||
|
|
||||||
|
# 2. apply the patcher the way the launcher does, from its manifest.
|
||||||
|
step(f"applying {patcher_zip}")
|
||||||
|
with zipfile.ZipFile(patcher_zip) as z:
|
||||||
|
z.extractall(patcher)
|
||||||
|
m = json.loads(need(patcher / "manifest.json", "patcher manifest.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
with zipfile.ZipFile(need(patcher / m["loader_archive"], "MelonLoader archive")) as z:
|
||||||
|
z.extractall(install)
|
||||||
|
print(" MelonLoader -> install root")
|
||||||
|
|
||||||
|
plugin_dir = install / m["plugin_dest"]
|
||||||
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(patcher / m["plugin_dll"], plugin_dir / m["plugin_dll"])
|
||||||
|
print(f" {m['plugin_dll']} -> {m['plugin_dest']}/")
|
||||||
|
|
||||||
|
config_dest = install / m["config_dest"]
|
||||||
|
config_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(config or patcher / m["config_template"], config_dest)
|
||||||
|
print(f" config -> {m['config_dest']}{'' if config else ' (template, unedited)'}")
|
||||||
|
|
||||||
|
stub = m.get("steam_stub")
|
||||||
|
if stub:
|
||||||
|
api_dest = install / stub["api_dest"]
|
||||||
|
if api_dest.exists():
|
||||||
|
backup = api_dest.with_name(api_dest.name + stub["api_backup_suffix"])
|
||||||
|
if not backup.exists():
|
||||||
|
shutil.copy2(api_dest, backup)
|
||||||
|
api_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(patcher / stub["api_dll"], api_dest)
|
||||||
|
shutil.copy2(patcher / stub["appid_file"], install / stub["appid_dest"])
|
||||||
|
print(f" Steam stub -> {stub['api_dest']}")
|
||||||
|
|
||||||
|
for old in m.get("old_plugin_paths") or []:
|
||||||
|
p = install / old
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
print(f" removed old {old}")
|
||||||
|
|
||||||
|
# 3. zip
|
||||||
|
key = patcher_zip.stem.removeprefix("dorknet-clientpatch-")
|
||||||
|
out = Path(a.output_zip) if a.output_zip else REPO / "dist" / f"dorknet-client-{key}.zip"
|
||||||
|
step(f"zipping -> {out}")
|
||||||
|
zip_dir(install, out)
|
||||||
|
print()
|
||||||
|
print(f"Built {out} ({out.stat().st_size / 2**20:.1f} MB). It contains game files: keep it private.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- cli ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
p = argparse.ArgumentParser(description="Build the DorkNet PC client packages.")
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
pz = sub.add_parser("patcher", help="dorknet-clientpatch-<VersionKey>.zip (no game files)")
|
||||||
|
pz.add_argument("--version-key", required=True, choices=sorted(LAYOUTS),
|
||||||
|
help="the build the zip is for; picks the install layout")
|
||||||
|
pz.add_argument("--melonloader-version", default="v0.6.6",
|
||||||
|
help="MelonLoader release tag to bundle (default: v0.6.6, what the mod compiles against)")
|
||||||
|
pz.add_argument("--output-dir", help="where the zip goes (default: <repo>/dist)")
|
||||||
|
pz.set_defaults(func=cmd_patcher)
|
||||||
|
|
||||||
|
c = sub.add_parser("client", help="stock build + patcher zip + config -> ready-to-run client zip")
|
||||||
|
c.add_argument("--base-client", required=True, help="the stock client, as a .zip or a folder")
|
||||||
|
c.add_argument("--patcher-zip", required=True, help="dorknet-clientpatch-<VersionKey>.zip from `patcher`")
|
||||||
|
c.add_argument("--config", help="dorknet-clientmod.json to install (default: the unedited template)")
|
||||||
|
c.add_argument("--output-zip", help="default: <repo>/dist/dorknet-client-<VersionKey>.zip")
|
||||||
|
c.set_defaults(func=cmd_client)
|
||||||
|
|
||||||
|
a = p.parse_args()
|
||||||
|
a.func(a)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user