User: kyle wood Date: 15 Nov 26 04:57 Revision: 8536a62f8cc9bd7b459e673aed12dcba2659749c Summary: Standardize HTTP requests through Jetbrains HttpRequests The McdevHttp util allows us to serve mocked results for tests in the future. TeamCity URL: http://ci.mcdev.io:80/viewModification.html?tab=vcsModificationFiles&modId=10788&personal=false Index: build.gradle.kts =================================================================== --- build.gradle.kts (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ build.gradle.kts (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -19,8 +19,6 @@ */ import io.sentry.android.gradle.extensions.SentryPluginExtension -import java.util.concurrent.atomic.AtomicInteger -import kotlin.concurrent.atomics.AtomicInt import org.gradle.kotlin.dsl.configure import org.jetbrains.changelog.Changelog import org.jetbrains.gradle.ext.settings @@ -37,10 +35,10 @@ alias(libs.plugins.sentry) apply (System.getenv("CI") == "true" && System.getenv("NO_SENTRY") != "true") } -val coreVersion: String by project +val coreVersion = project.property("coreVersion") as String -val gradleToolingExtension: Configuration by configurations.creating -val testLibs: Configuration by configurations.creating { +val gradleToolingExtension = configurations.create("gradleToolingExtension") +val testLibs = configurations.create("testLibs") { isTransitive = false } @@ -52,6 +50,7 @@ } }) val gradleToolingExtensionJar = tasks.register(gradleToolingExtensionSourceSet.jarTaskName) { + description = "Package Gradle tooling extension jar" from(gradleToolingExtensionSourceSet.output) archiveClassifier.set("gradle-tooling-extension") exclude("META-INF/plugin.xml") @@ -78,6 +77,7 @@ } val externalAnnotationsJar = tasks.register("externalAnnotationsJar") { + description = "Package external annotations jar" from("externalAnnotations") destinationDirectory.set(layout.buildDirectory.dir("externalAnnotations")) archiveFileName.set("externalAnnotations.jar") @@ -95,10 +95,6 @@ implementation(libs.mappingIo) implementation(libs.bundles.asm) - implementation(libs.bundles.fuel) { - exclude(group = "org.jetbrains.kotlin") - exclude(group = "org.jetbrains.kotlinx") - } implementation(libs.sentry) { exclude(group = "org.slf4j") } @@ -202,6 +198,7 @@ val sandboxTestTasks = mutableListOf>() repeat(testForks) { sandboxTestTasks += tasks.register("prepareTestSandboxFork$it") { + description = "Prepare sandbox for test fork #$it" sandboxSuffix.set("-fork-$it") doFirst { sandboxDirectory.get().asFile.listFiles() @@ -218,7 +215,8 @@ } } -val cleanTestSandboxForks by tasks.registering(Delete::class) { +val cleanTestSandboxForks = tasks.register("cleanTestSandboxForks", Delete::class) { + description = "Delete all test fork sandboxes" doFirst { tasks.prepareTestSandbox.flatMap { it.sandboxDirectory } .get().asFile.listFiles() @@ -265,6 +263,7 @@ .startsWith("src/test/resources") } + @Suppress("UnstableApiUsage") tasks { register("gradle") { files.from( @@ -299,29 +298,30 @@ } } -val generateAtLexer by lexer("AtLexer", "com/demonwav/mcdev/platform/minecraft/at/gen") -val generateAtParser by parser("AtParser", "com/demonwav/mcdev/platform/minecraft/at/gen") +val generateAtLexer = lexer("generateAtLexer", "AtLexer", "com/demonwav/mcdev/platform/minecraft/at/gen") +val generateAtParser = parser("generateAtParser", "AtParser", "com/demonwav/mcdev/platform/minecraft/at/gen") -val generateCtLexer by lexer("CtLexer", "com/demonwav/mcdev/platform/minecraft/ct/gen") -val generateCtParser by parser("CtParser", "com/demonwav/mcdev/platform/minecraft/ct/gen") +val generateCtLexer = lexer("generateCtLexer", "CtLexer", "com/demonwav/mcdev/platform/minecraft/ct/gen") +val generateCtParser = parser("generateCtParser", "CtParser", "com/demonwav/mcdev/platform/minecraft/ct/gen") -val generateNbttLexer by lexer("NbttLexer", "com/demonwav/mcdev/nbt/lang/gen") -val generateNbttParser by parser("NbttParser", "com/demonwav/mcdev/nbt/lang/gen") +val generateNbttLexer = lexer("generateNbttLexer", "NbttLexer", "com/demonwav/mcdev/nbt/lang/gen") +val generateNbttParser = parser("generateNbttParser", "NbttParser", "com/demonwav/mcdev/nbt/lang/gen") -val generateLangLexer by lexer("LangLexer", "com/demonwav/mcdev/translations/lang/gen") -val generateLangParser by parser("LangParser", "com/demonwav/mcdev/translations/lang/gen") +val generateLangLexer = lexer("generateLangLexer", "LangLexer", "com/demonwav/mcdev/translations/lang/gen") +val generateLangParser = parser("generateLangParser", "LangParser", "com/demonwav/mcdev/translations/lang/gen") -val generateMEExpressionLexer by lexer("MEExpressionLexer", "com/demonwav/mcdev/platform/mixin/expression/gen") -val generateMEExpressionParser by parser("MEExpressionParser", "com/demonwav/mcdev/platform/mixin/expression/gen") +val generateMEExpressionLexer = lexer("generateMEExpressionLexer", "MEExpressionLexer", "com/demonwav/mcdev/platform/mixin/expression/gen") +val generateMEExpressionParser = parser("generateMEExpressionParser", "MEExpressionParser", "com/demonwav/mcdev/platform/mixin/expression/gen") -val generateTranslationTemplateLexer by lexer( +val generateTranslationTemplateLexer = lexer( + "generateTranslationTemplateLexer", "TranslationTemplateLexer", "com/demonwav/mcdev/translations/template/gen" ) -val generate by tasks.registering { +val generate = tasks.register("generate") { group = "minecraft" - description = "Generates sources needed to compile the plugin." + description = "Generate sources needed to compile the plugin." outputs.dir(layout.buildDirectory.dir("gen")) dependsOn( generateAtLexer, Index: buildSrc/build.gradle.kts =================================================================== --- buildSrc/build.gradle.kts (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ buildSrc/build.gradle.kts (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -3,7 +3,7 @@ * * https://mcdev.io/ * - * Copyright (C) 2025 minecraft-dev + * Copyright (C) 2026 minecraft-dev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published @@ -37,14 +37,13 @@ gradlePluginPortal() maven("https://maven.neoforged.net/releases/") { content { + @Suppress("UnstableApiUsage") includeGroupAndSubgroups("net.neoforged") } } } dependencies { - // hack for version catalogs - implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) implementation(libs.gson) implementation(libs.kotlin.plugin) implementation(libs.intellij.plugin) Index: buildSrc/src/main/kotlin/mcdev-core.gradle.kts =================================================================== --- buildSrc/src/main/kotlin/mcdev-core.gradle.kts (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ buildSrc/src/main/kotlin/mcdev-core.gradle.kts (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -18,9 +18,9 @@ * along with this program. If not, see . */ +import gradle.kotlin.dsl.accessors._74e4e57ecc86fc3500a89165855e3ea0.versionCatalogs import org.cadixdev.gradle.licenser.header.HeaderStyle import org.cadixdev.gradle.licenser.tasks.LicenseTask -import org.gradle.accessors.dm.LibrariesForLibs import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion @@ -34,9 +34,9 @@ id("net.neoforged.licenser") } -val ideaVersionName: String by project -val coreVersion: String by project -val buildNumber: String? by project +val ideaVersionName = project.property("ideaVersionName") as String +val coreVersion = project.property("coreVersion") as String +val buildNumber = project.findProperty("buildNumber") as String? version = "$ideaVersionName-$coreVersion" @@ -108,20 +108,20 @@ maven("https://repo.denwav.dev/repository/maven-public/") } -val libs = the() +val libs = versionCatalogs.named("libs") dependencies { - compileOnly(libs.kotlin.stdlib) - compileOnly(libs.kotlin.reflect) - compileOnly(libs.annotations) - implementation(libs.bundles.coroutines) { + compileOnly(libs.findLibrary("kotlin.stdlib").get()) + compileOnly(libs.findLibrary("kotlin.reflect").get()) + compileOnly(libs.findLibrary("annotations").get()) + implementation(libs.findBundle("coroutines").get()) { exclude(module = "kotlinx-coroutines-core-jvm") exclude(group = "org.jetbrains.kotlin") } - testImplementation(libs.junit.api) - testImplementation(libs.junit.vintage) // Hack to get tests to compile and run - testRuntimeOnly(libs.junit.engine) - testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.findLibrary("junit.api").get()) + testImplementation(libs.findLibrary("junit.vintage").get()) // Hack to get tests to compile and run + testRuntimeOnly(libs.findLibrary("junit.engine").get()) + testRuntimeOnly(libs.findLibrary("junit.platform.launcher").get()) } intellijPlatform { Index: buildSrc/src/main/kotlin/mcdev-parsing.gradle.kts =================================================================== --- buildSrc/src/main/kotlin/mcdev-parsing.gradle.kts (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ buildSrc/src/main/kotlin/mcdev-parsing.gradle.kts (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -3,7 +3,7 @@ * * https://mcdev.io/ * - * Copyright (C) 2025 minecraft-dev + * Copyright (C) 2026 minecraft-dev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published @@ -18,25 +18,23 @@ * along with this program. If not, see . */ -import org.gradle.accessors.dm.LibrariesForLibs - plugins { idea } -val jflex: Configuration by configurations.creating -val jflexSkeleton: Configuration by configurations.creating -val grammarKit: Configuration by configurations.creating +val jflex = configurations.create("jflex") +val jflexSkeleton = configurations.create("jflexSkeleton") +val grammarKit = configurations.create("grammarKit") -val libs = the() +val libs = versionCatalogs.named("libs") dependencies { - jflex(libs.jflex.lib) - jflexSkeleton(libs.jflex.skeleton) { + jflex(libs.findLibrary("jflex.lib").get()) + jflexSkeleton(libs.findLibrary("jflex.skeleton").get()) { artifact { extension = "skeleton" } } - grammarKit(libs.grammarKit) + grammarKit(libs.findLibrary("grammarKit").get()) } idea { Index: buildSrc/src/main/kotlin/mcdev-publishing.gradle.kts =================================================================== --- buildSrc/src/main/kotlin/mcdev-publishing.gradle.kts (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ buildSrc/src/main/kotlin/mcdev-publishing.gradle.kts (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -32,68 +32,97 @@ id("org.jetbrains.intellij.platform") } +// Copied from PublishPluginTask, where it's private +fun Throwable.isIdeServicesUploadResponseParsingFailure(): Boolean { + val message = message.orEmpty() + return (javaClass.name == "com.fasterxml.jackson.databind.exc.InvalidFormatException" && + "Cannot deserialize value of type `int` from String" in message && + """PluginUpdateBean["id"]""" in message) || + cause?.isIdeServicesUploadResponseParsingFailure() == true || + suppressed.any { it.isIdeServicesUploadResponseParsingFailure() } +} + tasks.publishPlugin { - properties["mcdev.deploy.token"]?.let { deployToken -> - token.set(deployToken.toString()) + (findProperty("mcdev.deploy.token") as String?)?.let { deployToken -> + token.set(deployToken) } - channels.add(properties["mcdev.deploy.channel"]?.toString() ?: "Stable") + channels.add(findProperty("mcdev.deploy.channel")?.toString() ?: "Stable") // Overwrite the publish action, to properly set the until version after publishing (otherwise they ignore it). // See https://youtrack.jetbrains.com/issue/IJPL-166094/Plugins-Disable-until-build-range-check-by-default actions = listOf(Action { - if (token.orNull.isNullOrEmpty()) { + val resolvedToken = token.orNull + if (resolvedToken.isNullOrEmpty()) { throw GradleException("No token specified for publishing. Make sure to specify mcdev.deploy.token.") } val log = Logging.getLogger(javaClass) - val path = archiveFile.get().asFile.toPath().absolute() + val paths = archiveFiles.files.map { it.toPath().absolute() } val pluginId = "com.demonwav.minecraft-dev" - channels.get().forEach { channel -> - log.info("Uploading plugin '$pluginId' from '$path' to '${host.get()}', channel: '$channel'") - try { - val repositoryClient = when (ideServices.get()) { + val resolvedHost = host.get() + val useIdeServices = ideServices.get() + val isHidden = hidden.get() + val resolvedChannels = channels.get() + val repositoryClient = when (useIdeServices) { - true -> PluginRepositoryFactory.createWithImplementationClass( + true -> PluginRepositoryFactory.createWithImplementationClass( - host.get(), - token.get(), + resolvedHost, + resolvedToken, - "Automation", - IdeServicesPluginRepositoryService::class.java, - ) + "Automation", + IdeServicesPluginRepositoryService::class.java, + ) - false -> PluginRepositoryFactory.create(host.get(), token.get()) + false -> PluginRepositoryFactory.create(resolvedHost, resolvedToken) - } + } - val uploadBean = repositoryClient.uploader.uploadUpdateByXmlIdAndFamily( + + paths.forEach { path -> + resolvedChannels.forEach { channel -> + log.info("Uploading plugin '$pluginId' from '$path' to '$resolvedHost', channel: '$channel'") + + val uploadBean = try { + repositoryClient.uploader.uploadUpdateByXmlIdAndFamily( - id = pluginId, - family = ProductFamily.INTELLIJ, - file = path.toFile(), - channel = channel.takeIf { it != "default" }, - notes = null, + id = pluginId, + family = ProductFamily.INTELLIJ, + file = path.toFile(), + channel = channel.takeIf { it != "default" }, + notes = null, - isHidden = hidden.get(), + isHidden = isHidden, - ) + ) + } catch (exception: Exception) { + if (useIdeServices && exception.isIdeServicesUploadResponseParsingFailure()) { + log.warn("Uploaded successfully; ignoring IDE Services upload response parsing failure.") + log.debug("IDE Services upload response parsing failure", exception) + // No upload response to read the version ID from, so the until update can't happen + return@forEach + } + throw GradleException("Failed to upload plugin: ${exception.message}", exception) + } log.info("Uploaded successfully as version ID ${uploadBean.id}") + try { - val since = uploadBean.since - log.info("Since is ${since}, until is ${uploadBean.until}") - if (since != null && uploadBean.until.isNullOrBlank()) { - val newUntil = since.substringBefore(".") + ".*" - log.info("Updating until to $newUntil") - val request = HttpRequest.newBuilder() - .uri(URI.create("https://plugins.jetbrains.com/api/updates/${uploadBean.id}/since-until")) + val since = uploadBean.since + log.info("Since is ${since}, until is ${uploadBean.until}") + if (since != null && uploadBean.until.isNullOrBlank()) { + val newUntil = since.substringBefore(".") + ".*" + log.info("Updating until to $newUntil") + val request = HttpRequest.newBuilder() + .uri(URI.create("https://plugins.jetbrains.com/api/updates/${uploadBean.id}/since-until")) - .header("Authorization", "Bearer ${token.get()}") + .header("Authorization", "Bearer $resolvedToken") - .header("Content-Type", "application/json") - .header("User-Agent", "Minecraft Development Plugin Publisher") + .header("Content-Type", "application/json") + .header("User-Agent", "Minecraft Development Plugin Publisher") - .POST(HttpRequest.BodyPublishers.ofString("{\"since\":\"${uploadBean.since}\",\"until\":\"$newUntil\"}")) + .POST(HttpRequest.BodyPublishers.ofString("{\"since\":\"$since\",\"until\":\"$newUntil\"}")) - .build() - val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()) - if (response.statusCode() < 200 || response.statusCode() >= 300) { - throw IOException("Updating until failed with status code ${response.statusCode()}, ${response.body()}") - } - log.info("Successful with status code ${response.statusCode()}") - } - } catch (exception: Exception) { + .build() + val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw IOException("Updating until failed with status code ${response.statusCode()}, ${response.body()}") + } + log.info("Successful with status code ${response.statusCode()}") + } + } catch (exception: Exception) { - throw GradleException("Failed to upload plugin: ${exception.message}", exception) + throw GradleException("Failed to update until for plugin: ${exception.message}", exception) - } - } + } + } + } }) } Index: buildSrc/src/main/kotlin/util.kt =================================================================== --- buildSrc/src/main/kotlin/util.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ buildSrc/src/main/kotlin/util.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -24,39 +24,36 @@ import java.nio.charset.Charset import org.gradle.api.Project import org.gradle.api.tasks.TaskContainer +import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.util.PatternFilterable -import org.gradle.kotlin.dsl.RegisteringDomainObjectDelegateProviderWithTypeAndAction import org.gradle.kotlin.dsl.getValue import org.gradle.kotlin.dsl.provideDelegate +import org.gradle.kotlin.dsl.register import org.gradle.kotlin.dsl.registering -typealias TaskDelegate = RegisteringDomainObjectDelegateProviderWithTypeAndAction - -fun Project.lexer(flex: String, pack: String): TaskDelegate { +fun Project.lexer(name: String, flex: String, pack: String): TaskProvider { extensions.configure("license") { exclude(pack.removeSuffix("/") + "/**") } - return tasks.registering(JFlexExec::class) { + return tasks.register(name, JFlexExec::class) { sourceFile.set(layout.projectDirectory.file("src/main/grammars/$flex.flex")) destinationDirectory.set(layout.buildDirectory.dir("gen/$pack/lexer")) destinationFile.set(layout.buildDirectory.file("gen/$pack/lexer/$flex.java")) logFile.set(layout.buildDirectory.file("logs/generate$flex.log")) - val jflex by project.configurations - this.jflex.setFrom(jflex) + this.jflex.setFrom(project.configurations.named("jflex")) - val jflexSkeleton by project.configurations - skeletonFile.set(jflexSkeleton.singleFile) + skeletonFile.set(project.configurations.named("jflexSkeleton").get().singleFile) } } -fun Project.parser(bnf: String, pack: String): TaskDelegate { +fun Project.parser(name: String, bnf: String, pack: String): TaskProvider { extensions.configure("license") { exclude(pack.removeSuffix("/") + "/**") } - return tasks.registering(ParserExec::class) { + return tasks.register(name, ParserExec::class) { val destRoot = project.layout.buildDirectory.dir("gen") val dest = destRoot.map { it.dir(pack) } sourceFile.set(project.layout.projectDirectory.file("src/main/grammars/$bnf.bnf")) @@ -65,8 +62,7 @@ parserDirectory.set(dest.map { it.dir("parser") }) logFile.set(layout.buildDirectory.file("logs/generate$bnf.log")) - val grammarKit by project.configurations - this.grammarKit.setFrom(grammarKit) + this.grammarKit.setFrom(project.configurations.named("grammarKit")) } } Index: gradle.properties =================================================================== --- gradle.properties (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ gradle.properties (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -25,9 +25,6 @@ coreVersion = 1.9.1 -# Silences a build-time warning because we are bundling our own kotlin library -kotlin.stdlib.default.dependency = false - org.gradle.parallel=true org.gradle.caching=true Index: gradle/libs.versions.toml =================================================================== --- gradle/libs.versions.toml (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ gradle/libs.versions.toml (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -5,11 +5,10 @@ coroutines = "1.10.2" junit = "6.0.3" asm = "9.9.1" -fuel = "2.3.1" licenser = "0.7.5" changelog = "2.5.0" # https://github.com/JetBrains/intellij-platform-gradle-plugin -intellij-plugin = "2.13.1" +intellij-plugin = "2.20.1" # https://central.sonatype.com/artifact/org.jetbrains.intellij/plugin-repository-rest-client intellij-plugin-repository-rest-client = "2.0.50" # https://www.jetbrains.com/intellij-repository/snapshots or https://www.jetbrains.com/intellij-repository/releases/ @@ -62,9 +61,6 @@ gson = "com.google.code.gson:gson:2.13.2" jspecify = "org.jspecify:jspecify:1.0.0" -fuel = { module = "com.github.kittinunf.fuel:fuel", version.ref = "fuel" } -fuel-coroutines = { module = "com.github.kittinunf.fuel:fuel-coroutines", version.ref = "fuel" } - sentry = "io.sentry:sentry:8.37.1" # Testing @@ -85,4 +81,3 @@ [bundles] coroutines = ["coroutines-swing"] asm = ["asm", "asm-tree", "asm-analysis", "asm-util"] -fuel = ["fuel", "fuel-coroutines"] Index: src/main/kotlin/creator/ParchmentVersion.kt =================================================================== --- src/main/kotlin/creator/ParchmentVersion.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/creator/ParchmentVersion.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -3,7 +3,7 @@ * * https://mcdev.io/ * - * Copyright (C) 2025 minecraft-dev + * Copyright (C) 2026 minecraft-dev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published @@ -21,9 +21,8 @@ package com.demonwav.mcdev.creator import com.demonwav.mcdev.util.SemanticVersion -import com.github.kittinunf.fuel.core.FuelError -import com.github.kittinunf.fuel.core.HttpException import com.intellij.openapi.diagnostic.logger +import com.intellij.util.io.HttpRequests class ParchmentVersion private constructor(val mcVersion: SemanticVersion, val parchmentVersion: String) { @@ -46,7 +45,7 @@ return versions.subList(0, limit) } } catch (e: Exception) { - if (e !is FuelError || e.exception !is HttpException) { + if (e !is HttpRequests.HttpStatusException) { LOGGER.error("Failed to retrieve Parchment version data from $url", e) } } @@ -69,7 +68,7 @@ } .toList() } catch (e: Exception) { - if (e is FuelError && e.exception is HttpException) { + if (e is HttpRequests.HttpStatusException) { return null } LOGGER.error("Failed to list supported Parchment Minecraft versions", e) Index: src/main/kotlin/creator/creator-utils.kt =================================================================== --- src/main/kotlin/creator/creator-utils.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/creator/creator-utils.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -25,26 +25,8 @@ import com.intellij.openapi.application.ModalityState import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.progress.ProgressManager -import com.intellij.util.net.JdkProxyProvider -import java.io.IOException -import java.net.Proxy -import java.net.URI import javax.swing.JComponent -fun selectProxy(urlText: String): Proxy? { - val uri = URI(urlText) - val url = uri.toURL() - - val proxies = JdkProxyProvider.getInstance().proxySelector.select(uri) - for (proxy in proxies) { - try { - url.openConnection(proxy) - return proxy - } catch (_: IOException) {} - } - return null -} - val WizardContext.contentPanel: JComponent? get() = this.getUserData(AbstractWizard.KEY)?.contentPanel Index: src/main/kotlin/creator/custom/CreatorCredentials.kt =================================================================== --- src/main/kotlin/creator/custom/CreatorCredentials.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/creator/custom/CreatorCredentials.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -22,8 +22,6 @@ import com.demonwav.mcdev.MinecraftSettings import com.demonwav.mcdev.creator.custom.providers.RemoteTemplateProvider.RemoteAuthType -import com.github.kittinunf.fuel.core.Request -import com.github.kittinunf.fuel.core.extensions.authentication import com.intellij.collaboration.auth.ServerAccount import com.intellij.collaboration.auth.findAccountOrNull import com.intellij.credentialStore.CredentialAttributes @@ -33,6 +31,7 @@ import git4idea.remote.GitHttpAuthDataProvider import git4idea.remote.hosting.http.SilentHostedGitHttpAuthDataProvider import java.nio.file.Path +import java.util.Base64 import java.util.Properties import javax.xml.parsers.DocumentBuilderFactory import javax.xml.xpath.XPathFactory @@ -64,21 +63,29 @@ return PasswordSafe.instance[credAttrs] } - suspend fun configureAuthorization( - request: Request, + fun basicAuthHeader(username: String, password: String): Map { + val token = Base64.getEncoder().encodeToString("$username:$password".toByteArray(Charsets.UTF_8)) + return mapOf("Authorization" to "Basic $token") + } + + /** + * Returns the headers that authenticate a request to [url] with the given [authType], or an empty map if no + * credentials are stored for it. + */ + suspend fun authorizationHeaders( url: String, authType: RemoteAuthType, credentials: String - ): Request { + ): Map { when (authType) { - RemoteAuthType.NONE -> return request + RemoteAuthType.NONE -> return emptyMap() RemoteAuthType.BASIC -> { val creds = getCredentials(url, credentials) val username = creds?.userName val password = creds?.getPasswordAsString() if (username != null && password != null) { - return request.authentication().basic(username, password) + return basicAuthHeader(username, password) } } @@ -86,14 +93,14 @@ val creds = getCredentials(url, null) val password = creds?.getPasswordAsString() if (password != null) { - return request.authentication().bearer(password) + return mapOf("Authorization" to "Bearer $password") } } RemoteAuthType.GIT_HTTP -> { val creds = findGitHttpAuthBearerToken(credentials) if (creds != null) { - return request.authentication().basic(creds.first, creds.second) + return basicAuthHeader(creds.first, creds.second) } } @@ -102,12 +109,12 @@ val username = creds?.userName val password = creds?.getPasswordAsString() if (username != null && password != null) { - return request.header(username, password) + return mapOf(username to password) } } } - return request + return emptyMap() } fun getGitHttpAuthProviders(): List> { @@ -139,6 +146,11 @@ return account.name to token } + fun findMavenRepoAuthHeaders(url: String): Map { + val (username, password) = findMavenRepoCredentials(url) ?: return emptyMap() + return basicAuthHeader(username, password) + } + fun findMavenRepoCredentials(url: String): Pair? { val repoData = MinecraftSettings.instance.creatorMavenRepos.find { url.startsWith(it.url) } if (repoData == null) { Index: src/main/kotlin/creator/custom/npm/NpmEnvironment.kt =================================================================== --- src/main/kotlin/creator/custom/npm/NpmEnvironment.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/creator/custom/npm/NpmEnvironment.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -21,7 +21,7 @@ package com.demonwav.mcdev.creator.custom.npm import com.demonwav.mcdev.asset.MCDevBundle -import com.demonwav.mcdev.update.PluginUtil +import com.demonwav.mcdev.util.McdevHttp import com.intellij.ide.util.PropertiesComponent import com.intellij.execution.ExecutionException import com.intellij.execution.configurations.GeneralCommandLine @@ -37,7 +37,6 @@ import com.intellij.openapi.util.io.NioFiles import com.intellij.openapi.util.text.StringUtil import com.intellij.util.io.DigestUtil -import com.intellij.util.io.HttpRequests import com.intellij.util.io.URLUtil import java.nio.file.Files import java.nio.file.Path @@ -110,9 +109,7 @@ fun fetchPackageInfo(source: NpmPackageSource.Registry): NpmPackageInfo { // Scoped packages need their slash to be escaped val url = "$REGISTRY_URL/${source.name.replace("/", "%2F")}/${URLUtil.encodeURIComponent(source.version)}" - val text = HttpRequests.request(url) - .userAgent(PluginUtil.useragent) - .readString() + val text = McdevHttp.request(url).readString() val json = Json.parseToJsonElement(text).jsonObject fun JsonElement?.personName(): String? = when (this) { Index: src/main/kotlin/creator/custom/providers/RemoteTemplateProvider.kt =================================================================== --- src/main/kotlin/creator/custom/providers/RemoteTemplateProvider.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/creator/custom/providers/RemoteTemplateProvider.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -26,14 +26,9 @@ import com.demonwav.mcdev.creator.custom.CreatorCredentials import com.demonwav.mcdev.creator.custom.TemplateDescriptor import com.demonwav.mcdev.creator.modalityState -import com.demonwav.mcdev.creator.selectProxy -import com.demonwav.mcdev.update.PluginUtil import com.demonwav.mcdev.util.capitalize +import com.demonwav.mcdev.util.McdevHttp import com.demonwav.mcdev.util.refreshSync -import com.github.kittinunf.fuel.core.FuelManager -import com.github.kittinunf.fuel.coroutines.awaitByteArrayResult -import com.github.kittinunf.result.getOrNull -import com.github.kittinunf.result.onError import com.intellij.ide.util.projectWizard.WizardContext import com.intellij.openapi.application.PathManager import com.intellij.openapi.diagnostic.ControlFlowException @@ -67,6 +62,7 @@ import kotlin.io.path.absolutePathString import kotlin.io.path.exists import kotlin.io.path.writeBytes +import kotlinx.coroutines.CancellationException open class RemoteTemplateProvider : TemplateProvider { @@ -102,23 +98,26 @@ val repoUrl = replaceVariables(originalRepoUrl, TemplateDescriptor.LATEST_FORMAT_VERSION) - val manager = FuelManager() - manager.proxy = selectProxy(repoUrl) - var request = manager.get(repoUrl) - .header("Accept", "application/octet-stream") - .header("User-Agent", PluginUtil.useragent) - .timeout(10000) + val headers = if (credentials.isNotBlank()) { + CreatorCredentials.authorizationHeaders(originalRepoUrl, authType, credentials) + } else { + emptyMap() + } - if (credentials.isNotBlank()) { - request = CreatorCredentials.configureAuthorization(request, originalRepoUrl, authType, credentials) + val data = try { + McdevHttp.get(repoUrl, headers) { + accept("application/octet-stream").connectTimeout(10000) + }.connect { + readBytes(null) - } + } + } catch (e: Exception) { + if (e is CancellationException || e is ControlFlowException) { + throw e + } + thisLogger().warn("Could not fetch remote templates repository update at $repoUrl", e) + return false + } - val result = request.awaitByteArrayResult() - - val data = result.onError { - thisLogger().warn("Could not fetch remote templates repository update at $repoUrl", it) - }.getOrNull() ?: return false - try { val zipPath = RemoteTemplateRepo.getDestinationZip(repoName) zipPath.parent.createDirectories() Index: src/main/kotlin/creator/custom/types/GradlePluginSelectorCreatorProperty.kt =================================================================== --- src/main/kotlin/creator/custom/types/GradlePluginSelectorCreatorProperty.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/creator/custom/types/GradlePluginSelectorCreatorProperty.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -31,8 +31,6 @@ import com.demonwav.mcdev.util.SemanticVersion import com.demonwav.mcdev.util.getOrLogException import com.demonwav.mcdev.util.toBooleanLenient -import com.github.kittinunf.fuel.core.Request -import com.github.kittinunf.fuel.core.extensions.authentication import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.observable.properties.GraphProperty import com.intellij.openapi.observable.util.transform @@ -45,7 +43,6 @@ import com.intellij.ui.dsl.builder.bindText import com.intellij.util.ui.AsyncProcessIcon import java.util.concurrent.ConcurrentHashMap -import java.util.function.Function import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -207,11 +204,8 @@ val scope = context.childScope("GradlePluginSelectorCreatorProperty") scope.launch(Dispatchers.Default) { val result = withContext(Dispatchers.IO) { - val requestCustomizer = CreatorCredentials.findMavenRepoCredentials(url)?.let { (user, pass) -> - Function { request -> request.authentication().basic(user, pass) } - } - - runCatching { collectMavenVersions(url, requestCustomizer) } + val headers = CreatorCredentials.findMavenRepoAuthHeaders(url) + runCatching { collectMavenVersions(url, headers) } }.map { result -> val versions = result.asSequence() .filter(rawVersionFilter) Index: src/main/kotlin/creator/custom/types/MavenArtifactVersionCreatorProperty.kt =================================================================== --- src/main/kotlin/creator/custom/types/MavenArtifactVersionCreatorProperty.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/creator/custom/types/MavenArtifactVersionCreatorProperty.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -28,8 +28,6 @@ import com.demonwav.mcdev.creator.custom.TemplateValidationReporter import com.demonwav.mcdev.util.SemanticVersion import com.demonwav.mcdev.util.getOrLogException -import com.github.kittinunf.fuel.core.Request -import com.github.kittinunf.fuel.core.extensions.authentication import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.observable.properties.GraphProperty import com.intellij.ui.ComboboxSpeedSearch @@ -39,7 +37,6 @@ import com.intellij.ui.dsl.builder.bindText import com.intellij.util.ui.AsyncProcessIcon import java.util.concurrent.ConcurrentHashMap -import java.util.function.Function import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -160,11 +157,8 @@ val scope = context.childScope("MavenArtifactVersionCreatorProperty") scope.launch(Dispatchers.Default) { val result = withContext(Dispatchers.IO) { - val requestCustomizer = CreatorCredentials.findMavenRepoCredentials(url)?.let { (user, pass) -> - Function { request -> request.authentication().basic(user, pass) } - } - - runCatching { collectMavenVersions(url, requestCustomizer) } + val headers = CreatorCredentials.findMavenRepoAuthHeaders(url) + runCatching { collectMavenVersions(url, headers) } }.map { result -> val versions = result.asSequence() .filter(rawVersionFilter) Index: src/main/kotlin/creator/custom/types/PaperVersionCreatorProperty.kt =================================================================== --- src/main/kotlin/creator/custom/types/PaperVersionCreatorProperty.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/creator/custom/types/PaperVersionCreatorProperty.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -3,7 +3,7 @@ * * https://mcdev.io/ * - * Copyright (C) 2025 minecraft-dev + * Copyright (C) 2026 minecraft-dev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published @@ -26,17 +26,14 @@ import com.demonwav.mcdev.update.PluginUtil import com.demonwav.mcdev.util.MinecraftVersions import com.demonwav.mcdev.util.SemanticVersion +import com.demonwav.mcdev.util.McdevHttp import com.intellij.ui.ComboboxSpeedSearch import com.intellij.ui.JBColor import com.intellij.ui.dsl.builder.Panel import com.intellij.ui.dsl.builder.bindItem import com.intellij.ui.dsl.builder.bindText +import com.intellij.util.io.HttpRequests import com.intellij.util.ui.AsyncProcessIcon -import io.ktor.client.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -49,19 +46,21 @@ context: CreatorContext ) : SemanticVersionCreatorProperty(descriptor, context) { - @OptIn(DelicateCoroutinesApi::class) companion object { private var paperVersions: List? = null suspend fun getPaperVersions(): List { paperVersions?.let { return it } - val client = HttpClient() - val response = client.request("https://fill.papermc.io/v3/projects/paper", block = { - this.header("User-Agent", "minecraft-dev/${PluginUtil.pluginVersion} (https://github.com/minecraft-dev/MinecraftDev)") - }) - if (response.status.isSuccess()) { - val element = Json.parseToJsonElement(response.bodyAsText()) + val body = try { + McdevHttp.get("https://fill.papermc.io/v3/projects/paper") { + userAgent("minecraft-dev/${PluginUtil.pluginVersion} (https://github.com/minecraft-dev/MinecraftDev)") + }.suspend().connect { readString() } + } catch (_: HttpRequests.HttpStatusException) { + null + } + if (body != null) { + val element = Json.parseToJsonElement(body) val result = element.jsonObject["versions"]?.jsonObject?.values ?.asSequence() ?.flatMap { it.jsonArray } Index: src/main/kotlin/creator/maven-repo-utils.kt =================================================================== --- src/main/kotlin/creator/maven-repo-utils.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/creator/maven-repo-utils.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -3,7 +3,7 @@ * * https://mcdev.io/ * - * Copyright (C) 2025 minecraft-dev + * Copyright (C) 2026 minecraft-dev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published @@ -20,12 +20,8 @@ package com.demonwav.mcdev.creator -import com.demonwav.mcdev.update.PluginUtil -import com.github.kittinunf.fuel.core.FuelManager -import com.github.kittinunf.fuel.core.Request -import com.github.kittinunf.fuel.core.requests.suspendable +import com.demonwav.mcdev.util.McdevHttp import java.io.IOException -import java.util.function.Function import java.util.function.Predicate import javax.xml.stream.XMLInputFactory import javax.xml.stream.events.XMLEvent @@ -33,21 +29,11 @@ @Throws(IOException::class) suspend fun collectMavenVersions( url: String, - requestCustomizer: Function? = null, + headers: Map = emptyMap(), filter: Predicate = Predicate { true } -): List { - val manager = FuelManager() - manager.proxy = selectProxy(url) - - val response = manager.get(url) - .header("User-Agent", PluginUtil.useragent) - .allowRedirects(true) - .let { requestCustomizer?.apply(it) ?: it } - .suspendable() - .await() - +): List = McdevHttp.get(url, headers).suspend().connect { val result = mutableListOf() - response.body().toStream().use { stream -> + inputStream.use { stream -> val inputFactory = XMLInputFactory.newInstance() @Suppress("UNCHECKED_CAST") @@ -74,22 +60,13 @@ } } - return result + result } @Throws(IOException::class) -suspend fun scrapeArtifactoryDirectoryListing(url: String): List { - val manager = FuelManager() - manager.proxy = selectProxy(url) - - val response = manager.get(url) - .header("User-Agent", PluginUtil.useragent) - .allowRedirects(true) - .suspendable() - .await() - +suspend fun scrapeArtifactoryDirectoryListing(url: String): List = McdevHttp.get(url).suspend().connect { val result = mutableListOf() - response.body().toStream().use { stream -> + inputStream.use { stream -> val inputFactory = XMLInputFactory.newInstance() @Suppress("UNCHECKED_CAST") @@ -116,5 +93,5 @@ } } - return result + result } Index: src/main/kotlin/platform/architectury/ArchitecturyVersion.kt =================================================================== --- src/main/kotlin/platform/architectury/ArchitecturyVersion.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/platform/architectury/ArchitecturyVersion.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -3,7 +3,7 @@ * * https://mcdev.io/ * - * Copyright (C) 2025 minecraft-dev + * Copyright (C) 2026 minecraft-dev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published @@ -20,13 +20,9 @@ package com.demonwav.mcdev.platform.architectury -import com.demonwav.mcdev.creator.selectProxy -import com.demonwav.mcdev.update.PluginUtil import com.demonwav.mcdev.util.SemanticVersion import com.demonwav.mcdev.util.fromJson -import com.github.kittinunf.fuel.core.FuelManager -import com.github.kittinunf.fuel.core.requests.suspendable -import com.github.kittinunf.fuel.coroutines.awaitString +import com.demonwav.mcdev.util.McdevHttp import com.google.gson.Gson import com.google.gson.annotations.SerializedName @@ -50,14 +46,8 @@ suspend fun downloadData(): ArchitecturyVersion? { try { val url = "https://api.modrinth.com/v2/project/architectury-api/version" - val manager = FuelManager() - manager.proxy = selectProxy(url) + val response = McdevHttp.getString(url) - val response = manager.get(url) - .header("User-Agent", PluginUtil.useragent) - .suspendable() - .awaitString() - val data = Gson().fromJson>(response) val apiVersionMap = HashMap>() Index: src/main/kotlin/platform/fabric/util/FabricApiVersions.kt =================================================================== --- src/main/kotlin/platform/fabric/util/FabricApiVersions.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/platform/fabric/util/FabricApiVersions.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -3,7 +3,7 @@ * * https://mcdev.io/ * - * Copyright (C) 2025 minecraft-dev + * Copyright (C) 2026 minecraft-dev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published @@ -20,11 +20,8 @@ package com.demonwav.mcdev.platform.fabric.util -import com.demonwav.mcdev.creator.selectProxy -import com.demonwav.mcdev.update.PluginUtil import com.demonwav.mcdev.util.SemanticVersion -import com.github.kittinunf.fuel.core.FuelManager -import com.github.kittinunf.fuel.core.requests.suspendable +import com.demonwav.mcdev.util.McdevHttp import com.google.gson.JsonParser import com.google.gson.JsonSyntaxException import com.intellij.openapi.diagnostic.logger @@ -39,39 +36,31 @@ suspend fun downloadData(): FabricApiVersions? { try { val url = "https://api.modrinth.com/v2/project/P7dR8mSH/version" - val manager = FuelManager() - manager.proxy = selectProxy(url) + val text = McdevHttp.getString(url) - val response = manager.get(url) - .header("User-Agent", PluginUtil.useragent) - .suspendable() - .await() - val versions = mutableListOf() - response.body().toStream().use { stream -> - val json = JsonParser.parseReader(stream.reader())?.asJsonArray ?: return null + val json = JsonParser.parseString(text)?.asJsonArray ?: return null - versionLoop@ - for (ver in json) { - val version = ver?.asJsonObject ?: return null - val files = version["files"]?.asJsonArray ?: return null - for (file in files) { - val fileObj = file?.asJsonObject ?: return null - val filename = fileObj["filename"]?.asString ?: return null - if (!filename.startsWith("fabric-api-")) { - continue@versionLoop - } - } - val versionNumber = version["version_number"]?.asString?.let(SemanticVersion::tryParse) - ?: return null - val gameVersions = version["game_versions"]?.asJsonArray ?: return null - val gameVersionsList = mutableListOf() - for (gameVer in gameVersions) { - val gameVersion = gameVer?.asString ?: return null - gameVersionsList += gameVersion - } - versions += Version(gameVersionsList, versionNumber) - } + versionLoop@ + for (ver in json) { + val version = ver?.asJsonObject ?: return null + val files = version["files"]?.asJsonArray ?: return null + for (file in files) { + val fileObj = file?.asJsonObject ?: return null + val filename = fileObj["filename"]?.asString ?: return null + if (!filename.startsWith("fabric-api-")) { + continue@versionLoop + } + } + val versionNumber = version["version_number"]?.asString?.let(SemanticVersion::tryParse) + ?: return null + val gameVersions = version["game_versions"]?.asJsonArray ?: return null + val gameVersionsList = mutableListOf() + for (gameVer in gameVersions) { + val gameVersion = gameVer?.asString ?: return null + gameVersionsList += gameVersion + } + versions += Version(gameVersionsList, versionNumber) + } - } return FabricApiVersions(versions) } catch (e: IOException) { LOGGER.error(e) Index: src/main/kotlin/platform/fabric/util/FabricVersions.kt =================================================================== --- src/main/kotlin/platform/fabric/util/FabricVersions.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/platform/fabric/util/FabricVersions.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -3,7 +3,7 @@ * * https://mcdev.io/ * - * Copyright (C) 2025 minecraft-dev + * Copyright (C) 2026 minecraft-dev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published @@ -21,11 +21,8 @@ package com.demonwav.mcdev.platform.fabric.util import com.demonwav.mcdev.creator.custom.model.TemplateApi -import com.demonwav.mcdev.creator.selectProxy -import com.demonwav.mcdev.update.PluginUtil import com.demonwav.mcdev.util.SemanticVersion -import com.github.kittinunf.fuel.core.FuelManager -import com.github.kittinunf.fuel.core.requests.suspendable +import com.demonwav.mcdev.util.McdevHttp import com.google.gson.JsonParser import com.google.gson.JsonSyntaxException import com.intellij.openapi.diagnostic.logger @@ -47,44 +44,36 @@ suspend fun downloadData(): FabricVersions? { try { val url = "https://meta.fabricmc.net/v2/versions" - val manager = FuelManager() - manager.proxy = selectProxy(url) + val text = McdevHttp.getString(url) - val response = manager.get(url) - .header("User-Agent", PluginUtil.useragent) - .suspendable() - .await() - val gameList = mutableListOf() val mappingsList = mutableListOf() val loaderList = mutableListOf() - response.body().toStream().use { stream -> - val json = JsonParser.parseReader(stream.reader())?.asJsonObject ?: return null + val json = JsonParser.parseString(text)?.asJsonObject ?: return null - val game = json["game"]?.asJsonArray ?: return null - for (version in game) { - val versionObj = version?.asJsonObject ?: return null - val gameVer = versionObj["version"]?.asString ?: return null - val stable = versionObj["stable"]?.asBoolean ?: return null - gameList += Game(gameVer, stable) - } + val game = json["game"]?.asJsonArray ?: return null + for (version in game) { + val versionObj = version?.asJsonObject ?: return null + val gameVer = versionObj["version"]?.asString ?: return null + val stable = versionObj["stable"]?.asBoolean ?: return null + gameList += Game(gameVer, stable) + } - val mappings = json["mappings"]?.asJsonArray ?: return null - for (mapping in mappings) { - val mappingObj = mapping?.asJsonObject ?: return null - val gameVersion = mappingObj["gameVersion"]?.asString ?: return null - val version = mappingObj["version"]?.asString ?: return null - val build = mappingObj["build"]?.asInt ?: return null - mappingsList += Mappings(gameVersion, YarnVersion(version, build)) - } + val mappings = json["mappings"]?.asJsonArray ?: return null + for (mapping in mappings) { + val mappingObj = mapping?.asJsonObject ?: return null + val gameVersion = mappingObj["gameVersion"]?.asString ?: return null + val version = mappingObj["version"]?.asString ?: return null + val build = mappingObj["build"]?.asInt ?: return null + mappingsList += Mappings(gameVersion, YarnVersion(version, build)) + } - val loaders = json["loader"]?.asJsonArray ?: return null - for (loader in loaders) { - val loaderObj = loader?.asJsonObject ?: return null - val version = loaderObj["version"]?.asString?.let(SemanticVersion::tryParse) ?: return null - loaderList += version - } + val loaders = json["loader"]?.asJsonArray ?: return null + for (loader in loaders) { + val loaderObj = loader?.asJsonObject ?: return null + val version = loaderObj["version"]?.asString?.let(SemanticVersion::tryParse) ?: return null + loaderList += version + } - } return FabricVersions(gameList, mappingsList, loaderList) } catch (e: IOException) { LOGGER.error(e) Index: src/main/kotlin/update/OutdatedIdeChecker.kt =================================================================== --- src/main/kotlin/update/OutdatedIdeChecker.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/update/OutdatedIdeChecker.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -21,6 +21,7 @@ package com.demonwav.mcdev.update import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.util.McdevHttp import com.google.gson.JsonParser import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType @@ -30,7 +31,6 @@ import com.intellij.openapi.startup.ProjectActivity import com.intellij.openapi.util.BuildNumber import com.intellij.openapi.util.registry.Registry -import com.intellij.util.io.HttpRequests import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -75,9 +75,7 @@ } private fun fetchLatestReleaseBuild(): BuildNumber? { - val text = HttpRequests.request(RELEASES_URL) - .userAgent(PluginUtil.useragent) - .readString() + val text = McdevHttp.request(RELEASES_URL).readString() val build = JsonParser.parseString(text).asJsonObject .getAsJsonArray(PRODUCT_CODE) ?.firstOrNull() Index: src/main/kotlin/update/PluginUpdater.kt =================================================================== --- src/main/kotlin/update/PluginUpdater.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/update/PluginUpdater.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -20,6 +20,7 @@ package com.demonwav.mcdev.update +import com.demonwav.mcdev.util.McdevHttp import com.demonwav.mcdev.util.McdevReflection import com.demonwav.mcdev.util.forEachNotNull import com.demonwav.mcdev.util.invokeLater @@ -37,7 +38,6 @@ import com.intellij.openapi.updateSettings.impl.PluginDownloader import com.intellij.openapi.util.JDOMUtil import com.intellij.openapi.util.SystemInfo -import com.intellij.util.io.HttpRequests import java.io.IOException import java.net.URLEncoder import org.jdom.Element @@ -72,7 +72,7 @@ "https://plugins.jetbrains.com/plugins/list?pluginId=8327&build=" + "$buildNumber&pluginVersion=$currentVersion&os=$os" - val responseDoc = HttpRequests.request(url).connect { request -> + val responseDoc = McdevHttp.request(url).connect { request -> try { return@connect JDOMUtil.load(request.inputStream) } catch (e: JDOMException) { Index: src/main/kotlin/util/HttpConnectionFactory.kt =================================================================== --- src/main/kotlin/util/HttpConnectionFactory.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) +++ src/main/kotlin/util/HttpConnectionFactory.kt (revision 47c71a2b70e78f5bb104fb31869c125a422b7533) @@ -1,33 +0,0 @@ -/* - * Minecraft Development for IntelliJ - * - * https://mcdev.io/ - * - * Copyright (C) 2025 minecraft-dev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published - * by the Free Software Foundation, version 3.0 only. - * - * 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 Lesser General Public License - * along with this program. If not, see . - */ - -package com.demonwav.mcdev.util - -import com.intellij.util.net.HttpConnectionUtils -import java.net.HttpURLConnection -import java.net.URI - -sealed class HttpConnectionFactory { - open fun openHttpConnection(url: String) = URI.create(url).toURL().openConnection() as HttpURLConnection -} - -object ProxyHttpConnectionFactory : HttpConnectionFactory() { - override fun openHttpConnection(url: String) = HttpConnectionUtils.openHttpConnection(url) -} Index: src/main/kotlin/util/McdevHttp.kt =================================================================== --- src/main/kotlin/util/McdevHttp.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) +++ src/main/kotlin/util/McdevHttp.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -0,0 +1,121 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * 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 Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.util + +import com.demonwav.mcdev.update.PluginUtil +import com.intellij.util.io.HttpRequests +import com.intellij.util.io.RequestBuilder +import java.net.URI +import java.net.URISyntaxException +import kotlin.collections.iterator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * HTTP util. + * + * When the [MOCK_PROPERTY] system property is set to the base URL of a mock server (for example + * `http://127.0.0.1:54321`), [resolve] rewrites `https://host/path?query` to `/host/path?query` so tests can serve + * recorded responses. + */ +object McdevHttp { + const val MOCK_PROPERTY = "mcdev.test.httpMock" + + val mockBaseUrl: String? + get() = System.getProperty(MOCK_PROPERTY)?.trim()?.trimEnd('/')?.takeIf { it.isNotEmpty() } + + fun resolve(url: String): String { + val base = mockBaseUrl ?: return url + return rewrite(url, base) + } + + internal fun rewrite(url: String, base: String): String { + val uri = try { + URI(url) + } catch (_: URISyntaxException) { + return url + } + val scheme = uri.scheme?.lowercase() + if (scheme != "http" && scheme != "https") { + return url + } + val host = uri.host ?: return url + + val authority = if (uri.port == -1) host else "$host:${uri.port}" + val path = uri.rawPath.orEmpty() + val query = uri.rawQuery?.let { "?$it" }.orEmpty() + return "$base/$authority$path$query" + } + + fun request(url: String, headers: Map = emptyMap()): RequestBuilder { + val resolved = resolve(url) + val builder = HttpRequests.request(resolved).userAgent(PluginUtil.useragent) + if (resolved != url) { + builder.useProxy(false) + } + if (headers.isNotEmpty()) { + val host = try { + URI(resolved).host + } catch (_: URISyntaxException) { + null + } + // HttpRequests runs the tuner again for every redirect it follows + builder.tuner { connection -> + if (connection.url.host.equals(host, ignoreCase = true)) { + for ((name, value) in headers) { + connection.setRequestProperty(name, value) + } + } + } + } + return builder + } + + /** + * Convenience version of [request]. + */ + fun get( + url: String, + headers: Map = emptyMap(), + configure: RequestBuilder.() -> Unit = {}, + ): Request { + val request = request(url, headers) + request.configure() + return Request(request) + } + + suspend fun getString(url: String, headers: Map = emptyMap()): String = + get(url, headers).suspend().connect { readString() } + + class Request(private val builder: RequestBuilder) { + fun connect(read: HttpRequests.Request.() -> T): T { + return builder.connect { conn -> conn.read() } + } + + fun suspend(): SuspendingRequest = SuspendingRequest(this) + } + + class SuspendingRequest(private val request: Request) { + suspend fun connect(read: HttpRequests.Request.() -> T): T = withContext(Dispatchers.IO) { + request.connect(read) + } + } +} Index: src/test/kotlin/util/HttpCallSiteGuardTest.kt =================================================================== --- src/test/kotlin/util/HttpCallSiteGuardTest.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) +++ src/test/kotlin/util/HttpCallSiteGuardTest.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -0,0 +1,118 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * 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 Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.util + +import java.nio.file.Path +import kotlin.io.path.extension +import kotlin.io.path.invariantSeparatorsPathString +import kotlin.io.path.isDirectory +import kotlin.io.path.readLines +import kotlin.io.path.relativeTo +import kotlin.io.path.walk +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * Fails when main sources create an HTTP client or connection without going through [McdevHttp]. Requests that skip it + * can't be redirected to the mock server, so tests would hit the real network. + */ +@DisplayName("HTTP Call Site Guard Tests") +class HttpCallSiteGuardTest { + + private class Rule(val name: String, val regex: Regex) + + private class Allowed(val pathPrefix: String, val rules: Set?, val reason: String) { + fun matches(path: String, rule: Rule) = + (path == pathPrefix || path.startsWith(pathPrefix.trimEnd('/') + "/")) && (rules == null || rule.name in rules) + } + + data class Violation(val path: String, val line: Int, val rule: String, val text: String) { + override fun toString() = "$path:$line [$rule] $text" + } + + private val rules = listOf( + Rule("HttpRequests", Regex("""\bHttpRequests\s*\.\s*(request|post|put|patch|delete|head)\s*\(""")), + // Ktor ships with the platform, so it's on the classpath even though the plugin doesn't use it + Rule("Ktor HttpClient", Regex("""\bHttpClient\s*[({]""")), + Rule("java.net.http HttpClient", Regex("""\bHttpClient\s*\.\s*new(HttpClient|Builder)\s*\(""")), + Rule("HttpConnectionUtils", Regex("""\bHttpConnectionUtils\s*\.""")), + Rule("openConnection", Regex("""\.openConnection\s*\(""")), + ) + + private val allowed = listOf( + Allowed("util/McdevHttp.kt", null, "The routing helper itself."), + ) + + private fun scan(path: String, lines: List, allowed: List): List { + val violations = mutableListOf() + for ((index, line) in lines.withIndex()) { + val trimmed = line.trimStart() + if (trimmed.startsWith("import ") || trimmed.startsWith("//") || trimmed.startsWith("*") || + trimmed.startsWith("/*") + ) { + continue + } + for (rule in rules) { + if (rule.regex.containsMatchIn(line) && allowed.none { it.matches(path, rule) }) { + violations += Violation(path, index + 1, rule.name, trimmed) + } + } + } + return violations + } + + private fun scanMainSources(allowed: List): List { + val root = Path.of("src", "main", "kotlin").toAbsolutePath() + assertTrue(root.isDirectory(), "Main sources not found at $root, run tests from the project directory") + return root.walk() + .filter { it.extension == "kt" } + .sortedBy { it.invariantSeparatorsPathString } + .flatMap { file -> scan(file.relativeTo(root).invariantSeparatorsPathString, file.readLines(), allowed) } + .toList() + } + + @Test + @DisplayName("Main sources only make HTTP requests through McdevHttp") + fun noDirectHttpClients() { + val violations = scanMainSources(allowed) + assertTrue(violations.isEmpty()) { + "Found HTTP clients created outside McdevHttp. Use McdevHttp so requests can be redirected to the test " + + "mock server, or add an allowlist entry with a reason in HttpCallSiteGuardTest:\n" + + violations.joinToString("\n") + } + } + + @Test + @DisplayName("Every allowlist entry is still needed") + fun allowlistIsUsed() { + val unused = allowed.filter { entry -> + scanMainSources(allowed - entry).none { violation -> + val rule = rules.first { it.name == violation.rule } + entry.matches(violation.path, rule) + } + } + assertTrue(unused.isEmpty()) { + "Allowlist entries no longer match anything, remove them:\n" + + unused.joinToString("\n") { "${it.pathPrefix}: ${it.reason}" } + } + } +} Index: src/test/kotlin/util/McdevHttpTest.kt =================================================================== --- src/test/kotlin/util/McdevHttpTest.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) +++ src/test/kotlin/util/McdevHttpTest.kt (revision 8536a62f8cc9bd7b459e673aed12dcba2659749c) @@ -0,0 +1,194 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * 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 Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.util + +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.sun.net.httpserver.HttpServer +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.Collections +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("McdevHttp Tests") +class McdevHttpTest { + + private val base = "http://127.0.0.1:54321" + + @AfterEach + fun clearProperty() { + System.clearProperty(McdevHttp.MOCK_PROPERTY) + } + + @Test + @DisplayName("URLs are unchanged when the property is unset") + fun unsetLeavesUrlsAlone() { + assertNull(McdevHttp.mockBaseUrl) + val url = "https://meta.fabricmc.net/v2/versions" + assertEquals(url, McdevHttp.resolve(url)) + } + + @Test + @DisplayName("Blank property counts as unset") + fun blankPropertyIsUnset() { + System.setProperty(McdevHttp.MOCK_PROPERTY, " ") + assertNull(McdevHttp.mockBaseUrl) + } + + @Test + @DisplayName("Host becomes the first path segment") + fun rewritesHost() { + assertEquals( + "$base/meta.fabricmc.net/v2/versions", + McdevHttp.rewrite("https://meta.fabricmc.net/v2/versions", base), + ) + assertEquals("$base/example.com", McdevHttp.rewrite("https://example.com", base)) + assertEquals("$base/example.com/", McdevHttp.rewrite("https://example.com/", base)) + } + + @Test + @DisplayName("Query, encoding and port are kept") + fun keepsQueryEncodingAndPort() { + assertEquals( + "$base/registry.npmjs.org/@fabricmc%2Fcli/latest", + McdevHttp.rewrite("https://registry.npmjs.org/@fabricmc%2Fcli/latest", base), + ) + assertEquals( + "$base/example.com:8443/a%20b?x=1&y=%2F", + McdevHttp.rewrite("https://example.com:8443/a%20b?x=1&y=%2F#frag", base), + ) + } + + @Test + @DisplayName("Non-HTTP and malformed URLs are unchanged") + fun leavesOtherUrlsAlone() { + for (url in listOf("file:///tmp/foo.zip", "jar:file:///tmp/foo.zip!/", "not a url", "/relative/path")) { + assertEquals(url, McdevHttp.rewrite(url, base)) + } + } + + @Test + @DisplayName("Trailing slash on the base URL is ignored") + fun trailingSlash() { + System.setProperty(McdevHttp.MOCK_PROPERTY, "$base/") + val expected = "$base/example.com/x" + assertEquals(expected, McdevHttp.resolve("https://example.com/x")) + } + + @Test + @DisplayName("Requests reach a local server through the rewrite") + fun requestsReachLocalServer() { + // The default user agent reads the plugin version, which needs the IDE application + val fixture = IdeaTestFixtureFactory.getFixtureFactory().createBareFixture() + fixture.setUp() + val seen = Collections.synchronizedList(mutableListOf>()) + val server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0) + server.createContext("/") { exchange -> + seen += mapOf( + "uri" to exchange.requestURI.toString(), + "user-agent" to exchange.requestHeaders.getFirst("User-Agent"), + "authorization" to exchange.requestHeaders.getFirst("Authorization"), + ) + val body = "ok".toByteArray() + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + server.start() + try { + System.setProperty(McdevHttp.MOCK_PROPERTY, "http://127.0.0.1:${server.address.port}") + + val blocking = McdevHttp.request("https://registry.invalid/@scope%2Fname/latest").readString() + val suspending = runBlocking { + McdevHttp.get("https://maven.invalid/a/maven-metadata.xml?x=1", mapOf("Authorization" to "Basic abc")).suspend().connect { + readString() + } + } + + assertEquals(listOf("ok", "ok"), listOf(blocking, suspending)) + assertEquals( + listOf("/registry.invalid/@scope%2Fname/latest", "/maven.invalid/a/maven-metadata.xml?x=1"), + seen.map { it["uri"] }, + ) + assertEquals(listOf(null, "Basic abc"), seen.map { it["authorization"] }) + assertTrue(seen.all { it["user-agent"]?.startsWith("github_org/minecraft-dev/") == true }) { + "Unexpected user agents: ${seen.map { it["user-agent"] }}" + } + } finally { + server.stop(0) + fixture.tearDown() + } + } + + @Test + @DisplayName("Extra headers are only sent to the original host") + fun headersStayOnOriginalHost() { + val fixture = IdeaTestFixtureFactory.getFixtureFactory().createBareFixture() + fixture.setUp() + + val auth = Collections.synchronizedMap(mutableMapOf()) + fun HttpServer.respond(path: String, status: Int, location: String? = null) { + createContext(path) { exchange -> + auth[exchange.requestURI.path] = exchange.requestHeaders.getFirst("Authorization") + location?.let { exchange.responseHeaders.add("Location", it) } + val body = if (status == 200) "ok".toByteArray() else ByteArray(0) + exchange.sendResponseHeaders(status, if (body.isEmpty()) -1 else body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + } + + // Two hosts that are both this machine, under different names + val original = HttpServer.create(InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0), 0) + val other = HttpServer.create(InetSocketAddress(InetAddress.getByName("localhost"), 0), 0) + val otherBase = "http://localhost:${other.address.port}" + original.respond("/example.invalid/same", 302, "/example.invalid/final") + original.respond("/example.invalid/final", 200) + original.respond("/example.invalid/away", 302, "$otherBase/elsewhere") + other.respond("/elsewhere", 200) + original.start() + other.start() + try { + System.setProperty(McdevHttp.MOCK_PROPERTY, "http://127.0.0.1:${original.address.port}") + val headers = mapOf("Authorization" to "Basic abc") + + assertEquals("ok", McdevHttp.request("https://example.invalid/same", headers).readString()) + assertEquals("ok", McdevHttp.request("https://example.invalid/away", headers).readString()) + + assertEquals( + mapOf( + "/example.invalid/same" to "Basic abc", + "/example.invalid/final" to "Basic abc", + "/example.invalid/away" to "Basic abc", + "/elsewhere" to null, + ), + auth.toMap(), + ) + } finally { + original.stop(0) + other.stop(0) + fixture.tearDown() + } + } +}