From 9154d19014a9e1213fb61b5261966542b72b1e51 Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:06:57 +0100 Subject: [PATCH 1/3] merge v5loader and v5modloader... --- .../ctjs/internal/launch/ModLoaderUpdater.kt | 143 ++++---- .../ctjs/internal/launch/SecureLoader.kt | 221 +------------ .../ctjs/internal/launch/V5TokenSource.kt | 5 +- src/main/kotlin/com/v5/loader/V5PreLaunch.kt | 14 + .../kotlin/com/v5/loader/internal/V5Crypto.kt | 39 +++ .../kotlin/com/v5/loader/internal/V5Http.kt | 122 +++++++ .../kotlin/com/v5/loader/internal/V5Loader.kt | 306 ++++++++++++++++++ .../v5/natives/windows/x86_64/V5PathJNI.dll | Bin 180736 -> 180736 bytes src/main/resources/fabric.mod.json | 4 + 9 files changed, 547 insertions(+), 307 deletions(-) create mode 100644 src/main/kotlin/com/v5/loader/V5PreLaunch.kt create mode 100644 src/main/kotlin/com/v5/loader/internal/V5Crypto.kt create mode 100644 src/main/kotlin/com/v5/loader/internal/V5Http.kt create mode 100644 src/main/kotlin/com/v5/loader/internal/V5Loader.kt diff --git a/src/main/kotlin/com/chattriggers/ctjs/internal/launch/ModLoaderUpdater.kt b/src/main/kotlin/com/chattriggers/ctjs/internal/launch/ModLoaderUpdater.kt index d2b1aa4..088417e 100644 --- a/src/main/kotlin/com/chattriggers/ctjs/internal/launch/ModLoaderUpdater.kt +++ b/src/main/kotlin/com/chattriggers/ctjs/internal/launch/ModLoaderUpdater.kt @@ -5,11 +5,11 @@ import java.io.FileOutputStream import java.nio.charset.StandardCharsets internal object ModLoaderUpdater { - private const val MOD_LOADER_CANONICAL_FILE_NAME = "V5ModLoader.jar" + private const val MOD_LOADER_CANONICAL_FILE_NAME = "V5-Loader.jar" private const val MOD_LOADER_HELPER_TIMEOUT_SECONDS = 90 private const val MOD_LOADER_REPLACE_RETRY_COUNT = 20 - private const val UPDATE_POPUP_TITLE = "V5ModLoader" - private const val UPDATE_POPUP_MESSAGE = "V5ModLoader updated. Start minecraft again." + private const val UPDATE_POPUP_TITLE = "V5-Loader" + private const val UPDATE_POPUP_MESSAGE = "V5-Loader updated. Start Minecraft again." fun stageUpdateAndRelaunch( gameDir: File, @@ -34,9 +34,9 @@ internal object ModLoaderUpdater { val modsDir = File(canonicalGameDir, "mods").canonicalFile.apply { mkdirs() } val updaterDir = File(canonicalGameDir, ".v5-self-update").canonicalFile.apply { mkdirs() } val pid = ProcessHandle.current().pid() - val sourceJar = File(updaterDir, "V5ModLoader-$pid.jar.new").canonicalFile - val targetJar = selectModLoaderTarget(modsDir, candidates) - val backupJar = File(updaterDir, "V5ModLoader-$pid.jar.bak").canonicalFile + val sourceJar = File(updaterDir, "V5-Loader-$pid.jar.new").canonicalFile + val targetJar = selectLoaderTarget(modsDir, candidates) + val backupJar = File(updaterDir, "V5-Loader-$pid.jar.bak").canonicalFile val targetPath = targetJar.toPath().toAbsolutePath().normalize() val staleTargets = candidates .map { it.toPath().toAbsolutePath().normalize() } @@ -53,7 +53,7 @@ internal object ModLoaderUpdater { ) } - private fun selectModLoaderTarget(modsDir: File, candidates: List): File { + private fun selectLoaderTarget(modsDir: File, candidates: List): File { val canonicalModsDir = modsDir.canonicalFile.toPath().normalize() val existingTarget = candidates.singleOrNull() ?.canonicalFile @@ -74,90 +74,63 @@ internal object ModLoaderUpdater { } private fun writeUnixUpdateScript(updatePaths: UpdatePaths): File { - val script = File(updatePaths.sourceJar.parentFile, "modloader-update-${updatePaths.pid}.sh").canonicalFile - val lines = buildList { - add("#!/bin/sh") - add("PID=${updatePaths.pid}") - add("WAIT_SECONDS=0") - add("while kill -0 \"\$PID\" 2>/dev/null; do") - add(" sleep 1") - add(" WAIT_SECONDS=\$((WAIT_SECONDS + 1))") - add(" if [ \"\$WAIT_SECONDS\" -ge \"$MOD_LOADER_HELPER_TIMEOUT_SECONDS\" ]; then") - add(" exit 1") - add(" fi") - add("done") - add("ATTEMPT=0") - add("while [ \"\$ATTEMPT\" -lt \"$MOD_LOADER_REPLACE_RETRY_COUNT\" ]; do") - add(" mkdir -p ${shellQuote(updatePaths.targetJar.parentFile.canonicalPath)}") - add(" rm -f ${shellQuote(updatePaths.backupJar.absolutePath)} >/dev/null 2>&1 || true") - add(" if [ -f ${shellQuote(updatePaths.targetJar.absolutePath)} ]; then") - add(" mv -f ${shellQuote(updatePaths.targetJar.absolutePath)} ${shellQuote(updatePaths.backupJar.absolutePath)} >/dev/null 2>&1 || true") - add(" fi") - add(" if mv -f ${shellQuote(updatePaths.sourceJar.absolutePath)} ${shellQuote(updatePaths.targetJar.absolutePath)} >/dev/null 2>&1; then") - updatePaths.staleTargets.forEach { target -> - add(" rm -f ${shellQuote(target.absolutePath)} >/dev/null 2>&1 || true") - } - add(" rm -f ${shellQuote(updatePaths.backupJar.absolutePath)} >/dev/null 2>&1 || true") - add(" break") - add(" fi") - add(" if [ -f ${shellQuote(updatePaths.backupJar.absolutePath)} ]; then") - add(" mv -f ${shellQuote(updatePaths.backupJar.absolutePath)} ${shellQuote(updatePaths.targetJar.absolutePath)} >/dev/null 2>&1 || true") - add(" fi") - add(" ATTEMPT=\$((ATTEMPT + 1))") - add(" sleep 1") - add("done") - add("[ -f ${shellQuote(updatePaths.targetJar.absolutePath)} ] || exit 1") - add("if command -v osascript >/dev/null 2>&1; then") - add(" nohup osascript -e ${shellQuote("display dialog \"$UPDATE_POPUP_MESSAGE\" with title \"$UPDATE_POPUP_TITLE\" buttons {\"OK\"} default button 1")} >/dev/null 2>&1 &") - add("elif command -v zenity >/dev/null 2>&1; then") - add(" nohup zenity --info --title=${shellQuote(UPDATE_POPUP_TITLE)} --text=${shellQuote(UPDATE_POPUP_MESSAGE)} >/dev/null 2>&1 &") - add("elif command -v kdialog >/dev/null 2>&1; then") - add(" nohup kdialog --title ${shellQuote(UPDATE_POPUP_TITLE)} --msgbox ${shellQuote(UPDATE_POPUP_MESSAGE)} >/dev/null 2>&1 &") - add("elif command -v xmessage >/dev/null 2>&1; then") - add(" nohup xmessage -center ${shellQuote(UPDATE_POPUP_MESSAGE)} >/dev/null 2>&1 &") - add("fi") - add("rm -f ${shellQuote(script.absolutePath)} >/dev/null 2>&1 || true") - add("exit 0") + val script = File(updatePaths.sourceJar.parentFile, "v5-loader-update-${updatePaths.pid}.sh").canonicalFile + val lines = mutableListOf() + lines += "#!/bin/sh" + lines += "PID=${updatePaths.pid}" + lines += "WAIT_SECONDS=0" + lines += "while kill -0 \"\$PID\" 2>/dev/null; do" + lines += " sleep 1" + lines += " WAIT_SECONDS=\$((WAIT_SECONDS + 1))" + lines += " if [ \"\$WAIT_SECONDS\" -ge \"$MOD_LOADER_HELPER_TIMEOUT_SECONDS\" ]; then" + lines += " exit 1" + lines += " fi" + lines += "done" + lines += "ATTEMPT=0" + lines += "while [ \"\$ATTEMPT\" -lt \"$MOD_LOADER_REPLACE_RETRY_COUNT\" ]; do" + lines += " mkdir -p ${shellQuote(updatePaths.targetJar.parentFile.canonicalPath)}" + lines += " rm -f ${shellQuote(updatePaths.backupJar.absolutePath)} >/dev/null 2>&1 || true" + lines += " if [ -f ${shellQuote(updatePaths.targetJar.absolutePath)} ]; then" + lines += " mv -f ${shellQuote(updatePaths.targetJar.absolutePath)} ${shellQuote(updatePaths.backupJar.absolutePath)} >/dev/null 2>&1 || true" + lines += " fi" + lines += " if mv -f ${shellQuote(updatePaths.sourceJar.absolutePath)} ${shellQuote(updatePaths.targetJar.absolutePath)} >/dev/null 2>&1; then" + updatePaths.staleTargets.forEach { target -> + lines += " rm -f ${shellQuote(target.absolutePath)} >/dev/null 2>&1 || true" } script.writeLines(lines) return script } private fun writeWindowsUpdateScript(updatePaths: UpdatePaths): File { - val script = File(updatePaths.sourceJar.parentFile, "modloader-update-${updatePaths.pid}.cmd").canonicalFile - val lines = buildList { - add("@echo off") - add("setlocal enabledelayedexpansion") - add("set \"PID=${updatePaths.pid}\"") - add("set \"ATTEMPTS=0\"") - add(":wait_for_exit") - add("tasklist /FI \"PID eq %PID%\" | find \"%PID%\" >nul") - add("if not errorlevel 1 (") - add(" timeout /t 1 /nobreak >nul") - add(" set /a ATTEMPTS+=1") - add(" if !ATTEMPTS! geq $MOD_LOADER_HELPER_TIMEOUT_SECONDS exit /b 1") - add(" goto wait_for_exit") - add(")") - add("set \"ATTEMPTS=0\"") - add(":replace_loop") - add("del /f /q ${cmdQuote(updatePaths.backupJar.absolutePath)} >nul 2>nul") - add("if not exist ${cmdQuote(updatePaths.targetJar.parentFile.canonicalPath)} mkdir ${cmdQuote(updatePaths.targetJar.parentFile.canonicalPath)}") - add("if exist ${cmdQuote(updatePaths.targetJar.absolutePath)} move /y ${cmdQuote(updatePaths.targetJar.absolutePath)} ${cmdQuote(updatePaths.backupJar.absolutePath)} >nul 2>nul") - add("move /y ${cmdQuote(updatePaths.sourceJar.absolutePath)} ${cmdQuote(updatePaths.targetJar.absolutePath)} >nul 2>nul") - add("if not errorlevel 1 if exist ${cmdQuote(updatePaths.targetJar.absolutePath)} if not exist ${cmdQuote(updatePaths.sourceJar.absolutePath)} goto cleanup") - add("if exist ${cmdQuote(updatePaths.backupJar.absolutePath)} move /y ${cmdQuote(updatePaths.backupJar.absolutePath)} ${cmdQuote(updatePaths.targetJar.absolutePath)} >nul 2>nul") - add("set /a ATTEMPTS+=1") - add("if !ATTEMPTS! geq $MOD_LOADER_REPLACE_RETRY_COUNT exit /b 1") - add("timeout /t 1 /nobreak >nul") - add("goto replace_loop") - add(":cleanup") - updatePaths.staleTargets.forEach { target -> - add("del /f /q ${cmdQuote(target.absolutePath)} >nul 2>nul") - } - add("del /f /q ${cmdQuote(updatePaths.backupJar.absolutePath)} >nul 2>nul") - add("powershell -NoProfile -ExecutionPolicy Bypass -Command ${cmdQuote(buildPowerShellPopupCommand())} >nul 2>nul") - add("del /f /q \"%~f0\" >nul 2>nul") - add("exit /b 0") + val script = File(updatePaths.sourceJar.parentFile, "v5-loader-update-${updatePaths.pid}.cmd").canonicalFile + val lines = mutableListOf() + lines += "@echo off" + lines += "setlocal enabledelayedexpansion" + lines += "set \"PID=${updatePaths.pid}\"" + lines += "set \"ATTEMPTS=0\"" + lines += ":wait_for_exit" + lines += "tasklist /FI \"PID eq %PID%\" | find \"%PID%\" >nul" + lines += "if not errorlevel 1 (" + lines += " timeout /t 1 /nobreak >nul" + lines += " set /a ATTEMPTS+=1" + lines += " if !ATTEMPTS! geq $MOD_LOADER_HELPER_TIMEOUT_SECONDS exit /b 1" + lines += " goto wait_for_exit" + lines += ")" + lines += "set \"ATTEMPTS=0\"" + lines += ":replace_loop" + lines += "del /f /q ${cmdQuote(updatePaths.backupJar.absolutePath)} >nul 2>nul" + lines += "if not exist ${cmdQuote(updatePaths.targetJar.parentFile.canonicalPath)} mkdir ${cmdQuote(updatePaths.targetJar.parentFile.canonicalPath)}" + lines += "if exist ${cmdQuote(updatePaths.targetJar.absolutePath)} move /y ${cmdQuote(updatePaths.targetJar.absolutePath)} ${cmdQuote(updatePaths.backupJar.absolutePath)} >nul 2>nul" + lines += "move /y ${cmdQuote(updatePaths.sourceJar.absolutePath)} ${cmdQuote(updatePaths.targetJar.absolutePath)} >nul 2>nul" + lines += "if not errorlevel 1 if exist ${cmdQuote(updatePaths.targetJar.absolutePath)} if not exist ${cmdQuote(updatePaths.sourceJar.absolutePath)} goto cleanup" + lines += "if exist ${cmdQuote(updatePaths.backupJar.absolutePath)} move /y ${cmdQuote(updatePaths.backupJar.absolutePath)} ${cmdQuote(updatePaths.targetJar.absolutePath)} >nul 2>nul" + lines += "set /a ATTEMPTS+=1" + lines += "if !ATTEMPTS! geq $MOD_LOADER_REPLACE_RETRY_COUNT exit /b 1" + lines += "timeout /t 1 /nobreak >nul" + lines += "goto replace_loop" + lines += ":cleanup" + updatePaths.staleTargets.forEach { target -> + lines += "del /f /q ${cmdQuote(target.absolutePath)} >nul 2>nul" } script.writeLines(lines, "\r\n") return script diff --git a/src/main/kotlin/com/chattriggers/ctjs/internal/launch/SecureLoader.kt b/src/main/kotlin/com/chattriggers/ctjs/internal/launch/SecureLoader.kt index fd749a4..6f68137 100644 --- a/src/main/kotlin/com/chattriggers/ctjs/internal/launch/SecureLoader.kt +++ b/src/main/kotlin/com/chattriggers/ctjs/internal/launch/SecureLoader.kt @@ -15,7 +15,6 @@ import net.minecraft.util.Util import java.io.ByteArrayInputStream import java.io.File import java.io.IOException -import java.io.FileInputStream import java.io.FileOutputStream import java.net.URI import java.nio.charset.StandardCharsets @@ -30,7 +29,6 @@ internal object SecureLoader { private const val BACKEND_URL = "https://backend.rdbt.top" private const val DISK_MODULE_NAME = "V5" private const val LOADER_USER_AGENT = "V5Loader/1.1" - private const val RAT_DETECTED_DOCS_URL = "https://rdbt.top/docs/rat-detected" private const val TOKEN_EXPIRY_SKEW_SECONDS = 60L private const val SESSION_DIR_NAME = ".v5" private const val SESSION_FILE_NAME = "session.json" @@ -46,20 +44,6 @@ internal object SecureLoader { @Volatile private var internalToken: String? = null @Volatile private var didConsumeInitialLoaderToken = false - private enum class ModLoaderStatus { - VALID, - OUTDATED, - INVALID_TAMPERED, - INVALID_INSTALLATION, - CHECK_FAILED - } - - private data class ModLoaderCheckResult( - val status: ModLoaderStatus, - val candidates: List, - val message: String - ) - @JvmStatic fun getJwtToken(): String? { val token = internalToken @@ -295,21 +279,18 @@ internal object SecureLoader { fun onMixinPlugin() { if (isPluginLoaded) return - if (!ensureV5ModLoaderInstalled()) { - shutDownHard() - } println("[V5] Stage: onMixinPlugin") try { val token = getFreshJwtToken() if (token.isNullOrBlank()) { - println("[V5] No token passed from modloader.") + println("[V5] No loader auth token available.") shutDownHard() } val modulePath = getV5ModuleDir() if (modulePath.exists() && isLocalDeveloperModeEnabled()) { isDevMode = true - println("[V5] Developer mode with existing V5 module path. Skipping V5 module download.") + println("[V5] Developer mode is active. Skipping V5 module download.") isPluginLoaded = true return } @@ -324,28 +305,6 @@ internal object SecureLoader { } } - private fun ensureV5ModLoaderInstalled(): Boolean { - val result = checkV5ModLoader() - return when (result.status) { - ModLoaderStatus.VALID -> true - ModLoaderStatus.OUTDATED, - ModLoaderStatus.INVALID_INSTALLATION -> { - println("[V5] ${result.message}") - tryAutoUpdateModLoader(result) - false - } - ModLoaderStatus.INVALID_TAMPERED -> { - println("[V5] ${result.message}") - openRatDetectedDocsPage() - false - } - ModLoaderStatus.CHECK_FAILED -> { - println("[V5] ${result.message}") - false - } - } - } - private fun isLocalDeveloperModeEnabled(): Boolean { return try { val stateFile = File(File(CTJS.MODULES_FOLDER, "V5Config"), "developerMode.json") @@ -359,162 +318,6 @@ internal object SecureLoader { } } - @Suppress("FunctionName") - fun V5ModLoaderCheck(): Boolean { - return checkV5ModLoader().status == ModLoaderStatus.VALID - } - - private fun checkV5ModLoader(): ModLoaderCheckResult { - val modsDir = File(getGameDir(), "mods") - val candidates = modsDir.walk() - .filter { file -> file.isFile && isV5ModLoaderJar(file.name) } - .toList() - - if (candidates.size != 1) { - return ModLoaderCheckResult( - status = ModLoaderStatus.INVALID_INSTALLATION, - candidates = candidates, - message = "Expected one V5ModLoader jar in mods, found ${candidates.size}. Repairing install." - ) - } - - val hash = calculateFileSha256(candidates.first()) - if (hash.isBlank()) { - return ModLoaderCheckResult( - status = ModLoaderStatus.INVALID_INSTALLATION, - candidates = candidates, - message = "Failed to compute V5ModLoader hash. Repairing install." - ) - } - - val token = getFreshJwtToken() - if (token.isNullOrBlank()) { - return ModLoaderCheckResult( - status = ModLoaderStatus.CHECK_FAILED, - candidates = candidates, - message = "Missing auth token for modloader integrity check." - ) - } - - val connection = openBackendConnection("$BACKEND_URL/api/hash/modloader?hash=$hash") - - return try { - connection.requestMethod = "GET" - connection.setRequestProperty("Authorization", "Bearer $token") - connection.setRequestProperty("User-Agent", LOADER_USER_AGENT) - connection.setRequestProperty("Content-Type", "application/json") - connection.connectTimeout = 10000 - connection.readTimeout = 10000 - - val responseCode = connection.responseCode - val stream = if (responseCode in 200..299) - connection.inputStream - else - connection.errorStream - - val responseText = stream?.bufferedReader()?.use { it.readText() } ?: "" - - if (responseCode != 200) { - return ModLoaderCheckResult( - status = ModLoaderStatus.CHECK_FAILED, - candidates = candidates, - message = "Modloader integrity check failed ($responseCode): $responseText" - ) - } - - val json = jsonParser.parseToJsonElement(responseText).jsonObject - val integrity = json["integrity"]?.jsonPrimitive?.contentOrNull?.lowercase(Locale.ROOT) - - when (integrity) { - "valid" -> ModLoaderCheckResult( - status = ModLoaderStatus.VALID, - candidates = candidates, - message = "V5ModLoader integrity verified." - ) - "outdated" -> ModLoaderCheckResult( - status = ModLoaderStatus.OUTDATED, - candidates = candidates, - message = "V5ModLoader integrity is outdated. Downloading the latest build from backend." - ) - "invalid" -> ModLoaderCheckResult( - status = ModLoaderStatus.INVALID_TAMPERED, - candidates = candidates, - message = "V5ModLoader integrity is invalid. A malicious modified jar may be installed. Opened $RAT_DETECTED_DOCS_URL for more info." - ) - else -> ModLoaderCheckResult( - status = ModLoaderStatus.CHECK_FAILED, - candidates = candidates, - message = "Modloader integrity check returned unknown state: ${integrity ?: "missing"}" - ) - } - } catch (e: Exception) { - e.printStackTrace() - ModLoaderCheckResult( - status = ModLoaderStatus.CHECK_FAILED, - candidates = candidates, - message = "Failed to verify V5ModLoader against backend." - ) - } finally { - connection.disconnect() - } - } - - fun calculateFileSha256(file: File): String { - val digest = java.security.MessageDigest.getInstance("SHA-256") - - FileInputStream(file).use { fis -> - val buffer = ByteArray(8192) - var read: Int - - while (fis.read(buffer).also { read = it } != -1) { - digest.update(buffer, 0, read) - } - } - - return digest.digest().joinToString("") { "%02x".format(it) } - } - - private fun tryAutoUpdateModLoader(result: ModLoaderCheckResult) { - val token = getFreshJwtToken() - if (token.isNullOrBlank()) { - println("[V5] Missing auth token for automatic V5ModLoader repair.") - return - } - - val modLoaderBytes = try { - downloadAsset("/api/download/modloader", token) - } catch (e: Exception) { - println("[V5] Failed to download the latest V5ModLoader.") - e.printStackTrace() - return - } - - var updateStaged = false - try { - ModLoaderUpdater.stageUpdateAndRelaunch(getGameDir(), modLoaderBytes, result.candidates) - updateStaged = true - println("[V5] V5ModLoader update staged. Closing Minecraft now so the helper can swap jars.") - } catch (e: Exception) { - println("[V5] Failed to stage V5ModLoader update.") - e.printStackTrace() - } finally { - Arrays.fill(modLoaderBytes, 0) - } - - if (updateStaged) { - forceCloseForModLoaderUpdate() - } - } - - private fun openRatDetectedDocsPage() { - if (tryOpenUrl(RAT_DETECTED_DOCS_URL)) return - println("[V5] Failed to open browser automatically. Visit $RAT_DETECTED_DOCS_URL") - } - - private fun tryOpenUrl(url: String): Boolean { - return runCatching { Util.getPlatform().openUri(URI(url)) }.isSuccess - } - fun onInitialize() { if (isLoaded) return println("[V5] Stage: onInitialize") @@ -623,18 +426,6 @@ internal object SecureLoader { run() } - private fun forceCloseForModLoaderUpdate(): Nothing { - try { - System.out.flush() - System.err.flush() - Thread.sleep(150) - } catch (_: Exception) { - } - - Runtime.getRuntime().halt(0) - throw IllegalStateException("Failed to terminate process after staging V5ModLoader update") - } - private fun shutDownHard(): Nothing { Runtime.getRuntime().halt(0) throw IllegalStateException("V5 loader aborted due to unrecoverable error") @@ -642,14 +433,6 @@ internal object SecureLoader { fun isLoaded(): Boolean = isLoaded - private fun isV5ModLoaderJar(fileName: String): Boolean { - if (!fileName.endsWith(".jar", ignoreCase = true)) return false - if (fileName.startsWith("V5ModLoader", ignoreCase = true)) return true - // Gradle publish name (e.g. v5-1.0.0.jar), not V5-Loader.jar (case-insensitive v5- prefix). - return fileName.startsWith("v5-", ignoreCase = true) && - !fileName.startsWith("V5-Loader", ignoreCase = true) - } - private fun getGameDir(): File { return FabricLoader.getInstance().gameDir.toFile() } diff --git a/src/main/kotlin/com/chattriggers/ctjs/internal/launch/V5TokenSource.kt b/src/main/kotlin/com/chattriggers/ctjs/internal/launch/V5TokenSource.kt index 9a51ab2..e9382df 100644 --- a/src/main/kotlin/com/chattriggers/ctjs/internal/launch/V5TokenSource.kt +++ b/src/main/kotlin/com/chattriggers/ctjs/internal/launch/V5TokenSource.kt @@ -4,11 +4,10 @@ internal object V5TokenSource { private val consumeTokenMethod by lazy { runCatching { Class.forName("com.v5.loader.internal.V5Loader").getMethod("consumeToken") - }.getOrNull() ?: runCatching { - Class.forName("com.v5.loader.JNI").getMethod("consumeToken") }.getOrNull() } @JvmStatic - fun consumeToken(): String? = consumeTokenMethod?.invoke(null) as? String + fun consumeToken(): String? = + (consumeTokenMethod?.invoke(null) as? String)?.takeIf { it.isNotBlank() } } diff --git a/src/main/kotlin/com/v5/loader/V5PreLaunch.kt b/src/main/kotlin/com/v5/loader/V5PreLaunch.kt new file mode 100644 index 0000000..08b6088 --- /dev/null +++ b/src/main/kotlin/com/v5/loader/V5PreLaunch.kt @@ -0,0 +1,14 @@ +package com.v5.loader + +import com.v5.loader.internal.V5Loader +import net.fabricmc.loader.api.FabricLoader +import net.fabricmc.loader.api.entrypoint.PreLaunchEntrypoint + +class V5PreLaunch : PreLaunchEntrypoint { + override fun onPreLaunch() { + val fabricLoader = FabricLoader.getInstance() + System.setProperty("v5.game_dir", fabricLoader.gameDir.toAbsolutePath().toString()) + System.setProperty("v5.minecraft_version", fabricLoader.rawGameVersion) + V5Loader.init() + } +} diff --git a/src/main/kotlin/com/v5/loader/internal/V5Crypto.kt b/src/main/kotlin/com/v5/loader/internal/V5Crypto.kt new file mode 100644 index 0000000..a88dbe5 --- /dev/null +++ b/src/main/kotlin/com/v5/loader/internal/V5Crypto.kt @@ -0,0 +1,39 @@ +package com.v5.loader.internal + +import java.security.MessageDigest +import java.security.SecureRandom +import java.nio.file.Path +import kotlin.io.path.inputStream + +internal object V5Crypto { + private val secureRandom = SecureRandom() + + fun calculateFileSha256(path: String): String { + repeat(10) { attempt -> + try { + val digest = MessageDigest.getInstance("SHA-256") + Path.of(path).inputStream().use { input -> + val buffer = ByteArray(8192) + while (true) { + val read = input.read(buffer) + if (read <= 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } catch (_: Exception) { + if (attempt < 9) Thread.sleep(100) + } + } + return "" + } + + fun generateUrlSafeNonce(byteLen: Int = 16): String { + if (byteLen <= 0) return "" + val nonceBytes = ByteArray(byteLen) + secureRandom.nextBytes(nonceBytes) + var nonce = java.util.Base64.getEncoder().encodeToString(nonceBytes) + nonce = nonce.replace('+', '-').replace('/', '_') + return nonce.trimEnd('=') + } +} diff --git a/src/main/kotlin/com/v5/loader/internal/V5Http.kt b/src/main/kotlin/com/v5/loader/internal/V5Http.kt new file mode 100644 index 0000000..771cf4b --- /dev/null +++ b/src/main/kotlin/com/v5/loader/internal/V5Http.kt @@ -0,0 +1,122 @@ +package com.v5.loader.internal + +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +internal object V5Http { + const val BACKEND_HOST = "backend.rdbt.top" + private const val HTTP_TIMEOUT_SECONDS = 60L + private const val USER_AGENT = "V5Loader/1.1" + + private val httpClient: HttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + + fun httpsGetBytes(host: String, path: String, jwt: String = ""): ByteArray? { + val url = "https://$host$path" + val requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS)) + .header("User-Agent", USER_AGENT) + .GET() + + if (jwt.isNotEmpty()) { + requestBuilder.header("Authorization", "Bearer $jwt") + } + + return try { + val response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()) + if (response.statusCode() != 200) { + logHttpFailure("GET", url, response.statusCode(), response.body().size) + return null + } + response.body() + } catch (e: Exception) { + logTransportFailure("GET", url, e) + null + } + } + + fun httpsGet( + host: String, + path: String, + jwt: String = "", + extraHeaders: List> = emptyList(), + ): String { + val url = "https://$host$path" + val requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS)) + .header("User-Agent", USER_AGENT) + .GET() + + if (jwt.isNotEmpty()) { + requestBuilder.header("Authorization", "Bearer $jwt") + } + for ((name, value) in extraHeaders) { + requestBuilder.header(name, value) + } + + return try { + val response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() != 200) { + logHttpFailure("GET", url, response.statusCode(), response.body().length) + return "" + } + response.body() + } catch (e: Exception) { + logTransportFailure("GET", url, e) + "" + } + } + + fun httpsPost(host: String, path: String, jsonBody: String, jwt: String = ""): String { + val url = "https://$host$path" + val requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS)) + .header("Content-Type", "application/json") + .header("User-Agent", USER_AGENT) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + + if (jwt.isNotEmpty()) { + requestBuilder.header("Authorization", "Bearer $jwt") + } + + return try { + val response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() != 200) { + logHttpFailure("POST", url, response.statusCode(), response.body().length) + return "" + } + response.body() + } catch (e: Exception) { + logTransportFailure("POST", url, e) + "" + } + } + + fun fetchLoaderHash(token: String, hash: String, minecraftVersion: String): String { + return httpsGet( + BACKEND_HOST, + "/api/hash/loader?hash=$hash&minecraft_version=$minecraftVersion", + token, + ) + } + + private fun logTransportFailure(method: String, url: String, error: Exception) { + System.err.println( + "[V5] Curl $method failed url=$url curl_error=${error.message ?: error.javaClass.simpleName}", + ) + } + + private fun logHttpFailure(method: String, url: String, httpCode: Int, responseBytes: Int) { + System.err.println( + "[V5] HTTP $method returned non-200 url=$url http_code=$httpCode response_bytes=$responseBytes", + ) + } +} diff --git a/src/main/kotlin/com/v5/loader/internal/V5Loader.kt b/src/main/kotlin/com/v5/loader/internal/V5Loader.kt new file mode 100644 index 0000000..cec3ee0 --- /dev/null +++ b/src/main/kotlin/com/v5/loader/internal/V5Loader.kt @@ -0,0 +1,306 @@ +package com.v5.loader.internal + +import com.chattriggers.ctjs.internal.launch.ModLoaderUpdater +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import net.fabricmc.loader.api.FabricLoader +import java.awt.Desktop +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.net.InetAddress +import java.net.ServerSocket +import java.net.URI +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermission +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText +import kotlin.io.path.writeText + +internal object V5Loader { + private const val MOD_ID = "ctjs" + private val secretLock = Any() + private var initialized = false + private var sessionToken = "" + + @JvmStatic + @Synchronized + fun init() { + if (initialized) return + + val gameDirPath = resolveGameDirPath() + val sessionFilePath = buildSessionFilePath(gameDirPath) + val minecraftVersion = resolveMinecraftVersion() + + println("[V5] Using Minecraft version for loader: $minecraftVersion") + println("[V5] Authenticating...") + + val authSession = authenticate(sessionFilePath) + if (authSession == null || !validateAuthStatus(authSession.accessToken)) { + throw IllegalStateException("[V5] Authentication failed, expired, or account is banned.") + } + + synchronized(secretLock) { sessionToken = authSession.accessToken } + if (FabricLoader.getInstance().isDevelopmentEnvironment) { + println("[V5] Development environment detected; skipping self-update check.") + initialized = true + return + } + checkSelfUpdate(authSession.accessToken, minecraftVersion, File(gameDirPath)) + initialized = true + } + + @JvmStatic + fun consumeToken(): String { + return synchronized(secretLock) { + val current = sessionToken + sessionToken = "" + current + } + } + + private fun checkSelfUpdate(token: String, minecraftVersion: String, gameDir: File) { + val activeJar = resolveActiveJar() + val hash = V5Crypto.calculateFileSha256(activeJar.absolutePath) + if (hash.isEmpty()) throw IllegalStateException("[V5] Failed to hash active loader jar: ${activeJar.absolutePath}") + + val response = V5Http.fetchLoaderHash(token, hash, minecraftVersion) + val integrity = parseJsonObject(response)?.getStringOrNull("integrity")?.lowercase() + ?: throw IllegalStateException("[V5] Loader integrity check failed.") + + when (integrity) { + "valid" -> println("[V5] V5-Loader integrity verified.") + "outdated" -> stageSelfUpdate(token, minecraftVersion, gameDir, activeJar) + "invalid" -> throw IllegalStateException("[V5] V5-Loader integrity is invalid; refusing to run a modified jar.") + else -> throw IllegalStateException("[V5] Unknown V5-Loader integrity state: $integrity") + } + } + + private fun stageSelfUpdate(token: String, minecraftVersion: String, gameDir: File, activeJar: File): Nothing { + val bytes = V5Http.httpsGetBytes( + V5Http.BACKEND_HOST, + "/api/download/loader?minecraft_version=$minecraftVersion", + token, + ) ?: throw IllegalStateException("[V5] Failed to download updated V5-Loader.jar.") + + try { + ModLoaderUpdater.stageUpdateAndRelaunch(gameDir, bytes, listOf(activeJar)) + println("[V5] V5-Loader update staged. Closing Minecraft now so the helper can swap jars.") + } finally { + bytes.fill(0) + } + + Runtime.getRuntime().halt(0) + throw IllegalStateException("Failed to terminate process after staging V5-Loader update") + } + + private fun resolveActiveJar(): File { + val container = FabricLoader.getInstance().getModContainer(MOD_ID) + .orElseThrow { IllegalStateException("[V5] Could not resolve active $MOD_ID mod container.") } + return container.rootPaths + .mapNotNull(::resolveJarFile) + .firstOrNull() + ?: throw IllegalStateException("[V5] Active $MOD_ID container is not a jar; self-update is disabled in development.") + } + + private fun resolveJarFile(path: Path): File? { + runCatching { path.toAbsolutePath().normalize().toFile() }.getOrNull()?.let { file -> + if (file.isFile && file.extension.equals("jar", ignoreCase = true)) return file + } + + val uri = path.toUri().toString() + if (!uri.startsWith("jar:")) return null + val bang = uri.indexOf('!') + if (bang < 0) return null + return runCatching { File(URI(uri.substring(4, bang))) } + .getOrNull() + ?.takeIf { it.isFile && it.extension.equals("jar", ignoreCase = true) } + } + + private fun authenticate(sessionFilePath: String): AuthSession? { + val storedRefreshToken = readRefreshTokenFromSessionFile(sessionFilePath) + if (storedRefreshToken.isNotEmpty()) { + exchangeRefreshToken(storedRefreshToken, sessionFilePath)?.let { return it } + } + return runBrowserLogin(sessionFilePath) + } + + private fun runBrowserLogin(sessionFilePath: String): AuthSession? { + val serverSocket = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")) + serverSocket.soTimeout = 240_000 + + try { + val port = serverSocket.localPort + val expectedLocalNonce = V5Crypto.generateUrlSafeNonce(16) + val authUrl = "https://${V5Http.BACKEND_HOST}/api/auth/discord/login?state=port:$port:$expectedLocalNonce" + println("[V5] Opening browser for authentication: $authUrl") + openBrowser(authUrl) + + serverSocket.accept().use { clientSocket -> + if (!clientSocket.inetAddress.isLoopbackAddress) return null + val requestLine = BufferedReader(InputStreamReader(clientSocket.getInputStream())).readLine() ?: return null + val error = extractQueryParam(requestLine, "error=") + val callbackNonce = extractQueryParam(requestLine, "nonce=") + if (callbackNonce != expectedLocalNonce) return null + if (error.isNotEmpty()) { + writeHttpResponse(clientSocket, "HTTP/1.1 403 Forbidden", "

Access Denied

$error

") + return null + } + + writeHttpResponse( + clientSocket, + "HTTP/1.1 200 OK", + "

Authenticated!

You can close this tab and return to the game.

", + ) + + val callbackRefreshToken = extractQueryParam(requestLine, "refresh_token=") + if (callbackRefreshToken.isEmpty()) return null + if (!writeRefreshTokenToSessionFile(sessionFilePath, callbackRefreshToken)) { + System.err.println("[V5] Warning: failed to persist browser refresh token.") + } + return exchangeRefreshToken(callbackRefreshToken, sessionFilePath) + } + } catch (e: Exception) { + System.err.println("[V5] Authentication callback failed: ${e.message ?: e.javaClass.simpleName}") + return null + } finally { + serverSocket.close() + } + } + + private fun exchangeRefreshToken(refreshToken: String, sessionFilePath: String): AuthSession? { + val response = V5Http.httpsPost( + V5Http.BACKEND_HOST, + "/api/auth/refresh", + """{"refresh_token":"${jsonEscape(refreshToken)}"}""", + ) + if (response.isEmpty()) return null + val json = parseJsonObject(response) ?: return null + val accessToken = json.getStringOrNull("access_token").orEmpty() + val rotatedRefresh = json.getStringOrNull("refresh_token").orEmpty() + val error = json.getStringOrNull("error").orEmpty() + if (accessToken.isEmpty() || rotatedRefresh.isEmpty()) { + if (error in REVOKED_REFRESH_ERRORS) secureDeleteFile(sessionFilePath) + return null + } + writeRefreshTokenToSessionFile(sessionFilePath, rotatedRefresh) + return AuthSession(accessToken) + } + + private fun validateAuthStatus(token: String): Boolean { + val authStatus = V5Http.httpsGet(V5Http.BACKEND_HOST, "/api/authstatus", token) + if (authStatus.isEmpty()) return false + val authJson = parseJsonObject(authStatus) ?: return false + val statusPayload = authJson.getAsJsonObjectOrNull("status") ?: authJson + return !(statusPayload.getBoolOrNull("isBanned") ?: authJson.getBoolOrNull("isBanned") ?: false) + } + + private fun resolveMinecraftVersion(): String = System.getProperty("v5.minecraft_version").orEmpty().trim() + + private fun resolveGameDirPath(): String { + System.getProperty("v5.game_dir").orEmpty().trim().ifEmpty { + System.getProperty("user.dir").orEmpty().trim() + }.ifEmpty { + val os = System.getProperty("os.name", "").lowercase() + if (os.contains("win")) System.getenv("APPDATA").orEmpty().let { if (it.isBlank()) "" else Path.of(it, ".minecraft").toString() } + else System.getenv("HOME").orEmpty().let { if (it.isBlank()) "" else Path.of(it, ".minecraft").toString() } + }.let { return it } + } + + private fun buildSessionFilePath(gameDir: String): String = + if (gameDir.isEmpty()) "" else Path.of(gameDir, ".v5", "session.json").toString() + + private fun readRefreshTokenFromSessionFile(sessionFilePath: String): String { + if (sessionFilePath.isEmpty()) return "" + val path = Path.of(sessionFilePath) + if (!path.isRegularFile()) return "" + return parseJsonObject(path.readText())?.getStringOrNull("refresh_token").orEmpty().trim() + } + + private fun writeRefreshTokenToSessionFile(sessionFilePath: String, refreshToken: String): Boolean { + if (sessionFilePath.isEmpty() || refreshToken.isEmpty()) return false + val filePath = Path.of(sessionFilePath) + runCatching { Files.createDirectories(filePath.parent) } + val content = """{"refresh_token":"${jsonEscape(refreshToken)}","updated_at":${System.currentTimeMillis() / 1000}}""" + return try { + filePath.writeText(content) + if (!System.getProperty("os.name", "").lowercase().contains("win")) { + Files.setPosixFilePermissions(filePath, setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)) + } + true + } catch (_: Exception) { + false + } + } + + private fun secureDeleteFile(path: String) { + if (path.isNotEmpty()) runCatching { Files.deleteIfExists(Path.of(path)) } + } + + private fun openBrowser(url: String) { + runCatching { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI(url)) + return + } + } + val os = System.getProperty("os.name", "").lowercase() + val command = when { + os.contains("win") -> listOf("cmd", "/c", "start", "", url) + os.contains("mac") -> listOf("open", url) + else -> listOf("xdg-open", url) + } + ProcessBuilder(command).start() + } + + private fun writeHttpResponse(clientSocket: java.net.Socket, statusLine: String, body: String) { + clientSocket.getOutputStream().write("$statusLine\r\nContent-Type: text/html\r\n\r\n$body".toByteArray(StandardCharsets.UTF_8)) + } + + private fun extractQueryParam(requestLine: String, key: String): String { + val start = requestLine.indexOf(key) + if (start < 0) return "" + val valueStart = start + key.length + val end = listOf(requestLine.indexOf('&', valueStart), requestLine.indexOf(' ', valueStart)) + .filter { it >= 0 } + .minOrNull() ?: requestLine.length + return java.net.URLDecoder.decode(requestLine.substring(valueStart, end), StandardCharsets.UTF_8) + } + + private fun jsonEscape(input: String): String = buildString(input.length + 8) { + input.forEach { ch -> + when (ch) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> if (ch.code < 0x20) append(String.format("\\u%04x", ch.code)) else append(ch) + } + } + } + + private fun parseJsonObject(json: String): JsonObject? = runCatching { JsonParser.parseString(json).asJsonObject }.getOrNull() + + private fun JsonObject.getStringOrNull(key: String): String? = + get(key)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString + + private fun JsonObject.getBoolOrNull(key: String): Boolean? = + get(key)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean }?.asBoolean + + private fun JsonObject.getAsJsonObjectOrNull(key: String): JsonObject? = + get(key)?.takeIf { it.isJsonObject }?.asJsonObject + + private data class AuthSession(val accessToken: String) + + private val REVOKED_REFRESH_ERRORS = setOf( + "INVALID_REFRESH_TOKEN", + "REFRESH_TOKEN_EXPIRED", + "REFRESH_TOKEN_REUSED", + "SESSION_REVOKED", + ) +} diff --git a/src/main/resources/assets/v5/natives/windows/x86_64/V5PathJNI.dll b/src/main/resources/assets/v5/natives/windows/x86_64/V5PathJNI.dll index 89747a4c424d4111743a266f6b735b7c703b930f..88ea6459d8d7e2ccbac50d3f6d84c5a0a8014d5e 100644 GIT binary patch delta 36 pcmZo@;cjT*-XOrp%+TZ4EX>$0%*eQ1n2|})9VD<_-Gk|7B>=4S32XoW delta 36 pcmZo@;cjT*-XOrp{6X8NS(ve1n2~Y2Fe8(oJ4j%=x(CzEN&vwH3Wfjx diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 622ae61..353b1d8 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -16,6 +16,10 @@ "environment": "client", "entrypoints": { "preLaunch": [ + { + "value": "com.v5.loader.V5PreLaunch", + "adapter": "kotlin" + }, "com.chattriggers.ctjs.internal.launch.CTJSPreLaunch" ], "client": [ From d38b41e3dcaba6cf9d200b719cd3da8296e22fe8 Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:31:17 +0100 Subject: [PATCH 2/3] Add old loader migraiton --- README.md | 2 +- .../ctjs/internal/launch/ModLoaderUpdater.kt | 11 ++++++++++ .../kotlin/com/v5/loader/internal/V5Loader.kt | 20 +++++++++++++++---- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 02335b1..92d68eb 100644 --- a/README.md +++ b/README.md @@ -76,4 +76,4 @@ cmake -S NativeSrc -B NativeSrc/build -DCMAKE_BUILD_TYPE=Release; cmake --build ### Install built mod -After building, copy `build/libs/V5-Loader.jar` into `.minecraft/mods/` together with [V5ModLoader.jar](https://rdbt.top/docs/getting-started). V5ModLoader handles authentication and passes the JWT to the loader. +After building, copy `build/libs/V5-Loader.jar` into `.minecraft/mods/`. diff --git a/src/main/kotlin/com/chattriggers/ctjs/internal/launch/ModLoaderUpdater.kt b/src/main/kotlin/com/chattriggers/ctjs/internal/launch/ModLoaderUpdater.kt index 088417e..97d9e3f 100644 --- a/src/main/kotlin/com/chattriggers/ctjs/internal/launch/ModLoaderUpdater.kt +++ b/src/main/kotlin/com/chattriggers/ctjs/internal/launch/ModLoaderUpdater.kt @@ -97,6 +97,17 @@ internal object ModLoaderUpdater { updatePaths.staleTargets.forEach { target -> lines += " rm -f ${shellQuote(target.absolutePath)} >/dev/null 2>&1 || true" } + lines += " rm -f ${shellQuote(updatePaths.backupJar.absolutePath)}" + lines += " rm -f \"\$0\"" + lines += " exit 0" + lines += " fi" + lines += " if [ -f ${shellQuote(updatePaths.backupJar.absolutePath)} ]; then" + lines += " mv -f ${shellQuote(updatePaths.backupJar.absolutePath)} ${shellQuote(updatePaths.targetJar.absolutePath)} >/dev/null 2>&1 || true" + lines += " fi" + lines += " ATTEMPT=\$((ATTEMPT + 1))" + lines += " sleep 1" + lines += "done" + lines += "exit 1" script.writeLines(lines) return script } diff --git a/src/main/kotlin/com/v5/loader/internal/V5Loader.kt b/src/main/kotlin/com/v5/loader/internal/V5Loader.kt index cec3ee0..6327f09 100644 --- a/src/main/kotlin/com/v5/loader/internal/V5Loader.kt +++ b/src/main/kotlin/com/v5/loader/internal/V5Loader.kt @@ -21,6 +21,7 @@ import kotlin.io.path.writeText internal object V5Loader { private const val MOD_ID = "ctjs" + private const val LEGACY_MOD_LOADER_FILE_NAME = "V5ModLoader.jar" private val secretLock = Any() private var initialized = false private var sessionToken = "" @@ -63,6 +64,7 @@ internal object V5Loader { private fun checkSelfUpdate(token: String, minecraftVersion: String, gameDir: File) { val activeJar = resolveActiveJar() + val legacyModLoader = File(gameDir, "mods/$LEGACY_MOD_LOADER_FILE_NAME").takeIf(File::isFile) val hash = V5Crypto.calculateFileSha256(activeJar.absolutePath) if (hash.isEmpty()) throw IllegalStateException("[V5] Failed to hash active loader jar: ${activeJar.absolutePath}") @@ -71,14 +73,24 @@ internal object V5Loader { ?: throw IllegalStateException("[V5] Loader integrity check failed.") when (integrity) { - "valid" -> println("[V5] V5-Loader integrity verified.") - "outdated" -> stageSelfUpdate(token, minecraftVersion, gameDir, activeJar) + "valid" -> if (legacyModLoader == null) { + println("[V5] V5-Loader integrity verified.") + } else { + stageSelfUpdate(token, minecraftVersion, gameDir, activeJar, legacyModLoader) + } + "outdated" -> stageSelfUpdate(token, minecraftVersion, gameDir, activeJar, legacyModLoader) "invalid" -> throw IllegalStateException("[V5] V5-Loader integrity is invalid; refusing to run a modified jar.") else -> throw IllegalStateException("[V5] Unknown V5-Loader integrity state: $integrity") } } - private fun stageSelfUpdate(token: String, minecraftVersion: String, gameDir: File, activeJar: File): Nothing { + private fun stageSelfUpdate( + token: String, + minecraftVersion: String, + gameDir: File, + activeJar: File, + legacyModLoader: File? + ): Nothing { val bytes = V5Http.httpsGetBytes( V5Http.BACKEND_HOST, "/api/download/loader?minecraft_version=$minecraftVersion", @@ -86,7 +98,7 @@ internal object V5Loader { ) ?: throw IllegalStateException("[V5] Failed to download updated V5-Loader.jar.") try { - ModLoaderUpdater.stageUpdateAndRelaunch(gameDir, bytes, listOf(activeJar)) + ModLoaderUpdater.stageUpdateAndRelaunch(gameDir, bytes, listOfNotNull(activeJar, legacyModLoader)) println("[V5] V5-Loader update staged. Closing Minecraft now so the helper can swap jars.") } finally { bytes.fill(0) From 39086fdb05cd873b7e10122163bceb1d284c448b Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:38:03 +0100 Subject: [PATCH 3/3] fix windows cleanup --- .../com/chattriggers/ctjs/internal/launch/ModLoaderUpdater.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/kotlin/com/chattriggers/ctjs/internal/launch/ModLoaderUpdater.kt b/src/main/kotlin/com/chattriggers/ctjs/internal/launch/ModLoaderUpdater.kt index 97d9e3f..29f3b59 100644 --- a/src/main/kotlin/com/chattriggers/ctjs/internal/launch/ModLoaderUpdater.kt +++ b/src/main/kotlin/com/chattriggers/ctjs/internal/launch/ModLoaderUpdater.kt @@ -143,6 +143,10 @@ internal object ModLoaderUpdater { updatePaths.staleTargets.forEach { target -> lines += "del /f /q ${cmdQuote(target.absolutePath)} >nul 2>nul" } + lines += "del /f /q ${cmdQuote(updatePaths.backupJar.absolutePath)} >nul 2>nul" + lines += "powershell -NoProfile -Command ${cmdQuote(buildPowerShellPopupCommand())}" + lines += "del /f /q \"%~f0\" >nul 2>nul" + lines += "exit /b 0" script.writeLines(lines, "\r\n") return script }