diff --git a/src/main/java/com/lambda/mixin/render/ScreenMixin.java b/src/main/java/com/lambda/mixin/render/ScreenMixin.java index 6ed4f2c53..083d0be56 100644 --- a/src/main/java/com/lambda/mixin/render/ScreenMixin.java +++ b/src/main/java/com/lambda/mixin/render/ScreenMixin.java @@ -18,6 +18,7 @@ package com.lambda.mixin.render; import com.lambda.gui.components.QuickSearch; +import com.lambda.module.modules.client.AutoUpdater; import com.lambda.module.modules.render.ContainerPreview; import com.lambda.module.modules.render.NoRender; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; @@ -37,9 +38,19 @@ public class ScreenMixin { @Inject(method = "keyPressed", at = @At("HEAD"), cancellable = true) private void onKeyPressed(KeyInput input, CallbackInfoReturnable cir) { - if (input.key() == GLFW.GLFW_KEY_ESCAPE && QuickSearch.INSTANCE.isOpen()) { - QuickSearch.INSTANCE.close(); - cir.setReturnValue(true); + if (input.key() == GLFW.GLFW_KEY_ESCAPE) { + if (QuickSearch.INSTANCE.isOpen()) { + QuickSearch.INSTANCE.close(); + cir.setReturnValue(true); + } else if (AutoUpdater.getShowInstallModal()) { + AutoUpdater.INSTANCE.disable(); + AutoUpdater.setShowInstallModal(false); + cir.setReturnValue(true); + } else if (AutoUpdater.getShowUninstallModal()) { + AutoUpdater.INSTANCE.enable(); + AutoUpdater.setShowUninstallModal(false); + cir.setReturnValue(true); + } } } diff --git a/src/main/kotlin/com/lambda/gui/components/QuickSearch.kt b/src/main/kotlin/com/lambda/gui/components/QuickSearch.kt index a92fb7ca2..51fe17ae2 100644 --- a/src/main/kotlin/com/lambda/gui/components/QuickSearch.kt +++ b/src/main/kotlin/com/lambda/gui/components/QuickSearch.kt @@ -31,6 +31,7 @@ import com.lambda.gui.dsl.ImGuiBuilder import com.lambda.module.HudModule import com.lambda.module.Module import com.lambda.module.ModuleRegistry +import com.lambda.module.modules.client.AutoUpdater import com.lambda.util.KeyCode import com.lambda.util.StringUtils.capitalize import com.lambda.util.StringUtils.levenshteinDistance @@ -54,12 +55,13 @@ object QuickSearch { private const val DOUBLE_SHIFT_WINDOW_MS = 500L private const val MAX_RESULTS = 50 - private const val WINDOW_FLAGS = ImGuiWindowFlags.AlwaysAutoResize or - ImGuiWindowFlags.NoTitleBar or - ImGuiWindowFlags.NoMove or - ImGuiWindowFlags.NoResize or - ImGuiWindowFlags.NoScrollbar or - ImGuiWindowFlags.NoScrollWithMouse + const val WINDOW_FLAGS = + ImGuiWindowFlags.AlwaysAutoResize or + ImGuiWindowFlags.NoTitleBar or + ImGuiWindowFlags.NoMove or + ImGuiWindowFlags.NoResize or + ImGuiWindowFlags.NoScrollbar or + ImGuiWindowFlags.NoScrollWithMouse init { listenUnsafe { event -> @@ -303,6 +305,7 @@ object QuickSearch { } private fun handleKeyPress(event: ButtonEvent.Keyboard.Press) { + if (AutoUpdater.showInstallModal || AutoUpdater.showUninstallModal) return if ((!event.isPressed || event.isRepeated) || !(event.keyCode == KeyCode.LeftShift.code || event.keyCode == KeyCode.RightShift.code)) return diff --git a/src/main/kotlin/com/lambda/gui/components/SettingsWidget.kt b/src/main/kotlin/com/lambda/gui/components/SettingsWidget.kt index 8f13deca4..aa1faa386 100644 --- a/src/main/kotlin/com/lambda/gui/components/SettingsWidget.kt +++ b/src/main/kotlin/com/lambda/gui/components/SettingsWidget.kt @@ -26,6 +26,7 @@ import com.lambda.config.configurations.UserAutomationConfigs import com.lambda.gui.dsl.ImGuiBuilder import com.lambda.module.HudModule import com.lambda.module.Module +import com.lambda.module.modules.client.AutoUpdater import com.lambda.util.NamedEnum import imgui.ImGui import imgui.flag.ImGuiPopupFlags @@ -37,7 +38,7 @@ object SettingsWidget { */ fun ImGuiBuilder.buildConfigSettingsContext(config: Configurable) { group { - if (config is Module) { + if (config is Module && config !== AutoUpdater) { with(config.keybindSetting) { buildLayout() } with(config.disableOnReleaseSetting) { buildLayout() } with(config.drawSetting) { buildLayout() } diff --git a/src/main/kotlin/com/lambda/module/modules/client/AutoUpdater.kt b/src/main/kotlin/com/lambda/module/modules/client/AutoUpdater.kt new file mode 100644 index 000000000..10c518cc7 --- /dev/null +++ b/src/main/kotlin/com/lambda/module/modules/client/AutoUpdater.kt @@ -0,0 +1,359 @@ +/* + * Copyright 2026 Lambda + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.lambda.module.modules.client + +import com.lambda.Lambda.mc +import com.lambda.event.events.GuiEvent +import com.lambda.event.listener.SafeListener.Companion.listen +import com.lambda.gui.LambdaScreen +import com.lambda.gui.components.QuickSearch.WINDOW_FLAGS +import com.lambda.gui.dsl.ImGuiBuilder.popupModal +import com.lambda.module.Module +import com.lambda.module.tag.ModuleTag +import com.lambda.util.Communication.debug +import com.lambda.util.Communication.logError +import com.lambda.util.Communication.warn +import imgui.ImGui +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import net.fabricmc.loader.api.FabricLoader +import net.minecraft.SharedConstants +import java.net.URI +import java.nio.file.Path +import javax.xml.parsers.DocumentBuilderFactory + +object AutoUpdater : Module( + name = "AutoUpdater", + description = "Installs / uninstalls Lambda loader", + tag = ModuleTag.CLIENT, +) { + private val debug by setting("Debug", false, "Enable debug logging") + private val loaderBranch by setting("Loader Branch", Branch.Stable, "Select loader update branch") + private val clientBranch by setting("Client Branch", Branch.Snapshot, "Select client update branch") + + @JvmStatic var showInstallModal = false + @JvmStatic var showUninstallModal = false + + private const val MAVEN_URL = "https://maven.lambda-client.org" + private const val LOADER_RELEASES_META = "$MAVEN_URL/releases/com/lambda/lambda-loader/maven-metadata.xml" + private const val LOADER_SNAPSHOTS_META = "$MAVEN_URL/snapshots/com/lambda/lambda-loader/maven-metadata.xml" + private const val CLIENT_RELEASES_META = "$MAVEN_URL/releases/com/lambda/lambda/maven-metadata.xml" + private const val CLIENT_SNAPSHOTS_META = "$MAVEN_URL/snapshots/com/lambda/lambda/maven-metadata.xml" + + private enum class Branch { + Stable, + Snapshot + } + + init { + onEnable { + if (mc.currentScreen is LambdaScreen && !showUninstallModal) + showInstallModal = true + showUninstallModal = false + } + + onDisable { + if (mc.currentScreen is LambdaScreen && !showInstallModal) + showUninstallModal = true + showInstallModal = false + } + + listen(alwaysListen = true) { + if (showInstallModal) { + ImGui.openPopup("Install Loader") + popupModal("Install Loader", WINDOW_FLAGS) { + text("Do you want to install Lambda Client?") + spacing() + text("This will close the client automatically once the install is finished.") + spacing() + + button("Install", 120f, 0f) { + installLoader() + showInstallModal = false + } + + sameLine() + + button("Cancel", 120f, 0f) { + disable() + } + } + return@listen + } + + showInstallModal = false + + if (showUninstallModal) { + ImGui.openPopup("Uninstall Loader") + popupModal("Uninstall Loader", WINDOW_FLAGS) { + text("Do you want to uninstall Lambda Loader?") + spacing() + text("This will close the client automatically once the uninstall is finished.") + spacing() + + button("Uninstall", 120f, 0f) { + installClient() + showUninstallModal = false + } + + sameLine() + + button("Cancel", 120f, 0f) { + enable() + } + } + return@listen + } + + showUninstallModal = false + } + } + + private fun installLoader() { + runBlocking(Dispatchers.IO) { + try { + debug("Starting Lambda loader install...") + + val loaderJar = downloadLatestLoader() + if (loaderJar == null) { + logError("Failed to download latest Lambda loader") + return@runBlocking + } + + val clientJarPath = getModJarPath("lambda") + if (debug) debug("Lambda client JAR path: $clientJarPath") + + val jarFile = clientJarPath.toFile() + jarFile.writeBytes(loaderJar) + + debug("Successfully installed Lambda loader! Restarting...") + + mc.stop() + } catch (e: Exception) { + disable() + logError("Error installing Lambda loader", e) + } + } + } + + private fun installClient() { + runBlocking(Dispatchers.IO) { + try { + debug("Starting Lambda client install...") + + val clientJar = downloadLatestClient() + if (clientJar == null) { + logError("Failed to download latest Lambda client") + return@runBlocking + } + + val loaderJarPath = getModJarPath("lambda-loader") + if (debug) debug("Lambda loader JAR path: $loaderJarPath") + + val jarFile = loaderJarPath.toFile() + jarFile.writeBytes(clientJar) + + debug("Successfully installed Lambda client! Restarting...") + + mc.stop() + } catch (e: Exception) { + enable() + logError("Error installing Lambda client", e) + } + } + } + + fun downloadLatestLoader(): ByteArray? { + return try { + val branch = loaderBranch + val mcVersion = getMinecraftVersion() + + if (debug) debug("Downloading loader for MC $mcVersion from ${branch.name} branch") + + var version: String? + var baseUrl: String? + + when (branch) { + Branch.Stable -> { + val xml = URI(LOADER_RELEASES_META).toURL().readText() + version = parseLatestVersion(xml, null) + baseUrl = "$MAVEN_URL/releases" + } + Branch.Snapshot -> { + val xml = URI(LOADER_SNAPSHOTS_META).toURL().readText() + version = parseLatestVersion(xml, null) + baseUrl = "$MAVEN_URL/snapshots" + } + } + + if (version == null && branch == Branch.Stable) { + warn("No stable loader found, falling back to snapshot") + val xml = URI(LOADER_SNAPSHOTS_META).toURL().readText() + version = parseLatestVersion(xml, null) + baseUrl = "$MAVEN_URL/snapshots" + } + + if (version == null) { + logError("No loader version found") + return null + } + + val jarUrl = if (version.endsWith("-SNAPSHOT")) { + val snapshotInfo = getSnapshotInfo(baseUrl, "com/lambda/lambda-loader", version) ?: return null + val baseVersion = version.replace("-SNAPSHOT", "") + "$baseUrl/com/lambda/lambda-loader/$version/lambda-loader-$baseVersion-${snapshotInfo.timestamp}-${snapshotInfo.buildNumber}.jar" + } else { + "$baseUrl/com/lambda/lambda-loader/$version/lambda-loader-$version.jar" + } + + if (debug) debug("Downloading from: $jarUrl") + + URI(jarUrl).toURL().readBytes() + } catch (e: Exception) { + logError("Failed to download loader", e) + null + } + } + + fun downloadLatestClient(): ByteArray? { + return try { + val branch = clientBranch + val mcVersion = getMinecraftVersion() + + if (debug) debug("Downloading client for MC $mcVersion from ${branch.name} branch") + + var version: String? + var baseUrl: String? + + when (branch) { + Branch.Stable -> { + val xml = URI(CLIENT_RELEASES_META).toURL().readText() + version = parseLatestVersion(xml, mcVersion) + baseUrl = "$MAVEN_URL/releases" + } + Branch.Snapshot -> { + val xml = URI(CLIENT_SNAPSHOTS_META).toURL().readText() + version = parseLatestVersion(xml, mcVersion) + baseUrl = "$MAVEN_URL/snapshots" + } + } + + if (version == null && branch == Branch.Stable) { + warn("No stable client found for MC $mcVersion, falling back to snapshot") + val xml = URI(CLIENT_SNAPSHOTS_META).toURL().readText() + version = parseLatestVersion(xml, mcVersion) + baseUrl = "$MAVEN_URL/snapshots" + } + + if (version == null) { + logError("No client version found for MC $mcVersion") + return null + } + + val jarUrl = if (version.endsWith("-SNAPSHOT")) { + val snapshotInfo = getSnapshotInfo(baseUrl, "com/lambda/lambda", version) ?: return null + val baseVersion = version.replace("-SNAPSHOT", "") + "$baseUrl/com/lambda/lambda/$version/lambda-$baseVersion-${snapshotInfo.timestamp}-${snapshotInfo.buildNumber}.jar" + } else "$baseUrl/com/lambda/lambda/$version/lambda-$version.jar" + + if (debug) debug("Downloading from: $jarUrl") + + URI(jarUrl).toURL().readBytes() + } catch (e: Exception) { + logError("Failed to download client", e) + null + } + } + + private fun parseLatestVersion(xml: String, mcVersion: String? = null): String? { + return try { + val factory = DocumentBuilderFactory.newInstance() + val builder = factory.newDocumentBuilder() + val document = builder.parse(xml.byteInputStream()) + + val versionNodes = document.getElementsByTagName("version") + val versions = mutableListOf() + + (0 until versionNodes.length).forEach { i -> + versions.add(versionNodes.item(i).textContent) + } + + if (debug) { + if (mcVersion != null) { + debug("Target MC version: $mcVersion") + } + debug("Available versions: ${versions.joinToString(", ")}") + } + + val matchingVersions = if (mcVersion != null) { + versions.filter { version -> + val mcVersionInArtifact = version.substringAfter("+").substringBefore("-") + val normalizedArtifact = mcVersionInArtifact.replace(".", "") + val normalizedTarget = mcVersion.replace(".", "") + normalizedArtifact == normalizedTarget + } + } else { + versions + } + + if (matchingVersions.isEmpty()) { + if (debug) { + val versionMsg = mcVersion?.let { "for MC $it" } ?: "" + warn("No versions found $versionMsg") + } + null + } else matchingVersions.last() + } catch (e: Exception) { + logError("Error parsing version", e) + null + } + } + + private fun getSnapshotInfo(baseUrl: String, artifactPath: String, version: String): SnapshotInfo? { + return try { + val snapshotMetaUrl = URI("$baseUrl/$artifactPath/$version/maven-metadata.xml").toURL() + val xml = snapshotMetaUrl.readText() + + val factory = DocumentBuilderFactory.newInstance() + val builder = factory.newDocumentBuilder() + val document = builder.parse(xml.byteInputStream()) + + val timestamp = document.getElementsByTagName("timestamp").item(0).textContent + val buildNumber = document.getElementsByTagName("buildNumber").item(0).textContent + + SnapshotInfo(version, timestamp, buildNumber) + } catch (e: Exception) { + logError("Error getting snapshot info", e) + null + } + } + + private fun getMinecraftVersion() = SharedConstants.getGameVersion().name() + + private fun getModJarPath(modId: String): Path { + val fabricLoader = FabricLoader.getInstance() + val modContainer = fabricLoader.getModContainer(modId) + return modContainer.get().origin.paths[0].toAbsolutePath() + } + + private data class SnapshotInfo( + val version: String, + val timestamp: String, + val buildNumber: String + ) +} \ No newline at end of file