diff --git a/installer/src/main/kotlin/gg/essential/installer/download/util/CompleteURL.kt b/installer/src/main/kotlin/gg/essential/installer/download/util/CompleteURL.kt index 8b2ea08..a862c00 100644 --- a/installer/src/main/kotlin/gg/essential/installer/download/util/CompleteURL.kt +++ b/installer/src/main/kotlin/gg/essential/installer/download/util/CompleteURL.kt @@ -15,9 +15,12 @@ package gg.essential.installer.download.util +import kotlinx.serialization.Serializable + /** * A endpoint which consists of just a full URL and fallback URLs. */ +@Serializable data class CompleteURL( override val primaryURL: String, override val fallbackURLs: List = emptyList() diff --git a/installer/src/main/kotlin/gg/essential/installer/download/util/Endpoint.kt b/installer/src/main/kotlin/gg/essential/installer/download/util/Endpoint.kt index db270d2..c97b601 100644 --- a/installer/src/main/kotlin/gg/essential/installer/download/util/Endpoint.kt +++ b/installer/src/main/kotlin/gg/essential/installer/download/util/Endpoint.kt @@ -15,10 +15,13 @@ package gg.essential.installer.download.util +import kotlinx.serialization.Serializable + /** * An interface representing an Endpoint, which consists of a primary URL and fallback URLs */ -interface Endpoint { +@Serializable +sealed interface Endpoint { val primaryURL: String val fallbackURLs: List diff --git a/installer/src/main/kotlin/gg/essential/installer/gui/page/DebugPage.kt b/installer/src/main/kotlin/gg/essential/installer/gui/page/DebugPage.kt index eec0832..77f9233 100644 --- a/installer/src/main/kotlin/gg/essential/installer/gui/page/DebugPage.kt +++ b/installer/src/main/kotlin/gg/essential/installer/gui/page/DebugPage.kt @@ -21,20 +21,31 @@ import gg.essential.elementa.constraints.* import gg.essential.elementa.dsl.* import gg.essential.elementa.effects.ScissorEffect import gg.essential.elementa.layoutdsl.* +import gg.essential.elementa.state.v2.combinators.map +import gg.essential.elementa.state.v2.memo import gg.essential.elementa.state.v2.mutableStateOf +import gg.essential.elementa.state.v2.stateOf import gg.essential.elementa.state.v2.toV1 import gg.essential.elementa.util.onAnimationFrame import gg.essential.installer.exitInstaller import gg.essential.installer.gui.* import gg.essential.installer.gui.component.* +import gg.essential.installer.install.InstallStep +import gg.essential.installer.install.InstallSteps +import gg.essential.installer.install.start import gg.essential.installer.launchInMainCoroutineScope +import gg.essential.installer.launcher.InstallInfo +import gg.essential.installer.launcher.Launcher +import gg.essential.installer.launcher.Launchers import gg.essential.installer.logging.Logging.logger +import gg.essential.installer.mod.ModManager import gg.essential.installer.platform.Platform import gg.essential.universal.UDesktop import gg.essential.universal.UScreen import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.awt.Color +import gg.essential.installer.launcher.Installation as LInstallation /** * Debug page, of random things I need(ed) during testing @@ -58,6 +69,9 @@ object DebugPage : InstallerPage() { textButton("No Launchers page", ButtonStyle.GRAY, Modifier.width(200f).height(48f)) { PageHandler.navigateTo(NoLauncherFoundPage) } + textButton("Install Everything", ButtonStyle.GRAY, Modifier.width(200f).height(48f)) { + PageHandler.navigateTo(InstallEverything) + } textButton("Restart", ButtonStyle.GRAY, Modifier.width(200f).height(48f)) { exitInstaller(true) } @@ -82,4 +96,81 @@ object DebugPage : InstallerPage() { scroller.setVerticalScrollBarComponent(scrollbar) } + object InstallEverything : InstallerPage() { + + private val map = memo { + ModManager.getAvailableMCVersions()().associateWith { ModManager.getAvailableModloaders(stateOf(it))() } + } + private val count = map.map { m -> m.entries.sumOf { it.value.size } } + + private val installation = mutableStateOf?>(null) + + override fun LayoutScope.layoutPage() { + + titleAndBody( + "Install everything", + """ + This page allows you to install every single supported version & modloader in bulk. + This will create ${count.getUntracked()} profiles/installs! + Select a modloader on the right to install all possible profiles to it. + """, + modifier = Modifier.alignTopLeft() + ) + column(Modifier.width(320f).alignTopRight(), Arrangement.spacedBy(8f, FloatPosition.START)) { + forEach(Launchers.launchers) { launcher -> + launcherButton(launcher) + } + } + val desc = memo { + val inst = installation() ?: return@memo "Not installing..." + val currentStep = inst.currentStep() + val stepsCompleted = inst.stepsCompleted() + val numberOfSteps = inst.numberOfSteps() + "Installing: ${currentStep.id} ($stepsCompleted/$numberOfSteps)" + } + box(Modifier.alignBottomRight()) { + installerText(desc) + } + } + + private fun > LayoutScope.launcherButton(launcher: Launcher) { + button(stateOf(ButtonStyle.GRAY), disabled = installation.map { it != null }, modifier = Modifier.fillWidth().height(96f)) { + row(Modifier.fillWidth(padding = 24f), Arrangement.spacedBy(24f, FloatPosition.START)) { + box(Modifier.width(64f).heightAspect(1f)) { + image(launcher.type.icon, Modifier.fillParent().color(Color.WHITE)) + } + installerBoldText(launcher.type.displayName, Modifier.color(InstallerPalette.TEXT)) + } + }.onLeftClick { + if (installation.getUntracked() != null) return@onLeftClick + val installationInfos = map.getUntracked().flatMap { (mcVersion, modloaders) -> + modloaders.mapNotNull { modloader -> + val installInfo = launcher.getNewInstallInfo( + "Debug - $mcVersion ${modloader.type.displayName}", + ModManager.getBestModVersion(mcVersion, modloader.type).getUntracked() ?: return@mapNotNull null, + mcVersion, + modloader, + modloader.getBestModloaderVersion(mcVersion).getUntracked() ?: return@mapNotNull null + ) + InstallSteps.merge( + modloader.getInstallSteps(installInfo), + launcher.getNewInstallationInstallSteps(installInfo), + ) + } + } + val inst = InstallSteps.merge(*installationInfos.toTypedArray()).convertToSingleInstallStep() + + installation.set(inst) + + launchInMainCoroutineScope { + logger.info("Starting Install") + inst.start() + installation.set(null) + } + } + } + + + } + } diff --git a/installer/src/main/kotlin/gg/essential/installer/launcher/curseforge/CurseForge.kt b/installer/src/main/kotlin/gg/essential/installer/launcher/curseforge/CurseForge.kt index 3cad0df..3ca6e85 100644 --- a/installer/src/main/kotlin/gg/essential/installer/launcher/curseforge/CurseForge.kt +++ b/installer/src/main/kotlin/gg/essential/installer/launcher/curseforge/CurseForge.kt @@ -219,9 +219,11 @@ class CurseForge( * The real installer modifies this JSON a bit (removes a few fields), but it shouldn't be a problem if we just include the entire one. */ private suspend fun getModloaderJson(installInfo: InstallInfo): JsonObject { - var id = "${installInfo.modloader.type.name.lowercase()}-${installInfo.modloaderVersion.numeric}" - if (installInfo.modloader.type == ModloaderType.FABRIC || installInfo.modloader.type == ModloaderType.QUILT) { - id += "-${installInfo.mcVersion}" + val modloaderType = installInfo.modloader.type + val id = "${modloaderType.name.lowercase()}-" + when (modloaderType) { + ModloaderType.FABRIC, ModloaderType.QUILT -> "${installInfo.modloaderVersion.numeric}-${installInfo.mcVersion}" + ModloaderType.NEOFORGE -> installInfo.modloaderVersion.full + else -> installInfo.modloaderVersion.numeric } logger.info("Fetching $id from curseforge.") val url = MetadataManager.installer.urls.curseforgeModloaderInfo.replace("{id}", id) diff --git a/installer/src/main/kotlin/gg/essential/installer/launcher/curseforge/CurseForgeInstance.kt b/installer/src/main/kotlin/gg/essential/installer/launcher/curseforge/CurseForgeInstance.kt index 218b6bd..0a1bceb 100644 --- a/installer/src/main/kotlin/gg/essential/installer/launcher/curseforge/CurseForgeInstance.kt +++ b/installer/src/main/kotlin/gg/essential/installer/launcher/curseforge/CurseForgeInstance.kt @@ -27,7 +27,7 @@ import java.util.* @Serializable data class CurseForgeInstance( - val baseModLoader: Modloader, + val baseModLoader: Modloader?, val lastPlayed: Instant = Instant.MIN, // val isVanilla: Boolean, // Removed, since we don't need it, and some instances seem to miss it? val guid: String = UUID.randomUUID().toString(), @@ -44,6 +44,6 @@ data class CurseForgeInstance( ) val modloaderInfo: ModloaderInfo - get() = ModloaderInfo.fromVersionString(baseModLoader.name) + get() = ModloaderInfo.fromVersionString(baseModLoader?.name ?: gameVersion.toString()) } diff --git a/installer/src/main/kotlin/gg/essential/installer/launcher/vanilla/MinecraftInstallationData.kt b/installer/src/main/kotlin/gg/essential/installer/launcher/vanilla/MinecraftInstallationData.kt index 22bcde1..5ec36be 100644 --- a/installer/src/main/kotlin/gg/essential/installer/launcher/vanilla/MinecraftInstallationData.kt +++ b/installer/src/main/kotlin/gg/essential/installer/launcher/vanilla/MinecraftInstallationData.kt @@ -20,6 +20,7 @@ package gg.essential.installer.launcher.vanilla import gg.essential.installer.minecraft.MCVersion import gg.essential.installer.modloader.ModloaderInfo import gg.essential.installer.modloader.ModloaderType +import gg.essential.installer.modloader.ModloaderVersion import gg.essential.installer.util.InstantAsIso8601Serializer import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers @@ -44,6 +45,10 @@ data class MinecraftInstallationData( get() = when (ModloaderInfo.fromVersionString(lastVersionId).type) { ModloaderType.NONE_MODERN -> MCVersion.fromString(lastVersionId) ModloaderType.FORGE -> MCVersion.fromString(lastVersionId.split('-').first()) // Example: 1.18.2-forge-40.0.12 + ModloaderType.NEOFORGE -> { + val numeric = ModloaderVersion.fromVersion(ModloaderType.NEOFORGE, lastVersionId).numeric + MCVersion.fromString("1." + numeric.substring(0.. MCVersion.fromString(lastVersionId.split('-').last()) // Example: fabric-loader-0.15.3-1.20.4 else -> MCVersion.fromString(lastVersionId, false) // Try parsing non-strictly, to at least get the version hopefully } diff --git a/installer/src/main/kotlin/gg/essential/installer/main.kt b/installer/src/main/kotlin/gg/essential/installer/main.kt index 05ea173..b98b01c 100644 --- a/installer/src/main/kotlin/gg/essential/installer/main.kt +++ b/installer/src/main/kotlin/gg/essential/installer/main.kt @@ -50,6 +50,7 @@ private val height = System.getProperty("ui.height")?.toIntOrNull() ?: 600 private val scaleFactor = System.getProperty("ui.scaleFactor")?.toIntOrNull() ?: 1 private val resizable = System.getProperty("ui.resizable")?.toBoolean() ?: false private val debug = System.getProperty("installer.debug")?.toBoolean() ?: false +private val noModInstall = System.getProperty("installer.noModInstall")?.toBoolean() ?: false private lateinit var mainCoroutineScope: CoroutineScope private var requestRestart = false @@ -57,6 +58,8 @@ private var requestShutdown = false fun isDebug() = debug +fun isNoModInstallMode() = noModInstall + fun main(args: Array) { // Most important things to be loaded before anything else is run // This also sets up log4j's log file property diff --git a/installer/src/main/kotlin/gg/essential/installer/metadata/data/InstallerMetadata.kt b/installer/src/main/kotlin/gg/essential/installer/metadata/data/InstallerMetadata.kt index 000dc69..290b461 100644 --- a/installer/src/main/kotlin/gg/essential/installer/metadata/data/InstallerMetadata.kt +++ b/installer/src/main/kotlin/gg/essential/installer/metadata/data/InstallerMetadata.kt @@ -37,6 +37,8 @@ data class InstallerMetadata( val forgeInstaller: String, val fabric: String, val fabricFallback: String, + val neoforge: String, + val neoforgeInstaller: String, val minecraftVersions: String, val curseforgeModloaderInfo: String, ) diff --git a/installer/src/main/kotlin/gg/essential/installer/metadata/provider/ModVersionProvider.kt b/installer/src/main/kotlin/gg/essential/installer/metadata/provider/ModVersionProvider.kt index 2dfa3bf..ef5f732 100644 --- a/installer/src/main/kotlin/gg/essential/installer/metadata/provider/ModVersionProvider.kt +++ b/installer/src/main/kotlin/gg/essential/installer/metadata/provider/ModVersionProvider.kt @@ -62,7 +62,7 @@ sealed interface ModVersionProvider { @Transient override val type = "url" @Transient - override val logger: Logger = LoggerFactory.getLogger("URL Mod Version Provider ($versionURL $downloadInfoURL)") + override val logger: Logger = LoggerFactory.getLogger("URL Mod Version Provider") override suspend fun getAvailableModVersions(): Map> { return withContext(Dispatchers.IO) { @@ -91,8 +91,7 @@ sealed interface ModVersionProvider { } - val versionsString = - versions.map { entry -> entry.key.toString() + "-" + entry.value.map { it.key.name + "-" + (it.value.latestFeatured ?: it.value.latest).version } }.joinToString("; ") + val versionsString = versions.entries.joinToString("; ") { (mcVersion, map) -> "$mcVersion-[${map.entries.joinToString(",") { (type, versions) -> "$type-${(versions.latestFeatured ?: versions.latest).version}" }}]" } logger.info("Versions: $versionsString") versions } @@ -162,8 +161,7 @@ sealed interface ModVersionProvider { ) } } - val versionsString = - versionsMap.map { entry -> entry.key.toString() + "-" + entry.value.map { it.key.name + "-" + (it.value.latestFeatured ?: it.value.latest).version } }.joinToString("; ") + val versionsString = versionsMap.entries.joinToString("; ") { (mcVersion, map) -> "$mcVersion-[${map.entries.joinToString(",") { (type, versions) -> "$type-${(versions.latestFeatured ?: versions.latest).version}" }}]" } logger.info("Versions: $versionsString") versionsMap } diff --git a/installer/src/main/kotlin/gg/essential/installer/mod/ModManager.kt b/installer/src/main/kotlin/gg/essential/installer/mod/ModManager.kt index b0e1db8..8934b7d 100644 --- a/installer/src/main/kotlin/gg/essential/installer/mod/ModManager.kt +++ b/installer/src/main/kotlin/gg/essential/installer/mod/ModManager.kt @@ -17,12 +17,15 @@ package gg.essential.installer.mod import gg.essential.elementa.state.v2.State import gg.essential.elementa.state.v2.combinators.map +import gg.essential.elementa.state.v2.filter import gg.essential.elementa.state.v2.memo import gg.essential.elementa.state.v2.mutableStateOf import gg.essential.elementa.state.v2.toListState import gg.essential.installer.download.DownloadRequest +import gg.essential.installer.download.util.DownloadInfo import gg.essential.installer.install.InstallSteps import gg.essential.installer.install.installationStep +import gg.essential.installer.isNoModInstallMode import gg.essential.installer.launcher.InstallInfo import gg.essential.installer.logging.Logging.logger import gg.essential.installer.metadata.BRAND @@ -31,6 +34,7 @@ import gg.essential.installer.metadata.MetadataManager import gg.essential.installer.metadata.VERSION import gg.essential.installer.metadata.data.ModMetadata import gg.essential.installer.minecraft.MCVersion +import gg.essential.installer.modloader.Modloader import gg.essential.installer.modloader.ModloaderType import gg.essential.installer.platform.Platform import kotlinx.serialization.ExperimentalSerializationApi @@ -68,6 +72,16 @@ object ModManager { suspend fun loadModVersionsAndMetadata() { logger.info("Loading mod versions and metadata!") + + if (isNoModInstallMode()) { + logger.warn("Running in no mod install mode! This means mod versions will not actually be loaded!") + MCVersion.refreshKnownMcVersions() // Hack, since this is otherwise refreshed after this method... + val version = ModVersion("", "", DownloadInfo("", "", true)) + val map = Modloader.entries.associate { it.type to ModVersions(version, null, listOf(version)) } + availableVersions.set(MCVersion.knownVersions.filter { it >= MCVersion(8, 9) }.getUntracked().associateWith { map }) + return + } + val dataProviders = MetadataManager.dataProviders logger.debug("Version provider: {}", dataProviders.modVersionProviderStrategy) @@ -189,6 +203,9 @@ object ModManager { @OptIn(ExperimentalSerializationApi::class) fun getInstallSteps(installInfo: InstallInfo): InstallSteps { + if (isNoModInstallMode()) { + return InstallSteps() + } val modVersion = installInfo.modVersion val downloadInfo = modVersion.downloadInfo val filename = if(modVersion.version.isBlank()) "$BRAND-${installInfo.mcVersion}.jar" else "$BRAND-${modVersion.version}-${installInfo.mcVersion}.jar" diff --git a/installer/src/main/kotlin/gg/essential/installer/modloader/ForgeModloader.kt b/installer/src/main/kotlin/gg/essential/installer/modloader/ForgeModloader.kt index d7ebe24..92eee35 100644 --- a/installer/src/main/kotlin/gg/essential/installer/modloader/ForgeModloader.kt +++ b/installer/src/main/kotlin/gg/essential/installer/modloader/ForgeModloader.kt @@ -77,39 +77,6 @@ object ForgeModloader : Modloader(ModloaderType.FORGE) { val libraries: List? = null, ) - /** - * Represents the json format of the Minecraft launcher's modern version profiles - * - * It does not include the full spec, merely what is (or is thought to be) required by the installer to function - * Due to the missing fields, these should never be serialized to a file directly from these object. - * You should always use the full/raw version profile json provided by the modloader. - */ - @Serializable - data class ModernMinecraftVersionProfile( - val id: MinecraftVersionProfileId, - val inheritsFrom: String, - val releaseTime: String, - val time: String, - val type: String, - val mainClass: String, - val arguments: Arguments, - val libraries: List, - ) { - - @Serializable - data class Arguments(val game: List, val jvm: List) - - @Serializable - data class Library(val name: String, val downloads: Downloads) - - @Serializable - data class Downloads(val artifact: Artifact) - - @Serializable - data class Artifact(val path: String? = null, val url: String?, val sha1: String, val size: Long) - - } - override fun getMinecraftVersionProfileId(mcVersion: MCVersion, modloaderVersion: ModloaderVersion): MinecraftVersionProfileId { // Old forge has been very inconsistent with names for versions, this format is how modern versions format it, with "-wrapper" at the end to not mess with normal forge return "$mcVersion-forge-${modloaderVersion.numeric}-wrapper".replace(Regex("-{2,}"), "-") diff --git a/installer/src/main/kotlin/gg/essential/installer/modloader/ModernMinecraftVersionProfile.kt b/installer/src/main/kotlin/gg/essential/installer/modloader/ModernMinecraftVersionProfile.kt new file mode 100644 index 0000000..80d47ce --- /dev/null +++ b/installer/src/main/kotlin/gg/essential/installer/modloader/ModernMinecraftVersionProfile.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.’s Essential Installer repository + * and is protected under copyright registration #TX0009446119. For the + * full license, see: + * https://github.com/EssentialGG/EssentialInstaller/blob/main/LICENSE. + * + * You may modify, create, fork, and use new versions of our Essential + * Installer mod in accordance with the GPL-3 License and the additional + * provisions outlined in the LICENSE file. You may not sell, license, + * commercialize, or otherwise exploit the works in this file or any + * other in this repository, all of which is reserved by Essential. + */ + +package gg.essential.installer.modloader + +import kotlinx.serialization.Serializable + +/** + * Represents the json format of the Minecraft launcher's modern version profiles + * + * It does not include the full spec, merely what is (or is thought to be) required by the installer to function + * Due to the missing fields, these should never be serialized to a file directly from these object. + * You should always use the full/raw version profile json provided by the modloader. + */ +@Serializable +data class ModernMinecraftVersionProfile( + val id: MinecraftVersionProfileId, + val inheritsFrom: String, + val releaseTime: String, + val time: String, + val type: String, + val mainClass: String, + val arguments: Arguments, + val libraries: List, +) { + + @Serializable + data class Arguments(val game: List, val jvm: List) + + @Serializable + data class Library(val name: String, val downloads: Downloads) + + @Serializable + data class Downloads(val artifact: Artifact) + + @Serializable + data class Artifact(val path: String? = null, val url: String?, val sha1: String, val size: Long) + +} diff --git a/installer/src/main/kotlin/gg/essential/installer/modloader/Modloader.kt b/installer/src/main/kotlin/gg/essential/installer/modloader/Modloader.kt index 99652fe..775c5f6 100644 --- a/installer/src/main/kotlin/gg/essential/installer/modloader/Modloader.kt +++ b/installer/src/main/kotlin/gg/essential/installer/modloader/Modloader.kt @@ -96,7 +96,7 @@ abstract class Modloader(val type: ModloaderType) { } } - fun getPrismModloaderComponent(installInfo: PrismInstallInfo): MMCPack.Component { + open fun getPrismModloaderComponent(installInfo: PrismInstallInfo): MMCPack.Component { return MMCPack.Component( uid = installInfo.modloader.type.prismUID ?: throw IllegalArgumentException("Cannot get prism component for ${installInfo.modloader.type}"), version = installInfo.modloaderVersion.numeric, diff --git a/installer/src/main/kotlin/gg/essential/installer/modloader/ModloaderType.kt b/installer/src/main/kotlin/gg/essential/installer/modloader/ModloaderType.kt index abe90b4..7b3c900 100644 --- a/installer/src/main/kotlin/gg/essential/installer/modloader/ModloaderType.kt +++ b/installer/src/main/kotlin/gg/essential/installer/modloader/ModloaderType.kt @@ -31,7 +31,7 @@ enum class ModloaderType( FABRIC("Fabric", lazy { FabricModloader }, "net.fabricmc.fabric-loader", listOf("net.fabricmc.intermediary")), FORGE("Forge", lazy { ForgeModloader }, "net.minecraftforge"), QUILT("Quilt", null, "org.quiltmc.quilt-loader", listOf("net.fabricmc.intermediary")), - NEOFORGE("NeoForge", null, "net.neoforged"), + NEOFORGE("NeoForge", lazy { NeoForgeModloader }, "net.neoforged"), NONE_SNAPSHOT("Snapshot"), // To allow parsing snapshot versions NONE_ALPHA("Alpha"), // To allow parsing alpha versions diff --git a/installer/src/main/kotlin/gg/essential/installer/modloader/ModloaderVersion.kt b/installer/src/main/kotlin/gg/essential/installer/modloader/ModloaderVersion.kt index e5fdb2f..c4b97c9 100644 --- a/installer/src/main/kotlin/gg/essential/installer/modloader/ModloaderVersion.kt +++ b/installer/src/main/kotlin/gg/essential/installer/modloader/ModloaderVersion.kt @@ -50,6 +50,7 @@ data class ModloaderVersion( companion object { // Note if anyone ever edits this: Order inside the matching group matters (specifically for first one), as we would otherwise not parse x.y.z.w correctly, we would match the x.y.z first private val FORGE_VERSION_REGEX = Pattern.compile("(?(\\d+\\.\\d+|[02-9]|\\d{2,})\\.\\d+\\.\\d+)") + private val NEOFORGE_VERSION_REGEX = Pattern.compile("(?(\\d+\\.\\d+\\.\\d+))") fun fromVersion(type: ModloaderType, fullVersion: String, providedNumericVersion: String? = null): ModloaderVersion { @@ -90,7 +91,7 @@ data class ModloaderVersion( x.y.z, where x must not be 1. This effectively matches anything that looks like a version, but is not a minecraft version. Let's hope Minecraft doesn't update to 2.0... */ - ModloaderType.FORGE, ModloaderType.NEOFORGE -> { + ModloaderType.FORGE -> { val matcher = FORGE_VERSION_REGEX.matcher(fullVersion) if (matcher.find()) { matcher.group("version") @@ -98,6 +99,15 @@ data class ModloaderVersion( "" } } + // Neoforge doesn't have multiple numeric versions, making this easy + ModloaderType.NEOFORGE -> { + val matcher = NEOFORGE_VERSION_REGEX.matcher(fullVersion) + if (matcher.find()) { + matcher.group("version") + } else { + "" + } + } else -> "" } diff --git a/installer/src/main/kotlin/gg/essential/installer/modloader/NeoForgeModloader.kt b/installer/src/main/kotlin/gg/essential/installer/modloader/NeoForgeModloader.kt new file mode 100644 index 0000000..7857834 --- /dev/null +++ b/installer/src/main/kotlin/gg/essential/installer/modloader/NeoForgeModloader.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2025 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.’s Essential Installer repository + * and is protected under copyright registration #TX0009446119. For the + * full license, see: + * https://github.com/EssentialGG/EssentialInstaller/blob/main/LICENSE. + * + * You may modify, create, fork, and use new versions of our Essential + * Installer mod in accordance with the GPL-3 License and the additional + * provisions outlined in the LICENSE file. You may not sell, license, + * commercialize, or otherwise exploit the works in this file or any + * other in this repository, all of which is reserved by Essential. + */ + +package gg.essential.installer.modloader + +import gg.essential.installer.download.DownloadRequest +import gg.essential.installer.download.HttpManager +import gg.essential.installer.download.decode +import gg.essential.installer.download.util.CompleteURL +import gg.essential.installer.download.util.DownloadInfo +import gg.essential.installer.install.ErrorInstallStep +import gg.essential.installer.install.InstallSteps +import gg.essential.installer.install.StandaloneInstallStep +import gg.essential.installer.install.execute +import gg.essential.installer.launcher.InstallInfo +import gg.essential.installer.launcher.prism.MMCPack +import gg.essential.installer.launcher.prism.PrismInstallInfo +import gg.essential.installer.launcher.vanilla.MinecraftInstallInfo +import gg.essential.installer.metadata.MetadataManager +import gg.essential.installer.minecraft.MCVersion +import gg.essential.installer.platform.Platform +import gg.essential.installer.util.set +import gg.essential.installer.util.verifyChecksums +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.encodeToStream +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.io.FileNotFoundException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.zip.ZipFile +import kotlin.io.path.Path +import kotlin.io.path.createParentDirectories +import kotlin.io.path.div + +private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = false +} + +private val NEOFORGE_VERSION_LIST = CompleteURL(MetadataManager.installer.urls.neoforge) + +/** + * NeoForge modloader, lightly adapted from the first-written Forge class + * Not made generic, since there is no guarantee that in the future the install processes will stay the same. + * Thus, most of the code is simply duplicated. + */ +object NeoForgeModloader : Modloader(ModloaderType.NEOFORGE) { + + @Serializable + data class NeoForgeVersions(val isSnapshot: Boolean, val versions: List) + + @Serializable + data class NeoForgeInstallProfile( + val json: String, + val libraries: List? = null, + ) + + override fun getPrismModloaderComponent(installInfo: PrismInstallInfo): MMCPack.Component { + return super.getPrismModloaderComponent(installInfo).copy(version = installInfo.modloaderVersion.full) + } + + override fun getMinecraftVersionProfileId(mcVersion: MCVersion, modloaderVersion: ModloaderVersion): MinecraftVersionProfileId { + return "neoforge-${modloaderVersion.full}-wrapper" + } + + override suspend fun loadAvailableVersions(): Map { + try { + val neoforgeVersionList = HttpManager.httpGet(NEOFORGE_VERSION_LIST).decode(json).versions + + return neoforgeVersionList.fold(mutableMapOf>()) { acc, ver -> + val modloaderVersion = ModloaderVersion.fromVersion(ModloaderType.NEOFORGE, ver) + // NeoForge decided to support the craftmine april fools, which was the first version that (obviously) didn't have the numeric version parseable + if (modloaderVersion.numeric.isBlank()) { + logger.warn("Failed to parse $ver! No numeric version was found...") + return@fold acc + } + val versionRaw = "1." + modloaderVersion.numeric.substring(0.. + // NeoForge lists them oldest -> newest, so we flip it for consistency with fabric loader + ModloaderVersions(versions.last(), null, versions.reversed()) + } + } catch (e: Exception) { + logger.info("Error loading supported versions!", e) + return mapOf() + } + } + + /** + * NeoForge installing is a bit of a process, which is also dependent on what version we are installing to. + * + * The process was copied from our old installer so theres not much for me to explain why stuff is like it is here, it just works. + */ + @Suppress("LoggingSimilarMessage") + @OptIn(ExperimentalSerializationApi::class) + override fun getInstallSteps(installInfo: InstallInfo): InstallSteps { + // Early return if we are not updating the modloader in any capacity + if (!installInfo.updateModloaderVersion) { + return InstallSteps() + } + return when (installInfo) { + is MinecraftInstallInfo -> { + val fullModloaderVersion = installInfo.modloaderVersion.full + + val installerTempPath = Platform.tempFolder / "neoforge-$fullModloaderVersion-installer.jar" + val prepareStep: StandaloneInstallStep? = + if (availableVersions.getUntracked() + .containsKey(installInfo.mcVersion) + ) null else ErrorInstallStep(IllegalArgumentException("Minecraft version ${installInfo.mcVersion} not supported by NeoForge")) + val installStep: StandaloneInstallStep = installInfo.launcher.writeLibrariesAndVersionProfileInstallStep(installInfo) + val neoforgeInstallerUrl = MetadataManager.installer.urls.neoforgeInstaller.replace("{fullModloaderVersion}", fullModloaderVersion) + var downloadStep: StandaloneInstallStep = DownloadRequest( + DownloadInfo( + "NeoForge installer", + neoforgeInstallerUrl, + true, + DownloadInfo.Checksums() + ), + installerTempPath, + ).then("Verifying downloads") { + if (!Files.exists(installerTempPath)) { + throw FileNotFoundException("Unable to find neoforge installer jar for checksum comparison at $installerTempPath.") + } + val url = "$neoforgeInstallerUrl.sha256" + val request = HttpManager.httpGet(url) + if (request.status != HttpStatusCode.OK) { + throw IllegalStateException("Unable to fetch checksum for neoforge installer jar at $url") + } + val checksumResult = installerTempPath.toFile().verifyChecksums(DownloadInfo.Checksums(sha256 = request.bodyAsText())) + if (checksumResult != null && !checksumResult.result) { + throw IllegalStateException("Checksum verification failed for neoforge installer. Expected: ${checksumResult.expected}, Actual: ${checksumResult.actual}") + } + } + logger.debug("installerTempPath = {}", installerTempPath) + logger.debug("Download step: {}", downloadStep) + downloadStep = + downloadStep.then("Preparing libraries and version profile") { + logger.info("Preparing library and version profile") + + val libraryTempFolderPath = installInfo.librariesTempPath / "net" / "neoforged" / "neoforge" / fullModloaderVersion + val libraryTempPath = libraryTempFolderPath / "neoforge-$fullModloaderVersion-installer.jar" + + logger.debug("libraryTempFolderPath = {}", libraryTempFolderPath) + logger.debug("libraryTempPath = {}", libraryTempPath) + + Files.createDirectories(libraryTempFolderPath) + Files.copy(installerTempPath, libraryTempPath, StandardCopyOption.REPLACE_EXISTING) + logger.debug("Copied {} to {}", installerTempPath, libraryTempPath) + + ZipFile(installerTempPath.toFile()).use { zipFile -> + + val installProfile = zipFile.getInputStream(zipFile.getEntry("install_profile.json")).use { json.decodeFromStream(it) } + + logger.debug("Install profile:\n{}", json.encodeToString(installProfile)) + + val versionProfileInputStream = zipFile.getInputStream(zipFile.getEntry(installProfile.json.let { if (it.startsWith("/")) it.substring(1) else it })) + + val versionProfileRaw = String(versionProfileInputStream.readBytes()) + + logger.debug("versionProfileRaw = {}", versionProfileRaw) + + val versionProfileLibraries = json.decodeFromString(versionProfileRaw).libraries + val libraries = versionProfileLibraries + installProfile.libraries.orEmpty() + + for (library in libraries) { + logger.debug("Library: {}", library) + val name = library.name + val sha1 = library.downloads.artifact.sha1 + val pathString = library.downloads.artifact.path + val url = library.downloads.artifact.url + val size = library.downloads.artifact.size + if (pathString == null) + continue + if (installInfo.mcVersion >= MCVersion(20, 4) && name.matches(Regex("net\\.neoforged:neoforge:.*:client"))) { + continue + } + + val path = Path(pathString) + val launcherLibraryPath = installInfo.launcher.librariesPath / path + + if (Files.exists(launcherLibraryPath)) { + val checksumResult = launcherLibraryPath.toFile().verifyChecksums(DownloadInfo.Checksums(sha1 = sha1)) + if (checksumResult?.result == true) { + logger.debug("Skipping the download of ${library.name} library because it already exists.") + continue + } + } + + val libraryDownloadPath = installInfo.librariesTempPath / path + val libraryDownloadFolderPath = libraryDownloadPath.parent + + Files.createDirectories(libraryDownloadFolderPath) + logger.debug("Created temp library folder or ensured it exists: {}", libraryDownloadFolderPath) + + // zip paths always use forwards slashes / + val zipPath = (Path("maven") / path).toString().replace(File.separator, "/") + val entry = zipFile.getEntry(zipPath) + if (entry != null) { + val target = installInfo.librariesTempPath / path + target.createParentDirectories() + logger.debug("Created temp library folder or ensured it exists: {}", target) + logger.debug("Copying {} from installer to {}", zipPath, target) + Files.newOutputStream(target).use { zipFile.getInputStream(entry).copyTo(it) } + } else if (!url.isNullOrBlank()) { + DownloadRequest(DownloadInfo(name, url, size, DownloadInfo.Checksums(sha1 = sha1)), libraryDownloadPath).execute() + } + } + + var versionProfileJson = json.parseToJsonElement(versionProfileRaw).jsonObject + + versionProfileJson = versionProfileJson.set("mainClass", "io.github.zekerzhayard.forgewrapper.installer.Main") + var arguments = versionProfileJson["arguments"]?.jsonObject ?: JsonObject(emptyMap()) + val jvm = listOf( + JsonPrimitive("-Dforgewrapper.installer=${installInfo.launcher.librariesPath / installInfo.librariesTempPath.relativize(libraryTempPath)}"), + JsonPrimitive("-Dforgewrapper.minecraft=${installInfo.launcher.versionsPath / installInfo.versionProfileId / "${installInfo.versionProfileId}.jar"}"), + ) + arguments = arguments.set("jvm", JsonArray(jvm)) + versionProfileJson = versionProfileJson.set("arguments", arguments) + + val libs = versionProfileJson["libraries"]?.jsonArray?.toMutableList() ?: mutableListOf() + if (installInfo.mcVersion >= MCVersion(20, 4)) { + libs.removeIf { it.jsonObject["name"]?.jsonPrimitive?.content?.matches(Regex("net\\.neoforged:neoforge:.*:client")) ?: false } + } + libs.add( + json.encodeToJsonElement( + ModernMinecraftVersionProfile.Library( + name = "io.github.zekerzhayard:ForgeWrapper:prism-2024-02-29", + downloads = ModernMinecraftVersionProfile.Downloads( + artifact = ModernMinecraftVersionProfile.Artifact( + path = null, + url = "https://files.prismlauncher.org/maven/io/github/zekerzhayard/ForgeWrapper/prism-2024-02-29/ForgeWrapper-prism-2024-02-29.jar", + size = 35483, + sha1 = "86c6791e32ac6478dabf9663f0ad19f8b6465dfe", + ) + ) + ) + ) + ) + versionProfileJson = versionProfileJson.set("libraries", JsonArray(libs)) + + logger.debug("Fixed Version profile:\n{}", json.encodeToString(versionProfileJson)) // Yeah, I know we encode it twice + + Files.newOutputStream(installInfo.versionProfileTempPath).use { json.encodeToStream(versionProfileJson, it) } + logger.debug("Wrote Fixed Version profile to {}", installInfo.versionProfileTempPath) + } + } + InstallSteps(prepareStep, downloadStep, installStep) + } + + else -> { + logger.warn("Tried to get install steps for {}, which are not handled by this class.", installInfo.javaClass.simpleName) + InstallSteps() + } + } + + } + +} diff --git a/installer/src/main/resources/installer-metadata.json b/installer/src/main/resources/installer-metadata.json index 9bac958..bec7913 100644 --- a/installer/src/main/resources/installer-metadata.json +++ b/installer/src/main/resources/installer-metadata.json @@ -11,6 +11,8 @@ "forgeInstaller": "https://maven.minecraftforge.net/net/minecraftforge/forge/{fullModloaderVersion}/forge-{fullModloaderVersion}-installer.jar", "fabric": "https://meta.fabricmc.net", "fabricFallback": "https://meta2.fabricmc.net", + "neoforge": "https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/neoforge", + "neoforgeInstaller": "https://maven.neoforged.net/releases/net/neoforged/neoforge/{fullModloaderVersion}/neoforge-{fullModloaderVersion}-installer.jar", "minecraftVersions": "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json", "curseforgeModloaderInfo": "https://api.curseforge.com/v1/minecraft/modloader/{id}" } diff --git a/wrapper/src/installer.rs b/wrapper/src/installer.rs index de6febb..7fb2e4b 100644 --- a/wrapper/src/installer.rs +++ b/wrapper/src/installer.rs @@ -80,8 +80,9 @@ pub fn try_run_installer( let debug_arg = format!("-Dinstaller.debug={}", wrapper_info.debug); let version_arg = format!("-Dwrapper.version={}", VERSION); + let no_mod_arg = format!("-Dinstaller.noModInstall={}", wrapper_info.no_mod_install); - let mut args: Vec<&str> = vec![debug_arg.as_str(), version_arg.as_str()]; + let mut args: Vec<&str> = vec![debug_arg.as_str(), version_arg.as_str(), no_mod_arg.as_str()]; let temp_arg: String; if let Some(temp_str) = wrapper_info.temp_dir.to_str() { diff --git a/wrapper/src/main.rs b/wrapper/src/main.rs index 9f12839..ce5e2fc 100644 --- a/wrapper/src/main.rs +++ b/wrapper/src/main.rs @@ -61,6 +61,7 @@ pub fn main() { debug: args.contains(&String::from("--debug")), no_java_search: args.contains(&String::from("--no-java-search")), force_error: args.contains(&String::from("--force-error")), + no_mod_install: args.contains(&String::from("--no-mod-install")), temp_dir, cache_dir, }; @@ -107,6 +108,7 @@ pub struct WrapperInfo { pub debug: bool, pub no_java_search: bool, pub force_error: bool, + pub no_mod_install: bool, pub temp_dir: PathBuf, pub cache_dir: PathBuf, }