diff --git a/src/main/java/com/falsepattern/lib/internal/FalsePatternLib.java b/src/main/java/com/falsepattern/lib/internal/FalsePatternLib.java index 4a0ee53..2ff948b 100644 --- a/src/main/java/com/falsepattern/lib/internal/FalsePatternLib.java +++ b/src/main/java/com/falsepattern/lib/internal/FalsePatternLib.java @@ -43,7 +43,7 @@ import cpw.mods.fml.common.network.simpleimpl.SimpleNetworkWrapper; guiFactory = Tags.GROUPNAME + ".internal.config.LibraryGuiFactory", acceptableRemoteVersions = "*") public class FalsePatternLib { - public static final String UPDATE_URL = "https://falsepattern.com/mc/versions.json"; + public static final String UPDATE_URL = "https://falsepattern.com/mc/api/v2/versions.json"; public static final SimpleNetworkWrapper NETWORK = NetworkRegistry.INSTANCE.newSimpleChannel(Tags.MODID); @SidedProxy(clientSide = Tags.GROUPNAME + ".internal.proxy.ClientProxy", diff --git a/src/main/java/com/falsepattern/lib/internal/Internet.java b/src/main/java/com/falsepattern/lib/internal/Internet.java index 86a3fdb..07b900c 100644 --- a/src/main/java/com/falsepattern/lib/internal/Internet.java +++ b/src/main/java/com/falsepattern/lib/internal/Internet.java @@ -32,6 +32,8 @@ import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.file.Files; +import java.util.Collections; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicBoolean; @@ -39,13 +41,40 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; public class Internet { + private static final Map NO_HEADERS = Collections.emptyMap(); public static void connect(URL URL, Consumer onError, Consumer onSuccess) { + connect(URL, NO_HEADERS, onError, onSuccess); + } + + public static Map constructHeaders(String... entries) { + if (entries.length % 2 != 0) { + throw new IllegalArgumentException("Entries must be in pairs"); + } + val map = new java.util.HashMap(); + for (int i = 0; i < entries.length; i += 2) { + map.put(entries[i], entries[i + 1]); + } + return map; + } + + public static void connect(URL URL, Map headers, Consumer onError, Consumer onSuccess) { try { val connection = (HttpURLConnection) URL.openConnection(); connection.setConnectTimeout(3500); connection.setReadTimeout(5000); connection.setRequestProperty("User-Agent", Tags.MODNAME + " " + Tags.VERSION + " Internet Connector" + " (https://github.com/FalsePattern/FalsePatternLib)"); + for (val header: headers.entrySet()) { + val key = header.getKey(); + val value = header.getValue(); + if (key == null || value == null) { + throw new IllegalArgumentException("Null key or value"); + } + if (key.isEmpty()) { + throw new IllegalArgumentException("Empty key"); + } + connection.setRequestProperty(key, value); + } if (connection.getResponseCode() != 200) { onError.accept(new Exception("HTTP response code " + connection.getResponseCode())); } else { diff --git a/src/main/java/com/falsepattern/lib/internal/impl/updates/UpdateCheckerImpl.java b/src/main/java/com/falsepattern/lib/internal/impl/updates/UpdateCheckerImpl.java index e5d65b2..f7f4754 100644 --- a/src/main/java/com/falsepattern/lib/internal/impl/updates/UpdateCheckerImpl.java +++ b/src/main/java/com/falsepattern/lib/internal/impl/updates/UpdateCheckerImpl.java @@ -28,14 +28,17 @@ import com.falsepattern.lib.updates.ModUpdateInfo; import com.falsepattern.lib.updates.UpdateCheckException; import com.google.gson.JsonArray; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.google.gson.JsonParser; import lombok.val; +import lombok.var; import net.minecraft.client.resources.I18n; import net.minecraft.event.ClickEvent; import net.minecraft.util.IChatComponent; import cpw.mods.fml.common.Loader; +import java.io.InputStreamReader; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; @@ -43,11 +46,9 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; public final class UpdateCheckerImpl { - private static final AtomicBoolean jsonLibraryLoaded = new AtomicBoolean(false); - public static List updateListToChatMessages(String initiator, List updates) { if (updates == null || updates.size() == 0) { return null; @@ -84,32 +85,37 @@ public final class UpdateCheckerImpl { return updateText; } + private static JsonArray fetchRootListShared(String url) { + if (!LibraryConfig.ENABLE_UPDATE_CHECKER) { + throw new CompletionException(new UpdateCheckException("Update checker is disabled in config!")); + } + URL URL; + try { + URL = new URL(url); + } catch (MalformedURLException e) { + throw new CompletionException(new UpdateCheckException("Invalid URL: " + url, e)); + } + JsonElement parsed; + try { + parsed = new JsonParser().parse(Internet.download(URL).thenApply(String::new).join()); + } catch (CompletionException e) { + throw new CompletionException(new UpdateCheckException("Failed to download update checker JSON file!", + e.getCause() == null ? e : e.getCause())); + } + JsonArray modList; + if (parsed.isJsonArray()) { + modList = parsed.getAsJsonArray(); + } else { + modList = new JsonArray(); + modList.add(parsed); + } + return modList; + } + public static CompletableFuture> fetchUpdatesAsync(String url) { return CompletableFuture.supplyAsync(() -> { - if (!LibraryConfig.ENABLE_UPDATE_CHECKER) { - throw new CompletionException(new UpdateCheckException("Update checker is disabled in config!")); - } - URL URL; - try { - URL = new URL(url); - } catch (MalformedURLException e) { - throw new CompletionException(new UpdateCheckException("Invalid URL: " + url, e)); - } + val modList = fetchRootListShared(url); val result = new ArrayList(); - JsonElement parsed; - try { - parsed = new JsonParser().parse(Internet.download(URL).thenApply(String::new).join()); - } catch (CompletionException e) { - throw new CompletionException(new UpdateCheckException("Failed to download update checker JSON file!", - e.getCause() == null ? e : e.getCause())); - } - JsonArray modList; - if (parsed.isJsonArray()) { - modList = parsed.getAsJsonArray(); - } else { - modList = new JsonArray(); - modList.add(parsed); - } val installedMods = Loader.instance().getIndexedModList(); for (val node : modList) { if (!node.isJsonObject()) { @@ -170,4 +176,159 @@ public final class UpdateCheckerImpl { } } } + + public static CompletableFuture> fetchUpdatesAsyncV2(String url) { + return CompletableFuture.supplyAsync(() -> { + val modList = fetchRootListShared(url); + val result = new ArrayList(); + val installedMods = Loader.instance().getIndexedModList(); + for (val node : modList) { + if (!node.isJsonObject()) { + continue; + } + val obj = node.getAsJsonObject(); + if (!obj.has("modid")) { + continue; + } + if (!obj.has("versions")) { + continue; + } + val modid = obj.get("modid").getAsString(); + if (!installedMods.containsKey(modid)) { + continue; + } + val mod = installedMods.get(modid); + var versions = obj.get("versions"); + if (!versions.isJsonArray()) { + if (!versions.isJsonObject()) { + continue; + } + val ver = versions.getAsJsonObject(); + versions = new JsonArray(); + versions.getAsJsonArray().add(ver); + } + val parsedVersions = parseVersionSpecs(versions.getAsJsonArray()).join(); + val latestVersions = new ArrayList(); + for (val version : parsedVersions) { + val versionObj = version.getAsJsonObject(); + latestVersions.add(versionObj.get("version").getAsString()); + } + val currentVersion = mod.getVersion(); + if (latestVersions.contains(currentVersion)) { + continue; + } + val latest = parsedVersions.get(0).getAsJsonObject(); + result.add(new ModUpdateInfo(modid, currentVersion, latest.get("version").getAsString(), latest.get("url").getAsString())); + } + return result; + }); + } + + private static String getString(JsonObject object, String entry) { + if (!object.has(entry)) { + return null; + } + val element = object.get(entry); + if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + return element.getAsString(); + } + return null; + } + + private static CompletableFuture parseVersionSpecs(JsonArray versions) { + return CompletableFuture.supplyAsync(() -> { + val result = new JsonArray(); + for (val entry: versions) { + if (!entry.isJsonObject()) { + continue; + } + val obj = entry.getAsJsonObject(); + val type = getString(obj, "type"); + if (type == null) { + continue; + } + switch (type) { + case "raw": { + val version = getString(obj, "version"); + val url = getString(obj, "updateURL"); + if (version == null || url == null) { + continue; + } + val res = new JsonObject(); + res.addProperty("version", version); + res.addProperty("url", url); + result.add(res); + } + case "github": { + val repo = getString(obj, "repo"); + if (repo == null) { + continue; + } + JsonObject response; + try { + response = parseLatestGithubVersion(repo); + } catch (MalformedURLException e) { + e.printStackTrace(); + continue; + } + if (response == null) { + continue; + } + result.add(response); + } + } + } + return result; + }); + } + + private static JsonObject parseLatestGithubVersion(String repo) throws MalformedURLException { + val parts = repo.split("/"); + if (parts.length != 2) { + return null; + } + val owner = parts[0]; + val name = parts[1]; + val response = new AtomicReference(); + val result = new JsonObject(); + Internet.connect(new URL("https://api.github.com/repos/" + owner + "/" + name + "/releases/latest"), + Internet.constructHeaders("Accept", "application/vnd.github+json", + "X-GitHub-Api-Version", "2022-11-28"), + (e) -> {throw new CompletionException(e); + }, + (stream) -> { + val parser = new JsonParser(); + val json = parser.parse(new InputStreamReader(stream)); + if (!json.isJsonObject()) { + return; + } + response.set(json.getAsJsonObject()); + }); + if (response.get() == null) { + return null; + } + val payload = response.get(); + val url = getString(payload, "html_url"); + val version = getString(payload, "tag_name"); + if (version == null) { + return null; + } + result.addProperty("version", version); + result.addProperty("url", url == null ? "" : url); + return result; + } + + public static List fetchUpdatesV2(String url) throws UpdateCheckException { + try { + return fetchUpdatesAsyncV2(url).join(); + } catch (CompletionException e) { + try { + throw e.getCause(); + } catch (UpdateCheckException e1) { + throw e1; + } catch (Throwable e1) { + throw new UpdateCheckException("Failed to check for updates!", e1); + } + } + } } diff --git a/src/main/java/com/falsepattern/lib/internal/proxy/CommonProxy.java b/src/main/java/com/falsepattern/lib/internal/proxy/CommonProxy.java index 53e4d6c..3520e58 100644 --- a/src/main/java/com/falsepattern/lib/internal/proxy/CommonProxy.java +++ b/src/main/java/com/falsepattern/lib/internal/proxy/CommonProxy.java @@ -58,7 +58,7 @@ public class CommonProxy { CommonEventHandlerPre.registerBus(); if (LibraryConfig.ENABLE_UPDATE_CHECKER) { Share.LOG.info("Launching asynchronous update check."); - updatesFuture = UpdateChecker.fetchUpdatesAsync(FalsePatternLib.UPDATE_URL).thenApplyAsync(updates -> { + updatesFuture = UpdateChecker.fetchUpdatesAsyncV2(FalsePatternLib.UPDATE_URL).thenApplyAsync(updates -> { if (updates == null) { updates = Collections.emptyList(); } diff --git a/src/main/java/com/falsepattern/lib/updates/UpdateChecker.java b/src/main/java/com/falsepattern/lib/updates/UpdateChecker.java index a12c1ea..73e20e6 100644 --- a/src/main/java/com/falsepattern/lib/updates/UpdateChecker.java +++ b/src/main/java/com/falsepattern/lib/updates/UpdateChecker.java @@ -31,36 +31,81 @@ import java.util.concurrent.CompletableFuture; @StableAPI(since = "0.8.0") public final class UpdateChecker { /** - * Checks for updates. The URL should be a JSON file that contains a list of mods, each with a mod ID, one or more - * versions, and a URL for the user to check for updates in case the current and latest versions are different. - * The JSON file must have the following format: - *
{@code
-     *  [
-     *      {
-     *          "modID": "modid",
-     *          "latestVersion": ["1.0.0", "1.0.0-foo"],
-     *          "updateURL": "https://example.com/mods/mymod"
-     *      },
-     *      {
-     *          "modID": "modid2",
-     *          "latestVersion": ["0.2.0", "0.3.0-alpha"],
-     *          "updateURL": "https://example.com/mods/mymod2"
-     *      },
-     *      ...etc, one json object per mod.
-     *  ]
-     * }
- * - * @param url The URL to check - * - * @return A list of mods that were both available on the URL and installed + * DEPRECATED: Use the V2 API instead. This is kept for backwards compatibility. */ @StableAPI.Expose + @Deprecated public static CompletableFuture> fetchUpdatesAsync(String url) { return UpdateCheckerImpl.fetchUpdatesAsync(url); } /** - * Same this as {@link #fetchUpdatesAsync(String)}, but returns the result in a blocking fashion. + * Checks for updates. The URL should be a JSON file that contains a list of mods, each with a mod ID, and one or more + * versions. The JSON file must have the following format: + *
{@code
+     *  [
+     *      {
+     *          "modid": "modid",
+     *          "versions": [
+     *              {
+     *                  ...version object...
+     *              },
+     *              {
+     *                  ...version object...
+     *              }
+     *          ]
+     *      },
+     *      {
+     *          "modid": "modid2",
+     *          "versions": {
+     *              ...version object...
+     *          },
+     *          "updateURL": "https://example.com/mods/mymod2"
+     *      },
+     *      ...etc, one json object per mod.
+     *  ]
+     * }
+ * The currently supported version object formats are: + *

+ * Raw version entry: + *

{@code
+     * {
+     *     "type": "raw",
+     *     "version": "1.0.0",
+     *     "updateURL": "https://example.com/mods/mymod"
+     * }
+     * }
+ * + * GitHub version entry: + *
{@code
+     * {
+     *    "type": "github",
+     *    "repo": "Example/ExampleMod"
+     * }
+     * }
+ * + * The GitHub version entry will automatically fetch the latest release from the specified repository using the GitHub API. + * + * @param url The URL to check + * + * @return A list of mods that were both available on the URL and installed + */ + @StableAPI.Expose(since = "0.11.0") + public static CompletableFuture> fetchUpdatesAsyncV2(String url) { + return UpdateCheckerImpl.fetchUpdatesAsyncV2(url); + } + + /** + * DEPRECATED: Use the V2 API instead. This is kept for backwards compatibility. + */ + @StableAPI.Expose + @Deprecated + public static List fetchUpdates(String url) throws UpdateCheckException { + return UpdateCheckerImpl.fetchUpdates(url); + } + + /** + * Same this as {@link #fetchUpdatesAsyncV2(String)}, but returns the result in a blocking fashion. * * @param url The URL to check * @@ -68,9 +113,9 @@ public final class UpdateChecker { * * @throws UpdateCheckException If the update checker is disabled in config, the URL is invalid, or */ - @StableAPI.Expose - public static List fetchUpdates(String url) throws UpdateCheckException { - return UpdateCheckerImpl.fetchUpdates(url); + @StableAPI.Expose(since = "0.11.0") + public static List fetchUpdatesV2(String url) throws UpdateCheckException { + return UpdateCheckerImpl.fetchUpdatesV2(url); } /**