Skip to content

Commit f2b1c43

Browse files
committed
feat(loaders): Quilt Meta integration and verified Forge-family version discovery
1 parent bcd91a0 commit f2b1c43

6 files changed

Lines changed: 198 additions & 2 deletions

File tree

‎launcher-core/src/main/kotlin/pl/syntaxdevteam/syntaxlauncher/core/loader/FabricLoaderProvider.kt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ class FabricLoaderProvider(private val http: HttpClient) : ModLoaderProvider {
6060
object FabricProfileParser {
6161
private val coordinate = Regex("""[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:[A-Za-z0-9_.+~-]+""")
6262
private val allowedHosts = setOf(
63-
"maven.fabricmc.net", "libraries.minecraft.net", "repo.maven.apache.org", "repo1.maven.org"
63+
"maven.fabricmc.net", "maven.quiltmc.org", "libraries.minecraft.net",
64+
"repo.maven.apache.org", "repo1.maven.org"
6465
)
6566

6667
fun parseVersions(payload: String): List<ModLoaderVersion> {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package pl.syntaxdevteam.syntaxlauncher.core.loader
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.request.get
5+
import io.ktor.client.statement.bodyAsText
6+
import io.ktor.http.isSuccess
7+
import pl.syntaxdevteam.syntaxlauncher.core.minecraft.ResolvedMinecraftVersion
8+
9+
/** Parse Maven version tags without XML entities, external DTDs or arbitrary URLs. */
10+
internal object InstallerMavenVersions {
11+
private val versionTag = Regex("<version>\\s*([^<>\\s]{1,120})\\s*</version>")
12+
fun parse(xml: String): List<String> {
13+
require(xml.length <= 4_000_000 && !xml.contains("<!DOCTYPE", ignoreCase = true) &&
14+
!xml.contains("<!ENTITY", ignoreCase = true)) { "Unsafe Maven metadata." }
15+
return versionTag.findAll(xml).map { it.groupValues[1] }
16+
.filter { it.matches(Regex("[A-Za-z0-9][A-Za-z0-9._+~-]{0,119}")) }
17+
.distinct().toList()
18+
}
19+
}
20+
21+
/** The Forge-family installer pipeline is not yet implemented; fail closed on resolve. */
22+
abstract class InstallerBackedLoaderProvider(
23+
protected val http: HttpClient,
24+
private val metadataUrl: String,
25+
) : ModLoaderProvider {
26+
override suspend fun resolve(base: ResolvedMinecraftVersion, version: String): ResolvedMinecraftVersion {
27+
ModLoaderSelection(id, version)
28+
if (compatibleVersions(base.id).none { it.version == version }) {
29+
throw IncompatibleModLoaderException(id + " " + version + " is unavailable for " + base.id)
30+
}
31+
throw ModLoaderException(
32+
id + " requires its official installer processors and generated libraries. " +
33+
"It cannot be launched by SyntaxLauncher yet."
34+
)
35+
}
36+
protected suspend fun metadata(): List<String> {
37+
val response = http.get(metadataUrl)
38+
if (!response.status.isSuccess()) {
39+
throw ModLoaderException(id + " Maven metadata HTTP " + response.status.value)
40+
}
41+
return InstallerMavenVersions.parse(response.bodyAsText())
42+
}
43+
}
44+
45+
class NeoForgeLoaderProvider(http: HttpClient) : InstallerBackedLoaderProvider(
46+
http, "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml",
47+
) {
48+
override val id = "neoforge"
49+
override suspend fun compatibleVersions(minecraftVersionId: String): List<ModLoaderVersion> {
50+
require(minecraftVersionId.matches(Regex("[A-Za-z0-9._-]{1,80}")))
51+
val parts = minecraftVersionId.split('.')
52+
val prefix = when {
53+
parts.size >= 3 && parts[0] == "1" -> parts[1] + "." + parts[2] + "."
54+
parts.size >= 2 && parts[0].toIntOrNull() != null -> minecraftVersionId + "."
55+
else -> return emptyList()
56+
}
57+
return metadata().asReversed().filter { it.startsWith(prefix) }.map {
58+
ModLoaderVersion(it, !it.contains('-'))
59+
}
60+
}
61+
}
62+
63+
class ForgeLoaderProvider(http: HttpClient) : InstallerBackedLoaderProvider(
64+
http, "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml",
65+
) {
66+
override val id = "forge"
67+
override suspend fun compatibleVersions(minecraftVersionId: String): List<ModLoaderVersion> {
68+
require(minecraftVersionId.matches(Regex("[A-Za-z0-9._-]{1,80}")))
69+
val prefix = minecraftVersionId + "-"
70+
return metadata().asReversed().filter { it.startsWith(prefix) }.map {
71+
ModLoaderVersion(it.removePrefix(prefix), !it.contains("beta", ignoreCase = true))
72+
}
73+
}
74+
}

‎launcher-core/src/main/kotlin/pl/syntaxdevteam/syntaxlauncher/core/loader/ModLoader.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,5 @@ class ModLoaderRegistry(providers: List<ModLoaderProvider>) {
3535
if (selected == null) base else provider(selected.id).resolve(base, selected.version)
3636

3737
private fun provider(id: String): ModLoaderProvider =
38-
providersById[id] ?: throw ModLoaderException("Unsupported loader '$id'. Choose Vanilla or Fabric.")
38+
providersById[id] ?: throw ModLoaderException("Unsupported loader '$id'. Select a registered loader or Vanilla.")
3939
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package pl.syntaxdevteam.syntaxlauncher.core.loader
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.request.get
5+
import io.ktor.client.statement.bodyAsText
6+
import io.ktor.http.isSuccess
7+
import java.net.URLEncoder
8+
import java.nio.charset.StandardCharsets
9+
import pl.syntaxdevteam.syntaxlauncher.core.minecraft.ResolvedMinecraftVersion
10+
11+
/** Official Quilt Meta v3 launcher profile adapter. */
12+
class QuiltLoaderProvider(
13+
private val http: HttpClient,
14+
private val root: String = "https://meta.quiltmc.org/v3",
15+
) : ModLoaderProvider {
16+
override val id = "quilt"
17+
18+
override suspend fun compatibleVersions(minecraftVersionId: String): List<ModLoaderVersion> =
19+
FabricProfileParser.parseVersions(request("/versions/loader/" + encode(minecraftVersionId)))
20+
21+
override suspend fun resolve(base: ResolvedMinecraftVersion, version: String): ResolvedMinecraftVersion {
22+
ModLoaderSelection(id, version)
23+
if (compatibleVersions(base.id).none { it.version == version }) {
24+
throw IncompatibleModLoaderException("Quilt " + version + " does not support Minecraft " + base.id)
25+
}
26+
return FabricProfileParser.merge(base, version,
27+
request("/versions/loader/" + encode(base.id) + "/" + encode(version) + "/profile/json"))
28+
}
29+
30+
private suspend fun request(path: String): String {
31+
val response = http.get(root + path)
32+
if (!response.status.isSuccess()) {
33+
throw ModLoaderException("Quilt Meta returned HTTP " + response.status.value)
34+
}
35+
return response.bodyAsText()
36+
}
37+
38+
private fun encode(version: String): String {
39+
require(version.matches(Regex("[A-Za-z0-9._+~-]{1,120}"))) { "Invalid Quilt/Minecraft version." }
40+
return URLEncoder.encode(version, StandardCharsets.UTF_8)
41+
}
42+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package pl.syntaxdevteam.syntaxlauncher.core.loader
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.engine.mock.MockEngine
5+
import io.ktor.client.engine.mock.respond
6+
import kotlin.test.Test
7+
import kotlin.test.assertEquals
8+
import kotlin.test.assertFailsWith
9+
import kotlinx.coroutines.runBlocking
10+
import pl.syntaxdevteam.syntaxlauncher.core.minecraft.MinecraftArguments
11+
import pl.syntaxdevteam.syntaxlauncher.core.minecraft.MinecraftVersionType
12+
import pl.syntaxdevteam.syntaxlauncher.core.minecraft.ResolvedMinecraftVersion
13+
14+
class InstallerLoaderDiscoveryTest {
15+
private val metadata = """<metadata><versioning><versions>
16+
<version>26.3.0.84</version><version>26.2.0.76</version>
17+
<version>1.21.1-52.1.0</version><version>1.21.1-52.0.1</version>
18+
<version>21.1.48</version>
19+
</versions></versioning></metadata>"""
20+
21+
@Test fun filtersMavenVersionMetadataAndFailsClosedOnInstallerLaunch() = runBlocking {
22+
val http = HttpClient(MockEngine { respond(metadata) })
23+
try {
24+
val neo = NeoForgeLoaderProvider(http)
25+
assertEquals(listOf("26.3.0.84"), neo.compatibleVersions("26.3").map { it.version })
26+
assertEquals(listOf("21.1.48"), neo.compatibleVersions("1.21.1").map { it.version })
27+
val forge = ForgeLoaderProvider(http)
28+
assertEquals(listOf("52.0.1", "52.1.0"),
29+
forge.compatibleVersions("1.21.1").map { it.version })
30+
val base = ResolvedMinecraftVersion("26.3", MinecraftVersionType.RELEASE,
31+
"net.minecraft.client.main.Main", null, null, null,
32+
emptyList(), MinecraftArguments(), null, null)
33+
assertFailsWith<ModLoaderException> { neo.resolve(base, "26.3.0.84") }
34+
assertFailsWith<ModLoaderException> { forge.resolve(base.copy(id = "1.21.1"), "52.1.0") }
35+
} finally { http.close() }
36+
}
37+
38+
@Test fun rejectsExternalEntitiesAndInvalidCoordinates() {
39+
assertFailsWith<IllegalArgumentException> {
40+
InstallerMavenVersions.parse("<!DOCTYPE x [<!ENTITY a SYSTEM 'file:///etc/passwd'>]>")
41+
}
42+
assertEquals(emptyList(), InstallerMavenVersions.parse("<version>../bad</version>"))
43+
}
44+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package pl.syntaxdevteam.syntaxlauncher.core.loader
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.engine.mock.MockEngine
5+
import io.ktor.client.engine.mock.respond
6+
import kotlin.test.Test
7+
import kotlin.test.assertEquals
8+
import kotlin.test.assertFailsWith
9+
import kotlinx.coroutines.runBlocking
10+
import pl.syntaxdevteam.syntaxlauncher.core.minecraft.MinecraftArguments
11+
import pl.syntaxdevteam.syntaxlauncher.core.minecraft.MinecraftVersionType
12+
import pl.syntaxdevteam.syntaxlauncher.core.minecraft.ResolvedMinecraftVersion
13+
14+
class QuiltLoaderProviderTest {
15+
@Test fun mergesQuiltProfileThroughSharedMinecraftLaunchMetadata() = runBlocking {
16+
val http = HttpClient(MockEngine { req ->
17+
if (req.url.encodedPath.endsWith("/profile/json")) respond(
18+
"""{"inheritsFrom":"1.21.1",
19+
"mainClass":"org.quiltmc.loader.impl.launch.knot.KnotClient",
20+
"libraries":[{"name":"org.quiltmc:quilt-loader:0.28.0","url":"https://maven.quiltmc.org/"}]}"""
21+
) else respond("""[{"loader":{"version":"0.28.0","stable":true}}]""")
22+
})
23+
try {
24+
val base = ResolvedMinecraftVersion("1.21.1", MinecraftVersionType.RELEASE,
25+
"net.minecraft.client.main.Main", null, null, null,
26+
emptyList(), MinecraftArguments(), null, null)
27+
val quilt = QuiltLoaderProvider(http)
28+
assertEquals("0.28.0", quilt.compatibleVersions("1.21.1").single().version)
29+
val merged = quilt.resolve(base, "0.28.0")
30+
assertEquals("org.quiltmc.loader.impl.launch.knot.KnotClient", merged.mainClass)
31+
assertEquals("org.quiltmc:quilt-loader:0.28.0", merged.libraries.single().name)
32+
assertFailsWith<IncompatibleModLoaderException> { quilt.resolve(base, "0.27.0") }
33+
} finally { http.close() }
34+
}
35+
}

0 commit comments

Comments
 (0)