From b1c123916577217fc8573d8e7d82335b36fe959b Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:26:42 +0300 Subject: [PATCH 1/2] ci: build the PC and Quest clients on Jenkins A Jenkinsfile builds, on push (only what changed) or on demand: - the PC patcher zip (MelonLoader, the mod, config template, Steam stub), - the full PC client (stock build + patcher + config), - the Quest APK and .xapk. Stock builds come out of the restic build archive on the FTP: restic through its rclone backend, picked by snapshot tag per run and cached. The FTP login and restic password are Jenkins credentials, and the repository folders are environment settings, so nothing about the archive is fixed in the repo. ci/jenkins/Dockerfile is the Jenkins image with the toolchain added (.NET SDK, Android NDK and build-tools, apktool, Python with LIEF, restic, rclone), for the existing Jenkins service. The build scripts move from PowerShell to Python so they run the same on Windows, Linux and in CI: - DorkNet.Quest/scripts/build.py replaces build.ps1 + package.ps1; it can use a committed hook table (checked against the APK's libil2cpp.so) instead of dump.cs, and takes a persistent keystore. - tools/build_pc.py replaces build-patcher-zip.ps1 (now with the 2023 install layout) and adds the full-client build. Setup steps are in ci/jenkins/README.md. --- .github/workflows/release.yml | 4 +- DorkNet.Quest/README.md | 40 ++- DorkNet.Quest/scripts/build.ps1 | 248 --------------- DorkNet.Quest/scripts/build.py | 472 ++++++++++++++++++++++++++++ DorkNet.Quest/scripts/package.ps1 | 172 ---------- DorkNet.Server/site/src/lib/xapk.ts | 2 +- Jenkinsfile | 206 ++++++++++++ ci/jenkins/.gitattributes | 2 + ci/jenkins/Dockerfile | 83 +++++ ci/jenkins/README.md | 140 +++++++++ ci/jenkins/archive-restore.sh | 84 +++++ ci/jenkins/docker-compose.yml | 31 ++ tools/build-patcher-zip.ps1 | 157 --------- tools/build_pc.py | 259 +++++++++++++++ 14 files changed, 1304 insertions(+), 596 deletions(-) delete mode 100644 DorkNet.Quest/scripts/build.ps1 create mode 100644 DorkNet.Quest/scripts/build.py delete mode 100644 DorkNet.Quest/scripts/package.ps1 create mode 100644 Jenkinsfile create mode 100644 ci/jenkins/.gitattributes create mode 100644 ci/jenkins/Dockerfile create mode 100644 ci/jenkins/README.md create mode 100644 ci/jenkins/archive-restore.sh create mode 100644 ci/jenkins/docker-compose.yml delete mode 100644 tools/build-patcher-zip.ps1 create mode 100644 tools/build_pc.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eae2d14..867a707 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,10 +43,10 @@ jobs: Compress-Archive -Path publish/server/* ` -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 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 ─────────────────────────────── - name: Upload Release assets diff --git a/DorkNet.Quest/README.md b/DorkNet.Quest/README.md index bc4e639..e025cbf 100644 --- a/DorkNet.Quest/README.md +++ b/DorkNet.Quest/README.md @@ -60,7 +60,7 @@ Photon shards with PC DorkNet players with no server-side version change. and its matching **OBB**. > 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. ## Build @@ -76,20 +76,28 @@ dotnet Il2CppDumper.dll ` copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg # 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_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018' $env:APKTOOL_JAR = 'C:\path\to\apktool.jar' -DorkNet.Quest\scripts\build.ps1 ` - -Apk C:\path\to\RecRoom-store-2128210.apk ` - -DumpCs \dump\dump.cs ` - -Obb C:\path o\main.2128210.com.AgainstGravity.RecRoom.obb +py -3 DorkNet.Quest\scripts\build.py build ` + --apk C:\path\to\RecRoom-store-2128210.apk ` + --dump-cs \dump\dump.cs ` + --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-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 -folder ready to hand to players: +The same command works on Linux and macOS (`python3 DorkNet.Quest/scripts/build.py build …`). + +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 | | --- | --- | @@ -98,15 +106,16 @@ folder ready to hand to players: | `install.sh` | one-command install on macOS / Linux | | `README.txt` | player instructions | -To package an APK you already built, run `scripts/package.ps1 -Apk -Obb `. +To package an APK you already built, run `build.py package --apk --obb `. The package contains game binaries from your store copy, so only share it with 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 → 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 -dir on first run. **Keep that keystore** — Meta ties tokens to app id + package +zipalign → apksigner (v2+v3). Without `--keystore` it generates a fresh debug +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. It is never committed. @@ -189,7 +198,7 @@ every `armed `, then `intercepted https://ns.rec.net/ -> https://ns.: ... 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 | | `[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 | @@ -209,8 +218,7 @@ every `armed `, then `intercepted https://ns.rec.net/ -> https://ns. 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: \dorknet-recroom-.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 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('', - '') -} -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, '[^<]*', "$labelXml") - 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 -} diff --git a/DorkNet.Quest/scripts/build.py b/DorkNet.Quest/scripts/build.py new file mode 100644 index 0000000..6dc2021 --- /dev/null +++ b/DorkNet.Quest/scripts/build.py @@ -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 platform +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path +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) -> None: + 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" + fail("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 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('', + '' + '') + 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'[^<]*', lambda _: f'{label}', 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:///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: /dorknet-recroom-.apk)") + b.add_argument("--package-dir", help="release folder for the .xapk (default: /DorkNet-Quest-)") + 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: /DorkNet-Quest-)") + k.add_argument("--build-tools-dir") + k.set_defaults(func=cmd_package) + + a = p.parse_args() + a.func(a) + + +if __name__ == "__main__": + main() diff --git a/DorkNet.Quest/scripts/package.ps1 b/DorkNet.Quest/scripts/package.ps1 deleted file mode 100644 index decb13e..0000000 --- a/DorkNet.Quest/scripts/package.ps1 +++ /dev/null @@ -1,172 +0,0 @@ -<# -.SYNOPSIS - Bundle a patched Quest APK and its OBB into one installable package. - -.DESCRIPTION - Writes a release folder holding: - DorkNet-.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..com.AgainstGravity.RecRoom.obb). - -.PARAMETER OutDir - Release folder to create. Default: \DorkNet-Quest-. - -.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:///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 diff --git a/DorkNet.Server/site/src/lib/xapk.ts b/DorkNet.Server/site/src/lib/xapk.ts index 27c0b1b..c74dda6 100644 --- a/DorkNet.Server/site/src/lib/xapk.ts +++ b/DorkNet.Server/site/src/lib/xapk.ts @@ -1,5 +1,5 @@ // Read a DorkNet .xapk (zip) straight from a File the player picked, without -// loading it into memory. DorkNet.Quest/scripts/package.ps1 stores the APK and +// loading it into memory. DorkNet.Quest/scripts/build.py stores the APK and // the OBB uncompressed, so those entries are plain byte ranges of the file and // stream out with Blob.slice(); manifest.json is deflated and goes through // DecompressionStream. Handles zip64 so packages over 4 GB still open. diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..9609d05 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,206 @@ +// DorkNet client builds — PC (patcher zip + full client) and Quest (.xapk). +// +// Runs on the Jenkins image from ci/jenkins (the stock image plus Python with +// LIEF, .NET, the Android NDK/build-tools, apktool, restic and rclone). +// +// Stock builds come out of the restic build archive on the FTP. Where it lives +// is server configuration, not part of the repo: the FTP host, login and the +// restic password are Jenkins credentials, and the repository folders come +// from the Jenkins container'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. + +// 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 + +def buildPc = false +def buildQuest = false + +pipeline { + agent any + + 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 from the Jenkins environment.') + string(name: 'QUEST_REPO', defaultValue: '', + description: 'Quest restic repository folder on the FTP. Empty: ARCHIVE_QUEST_REPO from the Jenkins environment.') + booleanParam(name: 'LIST_SNAPSHOTS', defaultValue: false, + description: 'Only list the snapshots matching the tags in both repositories (to find a build), then stop.') + } + + 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 Jenkins or PC_REPO on the run}}" + quest="${QUEST_REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on Jenkins or QUEST_REPO on the run}}" + echo "== PC: $pc"; archive-restore list "$pc" "$PC_TAGS" + echo "== Quest: $quest"; archive-restore list "$quest" "$QUEST_TAGS" + ''' + } + } + } + } + + 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 Jenkins 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 Jenkins 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" + ''' + } + } + } + } + } + } + + post { + success { + archiveArtifacts artifacts: 'out/**', allowEmptyArchive: true, fingerprint: true + } + cleanup { + // Runs after archiving. Keeps the restic cache and restored stock + // builds ($DORKNET_CACHE in jenkins_home, outside the workspace); drops + // the workspace's build output and scratch. + 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() + } +} diff --git a/ci/jenkins/.gitattributes b/ci/jenkins/.gitattributes new file mode 100644 index 0000000..ff28d4c --- /dev/null +++ b/ci/jenkins/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts run inside the Linux image; keep them LF on every checkout. +*.sh text eol=lf diff --git a/ci/jenkins/Dockerfile b/ci/jenkins/Dockerfile new file mode 100644 index 0000000..78486f8 --- /dev/null +++ b/ci/jenkins/Dockerfile @@ -0,0 +1,83 @@ +# DorkNet Jenkins: the stock Jenkins image plus everything the PC and Quest +# client builds need, so the job (the Jenkinsfile at the repo root) runs right +# on the controller. Jobs, plugins and settings stay in the jenkins_home +# volume; only the image changes. +# +# PC Python + .NET SDK tools/build_pc.py +# Quest Python/LIEF + JDK + Android NDK, build-tools, apktool +# DorkNet.Quest/scripts/build.py +# Builds restic + rclone stock builds restored from the restic +# archive on the FTP (archive-restore.sh) +# +# See ci/jenkins/README.md. + +# Match this to the Jenkins image your Dokploy service runs today, so the +# upgrade keeps the same Jenkins version and Java: e.g. +# --build-arg JENKINS_IMAGE=jenkins/jenkins:2.492.3-lts-jdk17 +ARG JENKINS_IMAGE=jenkins/jenkins:lts-jdk17 +FROM ${JENKINS_IMAGE} + +USER root + +# ---- 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-8.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 live in the persistent +# jenkins_home volume, so each multi-GB build is downloaded once. +ENV DORKNET_CACHE=/var/jenkins_home/dorknet-cache + +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \ + DOTNET_NOLOGO=1 + +USER jenkins diff --git a/ci/jenkins/README.md b/ci/jenkins/README.md new file mode 100644 index 0000000..a6f2eda --- /dev/null +++ b/ci/jenkins/README.md @@ -0,0 +1,140 @@ +# Client builds on Jenkins + +The `Jenkinsfile` at the repo root builds the DorkNet clients on your existing +Jenkins. + +It runs right on the Jenkins container, so that container's image needs the +build toolchain: Python with LIEF, the .NET SDK, the Android NDK and build-tools, +apktool, restic and rclone. `ci/jenkins/Dockerfile` is the stock +Jenkins image with that toolchain added. Your jobs, plugins and settings live +in the `jenkins_home` volume, not in the image, so switching images leaves +them alone. + +| Platform | Output (Jenkins artifacts) | How | +|---|---|---| +| PC | `out/pc/dorknet-clientpatch-march_2023_03_21.zip`: MelonLoader, mod, config template, Steam stub, `manifest.json`; no game files | `tools/build_pc.py patcher` | +| PC | `out/pc/dorknet-client-march_2023_03_21.zip`: the stock Steam build with the patcher applied and your config; ready to run | `tools/build_pc.py client` | +| Quest | `out/quest/dorknet-recroom-.apk` and `out/quest/DorkNet-Quest-/` (`.xapk` + installers) | `DorkNet.Quest/scripts/build.py build` | + +The full PC client and the Quest packages contain game files, so keep them on +Jenkins. Don't publish them anywhere public. + +## Where the stock builds come from + +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=`, `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 in `jenkins_home/dorknet-cache`, so each stock +build is downloaded once. + +A Quest build needs a resolved hook table for its versionCode, committed under +`DorkNet.Quest/targets/`. Only `20230317-2128210.json` exists today. The build +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. + +## Setup + +### 1. Switch your Jenkins to this image + +On your Jenkins service in Dokploy: + +1. **Build from this repo.** + - For an Application service: provider GitHub, branch `march-2023-03-21`, + build type Dockerfile, Dockerfile path `ci/jenkins/Dockerfile`, build + context `ci/jenkins`. + - For a Compose service: change its `jenkins` service to `build:` from + `ci/jenkins` as in `ci/jenkins/docker-compose.yml`. + - Either way, **keep the same volume on `/var/jenkins_home`**. +2. **Match the Jenkins version.** Set the build argument `JENKINS_IMAGE` to the + image the service runs today (e.g. `jenkins/jenkins:2.492.3-lts-jdk17`). + The default is `jenkins/jenkins:lts-jdk17`. +3. **Add the environment:** + + | Variable | Value | + |---|---| + | `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` | + +4. **Redeploy.** The first image build downloads the Android NDK (~1.5 GB) and + takes a while. + +### 2. 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. + +### 3. 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` | + +**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. + +### 4. 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**. + +### 5. GitHub webhook + +In the repository's **Settings → Webhooks**: + +- Payload URL: `https:///github-webhook/` +- Content type: `application/json` +- Events: just the push event + +### Disk + +The restored stock builds are cached in `jenkins_home/dorknet-cache`, several +GB each. Jenkins keeps the artifacts of the last 3 runs. diff --git a/ci/jenkins/archive-restore.sh b/ci/jenkins/archive-restore.sh new file mode 100644 index 0000000..b323fd7 --- /dev/null +++ b/ci/jenkins/archive-restore.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# archive-restore — pull a Rec Room build out of the restic build archive. +# +# archive-restore list [tag[,tag…]] +# archive-restore restore [restic restore args…] +# +# 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 // 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="${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 diff --git a/ci/jenkins/docker-compose.yml b/ci/jenkins/docker-compose.yml new file mode 100644 index 0000000..cc05993 --- /dev/null +++ b/ci/jenkins/docker-compose.yml @@ -0,0 +1,31 @@ +# Reference for the Jenkins service on Dokploy, built from this directory's +# Dockerfile (the stock Jenkins image plus the client build toolchain). +# +# Use it on your EXISTING Jenkins service rather than as a second Jenkins. +# The data volume must be the one Jenkins uses today, or Jenkins starts empty. +# Point `volumes:` at that volume (and its name under the top-level +# `volumes:`, marked `external: true`), or keep this service's name and volume +# if they already match. See ci/jenkins/README.md. +services: + jenkins: + build: + context: . + dockerfile: Dockerfile + args: + # The image your Jenkins runs today, so the Jenkins version and Java + # stay the same. + JENKINS_IMAGE: ${JENKINS_IMAGE:-jenkins/jenkins:lts-jdk17} + restart: unless-stopped + expose: + - "8080" + environment: + # Where the stock builds live in the archive (restic repository folders + # on the FTP), as plain text. Overridable per run. + ARCHIVE_PC_REPO: ${ARCHIVE_PC_REPO:-} + ARCHIVE_QUEST_REPO: ${ARCHIVE_QUEST_REPO:-} + ARCHIVE_FTP_TLS: ${ARCHIVE_FTP_TLS:-explicit} + volumes: + - jenkins_home:/var/jenkins_home + +volumes: + jenkins_home: diff --git a/tools/build-patcher-zip.ps1 b/tools/build-patcher-zip.ps1 deleted file mode 100644 index 4f885a1..0000000 --- a/tools/build-patcher-zip.ps1 +++ /dev/null @@ -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 } -} diff --git a/tools/build_pc.py b/tools/build_pc.py new file mode 100644 index 0000000..7615b55 --- /dev/null +++ b/tools/build_pc.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Build the DorkNet PC client packages. + + patcher dorknet-clientpatch-.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 + +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) -> None: + 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-.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: /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-.zip from `patcher`") + c.add_argument("--config", help="dorknet-clientmod.json to install (default: the unedited template)") + c.add_argument("--output-zip", help="default: /dist/dorknet-client-.zip") + c.set_defaults(func=cmd_client) + + a = p.parse_args() + a.func(a) + + +if __name__ == "__main__": + main() From 83b896ffafe1b6e3be5b666b658346bffa6ac062 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:30:40 +0300 Subject: [PATCH 2/2] fix: drop an unused import and make the build scripts' exits explicit --- DorkNet.Quest/scripts/build.py | 6 +++--- tools/build_pc.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/DorkNet.Quest/scripts/build.py b/DorkNet.Quest/scripts/build.py index 6dc2021..ef29c0e 100644 --- a/DorkNet.Quest/scripts/build.py +++ b/DorkNet.Quest/scripts/build.py @@ -32,7 +32,6 @@ import argparse import hashlib import json import os -import platform import re import shutil import subprocess @@ -40,6 +39,7 @@ 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 @@ -53,7 +53,7 @@ def step(msg: str) -> None: print(f"==> {msg}", flush=True) -def fail(msg: str) -> None: +def fail(msg: str) -> NoReturn: raise SystemExit(f"error: {msg}") @@ -143,7 +143,7 @@ def resolve_apktool(arg: str | None) -> Path: root = sdk_root() if root and (root / "apktool.jar").exists(): return root / "apktool.jar" - fail("apktool.jar not found. Pass --apktool-jar or set 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: diff --git a/tools/build_pc.py b/tools/build_pc.py index 7615b55..49d3e5e 100644 --- a/tools/build_pc.py +++ b/tools/build_pc.py @@ -28,6 +28,7 @@ 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" @@ -50,7 +51,7 @@ def step(msg: str) -> None: print(f"==> {msg}", flush=True) -def fail(msg: str) -> None: +def fail(msg: str) -> NoReturn: raise SystemExit(f"error: {msg}")