diff --git a/src/main/java/com/terraformersmc/modmenu/ModMenu.java b/src/main/java/com/terraformersmc/modmenu/ModMenu.java index 770b7ab0..3ff02381 100644 --- a/src/main/java/com/terraformersmc/modmenu/ModMenu.java +++ b/src/main/java/com/terraformersmc/modmenu/ModMenu.java @@ -70,6 +70,8 @@ public void onInitializeClient() { ModMenuConfigManager.initializeConfig(); Set modpackMods = new HashSet<>(); Map updateCheckers = new HashMap<>(); + Map providedUpdateCheckers = new HashMap<>(); + FabricLoader.getInstance().getEntrypointContainers("modmenu", ModMenuApi.class).forEach(entrypoint -> { ModMetadata metadata = entrypoint.getProvider().getMetadata(); String modId = metadata.getId(); @@ -78,6 +80,7 @@ public void onInitializeClient() { configScreenFactories.put(modId, api.getModConfigScreenFactory()); apiImplementations.add(api); updateCheckers.put(modId, api.getUpdateChecker()); + providedUpdateCheckers.putAll(api.getProvidedUpdateCheckers()); api.attachModpackBadges(modpackMods::add); } catch (Throwable e) { LOGGER.error("Mod {} provides a broken implementation of ModMenuApi", modId, e); @@ -94,9 +97,14 @@ public void onInitializeClient() { mod = new FabricMod(modContainer, modpackMods); } - mod.setUpdateChecker(updateCheckers.get(mod.getId())); + var updateChecker = updateCheckers.get(mod.getId()); + + if (updateChecker == null) { + updateChecker = providedUpdateCheckers.get(mod.getId()); + } MODS.put(mod.getId(), mod); + mod.setUpdateChecker(updateChecker); } checkForUpdates(); diff --git a/src/main/java/com/terraformersmc/modmenu/ModMenuModMenuCompat.java b/src/main/java/com/terraformersmc/modmenu/ModMenuModMenuCompat.java index 99c8cacf..34c4be77 100644 --- a/src/main/java/com/terraformersmc/modmenu/ModMenuModMenuCompat.java +++ b/src/main/java/com/terraformersmc/modmenu/ModMenuModMenuCompat.java @@ -1,16 +1,18 @@ package com.terraformersmc.modmenu; -import com.google.common.collect.ImmutableMap; import com.terraformersmc.modmenu.api.ConfigScreenFactory; import com.terraformersmc.modmenu.api.ModMenuApi; +import com.terraformersmc.modmenu.api.UpdateChecker; import com.terraformersmc.modmenu.gui.ModMenuOptionsScreen; +import com.terraformersmc.modmenu.util.mod.fabric.FabricLoaderUpdateChecker; +import com.terraformersmc.modmenu.util.mod.quilt.QuiltLoaderUpdateChecker; + import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.screen.option.OptionsScreen; import java.util.Map; public class ModMenuModMenuCompat implements ModMenuApi { - @Override public ConfigScreenFactory getModConfigScreenFactory() { return ModMenuOptionsScreen::new; @@ -18,6 +20,15 @@ public ConfigScreenFactory getModConfigScreenFactory() { @Override public Map> getProvidedConfigScreenFactories() { - return ImmutableMap.of("minecraft", parent -> new OptionsScreen(parent, MinecraftClient.getInstance().options)); + return Map.of("minecraft", parent -> new OptionsScreen(parent, MinecraftClient.getInstance().options)); + } + + @Override + public Map getProvidedUpdateCheckers() { + if (ModMenu.runningQuilt) { + return Map.of("quilt_loader", new QuiltLoaderUpdateChecker()); + } else { + return Map.of("fabricloader", new FabricLoaderUpdateChecker()); + } } } diff --git a/src/main/java/com/terraformersmc/modmenu/api/ModMenuApi.java b/src/main/java/com/terraformersmc/modmenu/api/ModMenuApi.java index 4a6bc27b..729209f8 100644 --- a/src/main/java/com/terraformersmc/modmenu/api/ModMenuApi.java +++ b/src/main/java/com/terraformersmc/modmenu/api/ModMenuApi.java @@ -1,6 +1,5 @@ package com.terraformersmc.modmenu.api; -import com.google.common.collect.ImmutableMap; import com.terraformersmc.modmenu.ModMenu; import com.terraformersmc.modmenu.gui.ModsScreen; import net.minecraft.client.gui.screen.Screen; @@ -66,7 +65,17 @@ default UpdateChecker getUpdateChecker() { * @return a map of mod ids to screen factories. */ default Map> getProvidedConfigScreenFactories() { - return ImmutableMap.of(); + return Map.of(); + } + + /** + * Used to provide update checkers for other mods. A mod registering its own + * update checker will take priority over any provided ones should both exist. + * + * @return a map of mod ids to update checkers. + */ + default Map getProvidedUpdateCheckers() { + return Map.of(); } /** diff --git a/src/main/java/com/terraformersmc/modmenu/util/HttpUtil.java b/src/main/java/com/terraformersmc/modmenu/util/HttpUtil.java new file mode 100644 index 00000000..bf91b600 --- /dev/null +++ b/src/main/java/com/terraformersmc/modmenu/util/HttpUtil.java @@ -0,0 +1,44 @@ +package com.terraformersmc.modmenu.util; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import com.terraformersmc.modmenu.ModMenu; + +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.SharedConstants; + +public class HttpUtil { + private static final String USER_AGENT = buildUserAgent(); + private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); + + private HttpUtil() {} + + public static HttpResponse request(HttpRequest.Builder builder, HttpResponse.BodyHandler handler) throws IOException, InterruptedException { + builder.setHeader("User-Agent", USER_AGENT); + return HTTP_CLIENT.send(builder.build(), handler); + } + + private static String buildUserAgent() { + String env = ModMenu.devEnvironment ? "/development" : ""; + String loader = ModMenu.runningQuilt ? "quilt" : "fabric"; + + var modMenuVersion = getModMenuVersion(); + var minecraftVersion = SharedConstants.getGameVersion().getName(); + + // -> TerraformersMC/ModMenu/9.1.0 (1.20.3/quilt/development) + return "%s/%s (%s/%s%s)".formatted(ModMenu.GITHUB_REF, modMenuVersion, minecraftVersion, loader, env); + } + + private static String getModMenuVersion() { + var container = FabricLoader.getInstance().getModContainer(ModMenu.MOD_ID); + + if (container.isEmpty()) { + throw new RuntimeException("Unable to find Modmenu's own mod container!"); + } + + return VersionUtil.removeBuildMetadata(container.get().getMetadata().getVersion().getFriendlyString()); + } +} diff --git a/src/main/java/com/terraformersmc/modmenu/util/JsonUtil.java b/src/main/java/com/terraformersmc/modmenu/util/JsonUtil.java new file mode 100644 index 00000000..4a5e7b2d --- /dev/null +++ b/src/main/java/com/terraformersmc/modmenu/util/JsonUtil.java @@ -0,0 +1,38 @@ +package com.terraformersmc.modmenu.util; + +import java.util.Optional; + +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +public class JsonUtil { + private JsonUtil() {} + + public static Optional getString(JsonObject parent, String field) { + if (!parent.has(field)) { + return Optional.empty(); + } + + var value = parent.get(field); + + if (!value.isJsonPrimitive() || !((JsonPrimitive)value).isString()) { + return Optional.empty(); + } + + return Optional.of(value.getAsString()); + } + + public static Optional getBoolean(JsonObject parent, String field) { + if (!parent.has(field)) { + return Optional.empty(); + } + + var value = parent.get(field); + + if (!value.isJsonPrimitive() || !((JsonPrimitive)value).isBoolean()) { + return Optional.empty(); + } + + return Optional.of(value.getAsBoolean()); + } +} diff --git a/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerUtil.java b/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerUtil.java index f08a575e..a9423ae2 100644 --- a/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerUtil.java +++ b/src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerUtil.java @@ -15,12 +15,12 @@ import net.minecraft.client.toast.SystemToast; import net.minecraft.text.Text; import net.minecraft.util.Util; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.net.URI; -import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.*; @@ -28,7 +28,6 @@ public class UpdateCheckerUtil { public static final Logger LOGGER = LoggerFactory.getLogger("Mod Menu/Update Checker"); - private static final HttpClient client = HttpClient.newHttpClient(); private static boolean modrinthApiV2Deprecated = false; private static boolean allowsUpdateChecks(Mod mod) { @@ -48,10 +47,21 @@ public static void checkForUpdates() { public static void checkForCustomUpdates() { ModMenu.MODS.values().stream().filter(UpdateCheckerUtil::allowsUpdateChecks).forEach(mod -> { UpdateChecker updateChecker = mod.getUpdateChecker(); + if (updateChecker == null) { return; } - UpdateCheckerThread.run(mod, () -> mod.setUpdateInfo(updateChecker.checkForUpdates())); + + UpdateCheckerThread.run(mod, () -> { + var update = updateChecker.checkForUpdates(); + + if (update == null) { + return; + } + + mod.setUpdateInfo(update); + LOGGER.info("Update available for '{}@{}'", mod.getId(), mod.getVersion()); + }); }); } @@ -77,15 +87,8 @@ public static void checkForModrinthUpdates() { } }); - String environment = ModMenu.devEnvironment ? "/development" : ""; - String primaryLoader = ModMenu.runningQuilt ? "quilt" : "fabric"; - List loaders = ModMenu.runningQuilt ? List.of("fabric", "quilt") : List.of("fabric"); - String mcVer = SharedConstants.getGameVersion().getName(); - String version = FabricLoader.getInstance().getModContainer(ModMenu.MOD_ID) - .get().getMetadata().getVersion().getFriendlyString(); - final var modMenuVersion = version.split("\\+", 2)[0]; // Strip build metadata for privacy - final var userAgent = "%s/%s (%s/%s%s)".formatted(ModMenu.GITHUB_REF, modMenuVersion, mcVer, primaryLoader, environment); + List loaders = ModMenu.runningQuilt ? List.of("fabric", "quilt") : List.of("fabric"); List updateChannels; UpdateChannel preferredChannel = UpdateChannel.getUserPreference(); @@ -100,20 +103,17 @@ public static void checkForModrinthUpdates() { String body = ModMenu.GSON_MINIFIED.toJson(new LatestVersionsFromHashesBody(modHashes.keySet(), loaders, mcVer, updateChannels)); - LOGGER.debug("User agent: " + userAgent); - LOGGER.debug("Body: " + body); + LOGGER.debug("Body: {}", body); var latestVersionsRequest = HttpRequest.newBuilder() .POST(HttpRequest.BodyPublishers.ofString(body)) - .header("User-Agent", userAgent) .header("Content-Type", "application/json") - .uri(URI.create("https://api.modrinth.com/v2/version_files/update")) - .build(); + .uri(URI.create("https://api.modrinth.com/v2/version_files/update")); try { - var latestVersionsResponse = client.send(latestVersionsRequest, HttpResponse.BodyHandlers.ofString()); + var latestVersionsResponse = HttpUtil.request(latestVersionsRequest, HttpResponse.BodyHandlers.ofString()); int status = latestVersionsResponse.statusCode(); - LOGGER.debug("Status: " + status); + LOGGER.debug("Status: {}", status); if (status == 410) { modrinthApiV2Deprecated = true; LOGGER.warn("Modrinth API v2 is deprecated, unable to check for mod updates."); diff --git a/src/main/java/com/terraformersmc/modmenu/util/VersionUtil.java b/src/main/java/com/terraformersmc/modmenu/util/VersionUtil.java index 4d14b228..29e1a81c 100644 --- a/src/main/java/com/terraformersmc/modmenu/util/VersionUtil.java +++ b/src/main/java/com/terraformersmc/modmenu/util/VersionUtil.java @@ -5,9 +5,7 @@ public final class VersionUtil { private static final List PREFIXES = List.of("version", "ver", "v"); - private VersionUtil() { - return; - } + private VersionUtil() {} public static String stripPrefix(String version) { version = version.trim(); @@ -24,4 +22,8 @@ public static String stripPrefix(String version) { public static String getPrefixedVersion(String version) { return "v" + stripPrefix(version); } + + public static String removeBuildMetadata(String version) { + return version.split("\\+")[0]; + } } diff --git a/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricLoaderUpdateChecker.java b/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricLoaderUpdateChecker.java new file mode 100644 index 00000000..fcaeb16e --- /dev/null +++ b/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricLoaderUpdateChecker.java @@ -0,0 +1,147 @@ +package com.terraformersmc.modmenu.util.mod.fabric; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonParser; +import com.terraformersmc.modmenu.api.UpdateChannel; +import com.terraformersmc.modmenu.api.UpdateChecker; +import com.terraformersmc.modmenu.api.UpdateInfo; +import com.terraformersmc.modmenu.util.HttpUtil; +import com.terraformersmc.modmenu.util.JsonUtil; +import com.terraformersmc.modmenu.util.OptionalUtil; + +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.SemanticVersion; +import net.fabricmc.loader.api.Version; +import net.fabricmc.loader.api.VersionParsingException; + +public class FabricLoaderUpdateChecker implements UpdateChecker { + public static final Logger LOGGER = LoggerFactory.getLogger("Mod Menu/Fabric Update Checker"); + private static final URI LOADER_VERSIONS = URI.create("https://meta.fabricmc.net/v2/versions/loader"); + + @Override + public UpdateInfo checkForUpdates() { + UpdateInfo result = null; + + try { + result = checkForUpdates0(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (IOException e) { + LOGGER.error("Failed Fabric Loader update check!", e); + } + + return result; + } + + private static UpdateInfo checkForUpdates0() throws IOException, InterruptedException { + var preferredChannel = UpdateChannel.getUserPreference(); + + var request = HttpRequest.newBuilder().GET().uri(LOADER_VERSIONS); + var response = HttpUtil.request(request, HttpResponse.BodyHandlers.ofString()); + + var status = response.statusCode(); + + if (status != 200) { + LOGGER.warn("Fabric Meta responded with a non-200 status: {}!", status); + return null; + } + + var contentType = response.headers().firstValue("Content-Type"); + + if (contentType.isEmpty() || !contentType.get().contains("application/json")) { + LOGGER.warn("Fabric Meta responded with a non-json content type, aborting loader update check!"); + return null; + } + + var data = JsonParser.parseString(response.body()); + + if (!data.isJsonArray()) { + LOGGER.warn("Received invalid data from Fabric Meta, aborting loader update check!"); + return null; + } + + SemanticVersion match = null; + boolean stableVersion = true; + + for (var child : data.getAsJsonArray()) { + if (!child.isJsonObject()) { + continue; + } + + var object = child.getAsJsonObject(); + var version = JsonUtil.getString(object, "version"); + + if (version.isEmpty()) { + continue; + } + + SemanticVersion parsed; + + try { + parsed = SemanticVersion.parse(version.get()); + } catch (VersionParsingException e) { + continue; + } + + // Why aren't betas just marked as beta in the version string ... + var stable = OptionalUtil.isPresentAndTrue(JsonUtil.getBoolean(object, "stable")); + + if (preferredChannel == UpdateChannel.RELEASE && !stable) { + continue; + } + + if (match == null || isNewer(parsed, match)) { + match = parsed; + stableVersion = stable; + } + } + + Version current = getCurrentVersion(); + + if (match == null || !isNewer(match, current)) { + LOGGER.debug("Fabric Loader is up to date."); + return null; + } + + LOGGER.debug("Fabric Loader has a matching update available!"); + return new FabricLoaderUpdateInfo(stableVersion); + } + + private static boolean isNewer(Version self, Version other) { + return self.compareTo(other) > 0; + } + + private static Version getCurrentVersion() { + return FabricLoader.getInstance().getModContainer("fabricloader").get().getMetadata().getVersion(); + } + + private static class FabricLoaderUpdateInfo implements UpdateInfo { + private final boolean isStable; + + private FabricLoaderUpdateInfo(boolean isStable) { + this.isStable = isStable; + } + + @Override + public boolean isUpdateAvailable() { + return true; + } + + @Override + public String getDownloadLink() { + return "https://fabricmc.net/use/installer"; + } + + @Override + public UpdateChannel getUpdateChannel() { + return this.isStable ? UpdateChannel.RELEASE : UpdateChannel.BETA; + } + } +} diff --git a/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricMod.java b/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricMod.java index 72da944e..2c89e9e5 100644 --- a/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricMod.java +++ b/src/main/java/com/terraformersmc/modmenu/util/mod/fabric/FabricMod.java @@ -51,7 +51,7 @@ public FabricMod(ModContainer modContainer, Set modpackMods) { this.container = modContainer; this.metadata = modContainer.getMetadata(); - if ("minecraft".equals(metadata.getId()) || "fabricloader".equals(metadata.getId()) || "java".equals(metadata.getId()) || "quilt_loader".equals(metadata.getId())) { + if ("minecraft".equals(metadata.getId()) || "java".equals(metadata.getId())) { allowsUpdateChecks = false; } diff --git a/src/main/java/com/terraformersmc/modmenu/util/mod/quilt/QuiltLoaderUpdateChecker.java b/src/main/java/com/terraformersmc/modmenu/util/mod/quilt/QuiltLoaderUpdateChecker.java new file mode 100644 index 00000000..9b5da457 --- /dev/null +++ b/src/main/java/com/terraformersmc/modmenu/util/mod/quilt/QuiltLoaderUpdateChecker.java @@ -0,0 +1,157 @@ +package com.terraformersmc.modmenu.util.mod.quilt; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import org.quiltmc.loader.api.QuiltLoader; +import org.quiltmc.loader.api.Version; +import org.quiltmc.loader.api.VersionFormatException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonParser; +import com.terraformersmc.modmenu.api.UpdateChannel; +import com.terraformersmc.modmenu.api.UpdateChecker; +import com.terraformersmc.modmenu.api.UpdateInfo; +import com.terraformersmc.modmenu.util.HttpUtil; +import com.terraformersmc.modmenu.util.JsonUtil; + +public class QuiltLoaderUpdateChecker implements UpdateChecker { + public static final Logger LOGGER = LoggerFactory.getLogger("Mod Menu/Quilt Update Checker"); + private static final URI LOADER_VERSIONS = URI.create("https://meta.quiltmc.org/v3/versions/loader"); + + @Override + public UpdateInfo checkForUpdates() { + UpdateInfo result = null; + + try { + result = checkForUpdates0(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (IOException e) { + LOGGER.error("Failed Quilt Loader update check!", e); + } + + return result; + } + + private static UpdateInfo checkForUpdates0() throws IOException, InterruptedException { + var preferredChannel = UpdateChannel.getUserPreference(); + + var request = HttpRequest.newBuilder().GET().uri(LOADER_VERSIONS); + var response = HttpUtil.request(request, HttpResponse.BodyHandlers.ofString()); + + var status = response.statusCode(); + + if (status != 200) { + LOGGER.warn("Quilt Meta responded with a non-200 status: {}!", status); + return null; + } + + var contentType = response.headers().firstValue("Content-Type"); + + if (contentType.isEmpty() || !contentType.get().contains("application/json")) { + LOGGER.warn("Quilt Meta responded with a non-json content type, aborting loader update check!"); + return null; + } + + var data = JsonParser.parseString(response.body()); + + if (!data.isJsonArray()) { + LOGGER.warn("Received invalid data from Quilt Meta, aborting loader update check!"); + return null; + } + + Version.Semantic match = null; + + for (var child : data.getAsJsonArray()) { + if (!child.isJsonObject()) { + continue; + } + + var object = child.getAsJsonObject(); + var version = JsonUtil.getString(object, "version"); + + if (version.isEmpty()) { + continue; + } + + Version.Semantic parsed; + + try { + parsed = Version.Semantic.of(version.get()); + } catch (VersionFormatException e) { + continue; + } + + if (preferredChannel == UpdateChannel.RELEASE && !parsed.preRelease().equals("")) { + continue; + } else if (preferredChannel == UpdateChannel.BETA && !isStableOrBeta(parsed.preRelease())) { + continue; + } + + if (match == null || isNewer(parsed, match)) { + match = parsed; + } + } + + Version.Semantic current = getCurrentVersion(); + + if (match == null || !isNewer(match, current)) { + LOGGER.debug("Quilt Loader is up to date."); + return null; + } + + LOGGER.debug("Quilt Loader has a matching update available!"); + + UpdateChannel updateChannel; + var preRelease = match.preRelease(); + + if (preRelease.isEmpty()) { + updateChannel = UpdateChannel.RELEASE; + } else if (isStableOrBeta(preRelease)) { + updateChannel = UpdateChannel.BETA; + } else { + updateChannel = UpdateChannel.ALPHA; + } + + return new QuiltLoaderUpdateInfo(updateChannel); + } + + private static boolean isNewer(Version.Semantic self, Version.Semantic other) { + return self.compareTo(other) > 0; + } + + private static Version.Semantic getCurrentVersion() { + return QuiltLoader.getModContainer("quilt_loader").get().metadata().version().semantic(); + } + + private static boolean isStableOrBeta(String preRelease) { + return preRelease.isEmpty() || preRelease.startsWith("beta") || preRelease.startsWith("pre") || preRelease.startsWith("rc"); + } + + private static class QuiltLoaderUpdateInfo implements UpdateInfo { + private final UpdateChannel updateChannel; + + private QuiltLoaderUpdateInfo(UpdateChannel updateChannel) { + this.updateChannel = updateChannel; + } + + @Override + public boolean isUpdateAvailable() { + return true; + } + + @Override + public String getDownloadLink() { + return "https://quiltmc.org/en/install/client"; + } + + @Override + public UpdateChannel getUpdateChannel() { + return this.updateChannel; + } + } +}