Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix update checker promoting downgrades, other followup improvements #716

Merged
merged 3 commits into from
Jun 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ public class DescriptionListWidget extends EntryListWidget<DescriptionListWidget

private static final Text HAS_UPDATE_TEXT = Text.translatable("modmenu.hasUpdate");
private static final Text EXPERIMENTAL_TEXT = Text.translatable("modmenu.experimental").formatted(Formatting.GOLD);
private static final Text MODRINTH_TEXT = Text.translatable("modmenu.modrinth");
private static final Text DOWNLOAD_TEXT = Text.translatable("modmenu.downloadLink").formatted(Formatting.BLUE).formatted(Formatting.UNDERLINE);
private static final Text CHILD_HAS_UPDATE_TEXT = Text.translatable("modmenu.childHasUpdate");
private static final Text LINKS_TEXT = Text.translatable("modmenu.links");
Expand Down Expand Up @@ -114,30 +113,20 @@ public void renderList(DrawContext DrawContext, int mouseX, int mouseY, float de
children().add(new DescriptionEntry(line, 8));
}

if (updateInfo instanceof ModrinthUpdateInfo modrinthUpdateInfo) {
Text updateText = Text.translatable("modmenu.updateText", VersionUtil.stripPrefix(modrinthUpdateInfo.getVersionNumber()), MODRINTH_TEXT)
.formatted(Formatting.BLUE)
.formatted(Formatting.UNDERLINE);

for (OrderedText line : textRenderer.wrapLines(updateText, wrapWidth - 16)) {
children().add(new LinkEntry(line, modrinthUpdateInfo.getDownloadLink(), 8));
}
Text updateMessage = updateInfo.getUpdateMessage();
String downloadLink = updateInfo.getDownloadLink();
if (updateMessage == null) {
updateMessage = DOWNLOAD_TEXT;
} else {
Text updateMessage = updateInfo.getUpdateMessage();
String downloadLink = updateInfo.getDownloadLink();
if (updateMessage == null) {
updateMessage = DOWNLOAD_TEXT;
} else {
if (downloadLink != null) {
updateMessage = updateMessage.copy().formatted(Formatting.BLUE).formatted(Formatting.UNDERLINE);
}
if (downloadLink != null) {
updateMessage = updateMessage.copy().formatted(Formatting.BLUE).formatted(Formatting.UNDERLINE);
}
for (OrderedText line : textRenderer.wrapLines(updateMessage, wrapWidth - 16)) {
if (downloadLink != null) {
children().add(new LinkEntry(line, downloadLink, 8));
} else {
children().add(new DescriptionEntry(line, 8));
}
}
for (OrderedText line : textRenderer.wrapLines(updateMessage, wrapWidth - 16)) {
if (downloadLink != null) {
children().add(new LinkEntry(line, downloadLink, 8));
} else {
children().add(new DescriptionEntry(line, 8));
}
}
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.terraformersmc.modmenu.util;

import org.jetbrains.annotations.NotNull;

import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;

public class UpdateCheckerThreadFactory implements ThreadFactory {
static final AtomicInteger COUNT = new AtomicInteger(-1);

@Override
public Thread newThread(@NotNull Runnable r) {
var index = COUNT.incrementAndGet();
return Thread.ofVirtual().name("ModMenu/Update Checker/%s".formatted(index)).unstarted(r);
}
}
228 changes: 181 additions & 47 deletions src/main/java/com/terraformersmc/modmenu/util/UpdateCheckerUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,29 @@
import com.terraformersmc.modmenu.ModMenu;
import com.terraformersmc.modmenu.api.UpdateChannel;
import com.terraformersmc.modmenu.api.UpdateChecker;
import com.terraformersmc.modmenu.api.UpdateInfo;
import com.terraformersmc.modmenu.config.ModMenuConfig;
import com.terraformersmc.modmenu.util.mod.Mod;
import com.terraformersmc.modmenu.util.mod.ModrinthUpdateInfo;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.SharedConstants;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.toast.SystemToast;
import net.minecraft.text.Text;
import net.minecraft.util.Util;

import net.minecraft.util.Util;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;

public class UpdateCheckerUtil {
public static final Logger LOGGER = LoggerFactory.getLogger("Mod Menu/Update Checker");
Expand All @@ -40,53 +45,180 @@ public static void checkForUpdates() {
}

LOGGER.info("Checking mod updates...");
Util.getMainWorkerExecutor().execute(UpdateCheckerUtil::checkForModrinthUpdates);
checkForCustomUpdates();
Util.getMainWorkerExecutor().execute(UpdateCheckerUtil::checkForUpdates0);
}

public static void checkForCustomUpdates() {
ModMenu.MODS.values().stream().filter(UpdateCheckerUtil::allowsUpdateChecks).forEach(mod -> {
UpdateChecker updateChecker = mod.getUpdateChecker();
private static void checkForUpdates0() {
try (var executor = Executors.newThreadPerTaskExecutor(new UpdateCheckerThreadFactory())) {
List<Mod> withoutUpdateChecker = new ArrayList<>();

ModMenu.MODS.values().stream().filter(UpdateCheckerUtil::allowsUpdateChecks).forEach(mod -> {
UpdateChecker updateChecker = mod.getUpdateChecker();

if (updateChecker == null) {
withoutUpdateChecker.add(mod); // Fall back to update checking via Modrinth
} else {
executor.submit(() -> {
// We don't know which mod the thread is for yet in the thread factory
Thread.currentThread().setName("ModMenu/Update Checker/%s".formatted(mod.getName()));

var update = updateChecker.checkForUpdates();

if (update == null) {
return;
}

mod.setUpdateInfo(update);
LOGGER.info("Update available for '{}@{}'", mod.getId(), mod.getVersion());
});
}
});

if (modrinthApiV2Deprecated) {
return;
}

var modHashes = getModHashes(withoutUpdateChecker);

var currentVersionsFuture = executor.submit(() -> getCurrentVersions(modHashes.keySet()));
var updatedVersionsFuture = executor.submit(() -> getUpdatedVersions(modHashes.keySet()));

Map<String, Instant> currentVersions = null;
Map<String, VersionUpdate> updatedVersions = null;

try {
currentVersions = currentVersionsFuture.get();
updatedVersions = updatedVersionsFuture.get();
} catch (ExecutionException e) {
throw new RuntimeException(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}

if (updateChecker == null) {
if (currentVersions == null || updatedVersions == null) {
return;
}

UpdateCheckerThread.run(mod, () -> {
var update = updateChecker.checkForUpdates();
for (var hash : modHashes.keySet()) {
var date = currentVersions.get(hash);
var data = updatedVersions.get(hash);

if (update == null) {
return;
if (date == null || data == null) {
continue;
}

mod.setUpdateInfo(update);
LOGGER.info("Update available for '{}@{}'", mod.getId(), mod.getVersion());
});
});
}
// Current version is still the newest
if (Objects.equals(hash, data.hash)) {
continue;
}

public static void checkForModrinthUpdates() {
if (modrinthApiV2Deprecated) {
return;
// Current version is newer than what's
// Available on our preferred update channel
if (date.compareTo(data.releaseDate) >= 0) {
continue;
}

for (var mod : modHashes.get(hash)) {
mod.setUpdateInfo(data.asUpdateInfo());
LOGGER.info("Update available for '{}@{}', (-> {})", mod.getId(), mod.getVersion(), data.versionNumber);
}
}
}
}

Map<String, Set<Mod>> modHashes = new HashMap<>();
new ArrayList<>(ModMenu.MODS.values()).stream().filter(UpdateCheckerUtil::allowsUpdateChecks).filter(mod -> mod.getUpdateChecker() == null).forEach(mod -> {
private static Map<String, Set<Mod>> getModHashes(Collection<Mod> mods) {
Map<String, Set<Mod>> results = new HashMap<>();

for (var mod : mods) {
String modId = mod.getId();

try {
String hash = mod.getSha512Hash();

if (hash != null) {
LOGGER.debug("Hash for {} is {}", modId, hash);
modHashes.putIfAbsent(hash, new HashSet<>());
modHashes.get(hash).add(mod);
results.putIfAbsent(hash, new HashSet<>());
results.get(hash).add(mod);
}
} catch (IOException e) {
LOGGER.error("Error getting mod hash for mod {}: ", modId, e);
}
});
};

return results;
}

public static void triggerV2DeprecatedToast() {
if (modrinthApiV2Deprecated && ModMenuConfig.UPDATE_CHECKER.getValue()) {
MinecraftClient.getInstance().getToastManager().add(new SystemToast(
SystemToast.Type.PERIODIC_NOTIFICATION,
Text.translatable("modmenu.modrinth.v2_deprecated.title"),
Text.translatable("modmenu.modrinth.v2_deprecated.description")
));
}
}

/**
* @return a map of file hash to its release date on Modrinth.
*/
private static @Nullable Map<String, Instant> getCurrentVersions(Collection<String> modHashes) {
String body = ModMenu.GSON_MINIFIED.toJson(new CurrentVersionsFromHashes(modHashes));

var request = HttpRequest.newBuilder()
.POST(HttpRequest.BodyPublishers.ofString(body))
.header("Content-Type", "application/json")
.uri(URI.create("https://api.modrinth.com/v2/version_files"));

try {
var response = HttpUtil.request(request, HttpResponse.BodyHandlers.ofString());

if (response.statusCode() == 410) {
modrinthApiV2Deprecated = true;
LOGGER.warn("Modrinth API v2 is deprecated, unable to check for mod updates.");
} else if (response.statusCode() == 200) {
Map<String, Instant> results = new HashMap<>();
JsonObject data = JsonParser.parseString(response.body()).getAsJsonObject();

data.asMap().forEach((hash, inner) -> {
Instant date;
var version = inner.getAsJsonObject();

try {
date = Instant.parse(version.get("date_published").getAsString());
} catch (DateTimeParseException e) {
return;
}

results.put(hash, date);
});

return results;
}
} catch (IOException | InterruptedException e) {
LOGGER.error("Error checking for versions: ", e);
}

return null;
}

public static class CurrentVersionsFromHashes {
public Collection<String> hashes;
public String algorithm = "sha512";

public CurrentVersionsFromHashes(Collection<String> hashes) {
this.hashes = hashes;
}
}

private static UpdateChannel getUpdateChannel(String versionType) {
try {
return UpdateChannel.valueOf(versionType.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException | NullPointerException e) {
return UpdateChannel.RELEASE;
}
}

private static @Nullable Map<String, VersionUpdate> getUpdatedVersions(Collection<String> modHashes) {
String mcVer = SharedConstants.getGameVersion().getName();
List<String> loaders = ModMenu.runningQuilt ? List.of("fabric", "quilt") : List.of("fabric");

Expand All @@ -101,7 +233,7 @@ public static void checkForModrinthUpdates() {
updateChannels = List.of(UpdateChannel.ALPHA, UpdateChannel.BETA, UpdateChannel.RELEASE);
}

String body = ModMenu.GSON_MINIFIED.toJson(new LatestVersionsFromHashesBody(modHashes.keySet(), loaders, mcVer, updateChannels));
String body = ModMenu.GSON_MINIFIED.toJson(new LatestVersionsFromHashesBody(modHashes, loaders, mcVer, updateChannels));

LOGGER.debug("Body: {}", body);
var latestVersionsRequest = HttpRequest.newBuilder()
Expand All @@ -118,6 +250,7 @@ public static void checkForModrinthUpdates() {
modrinthApiV2Deprecated = true;
LOGGER.warn("Modrinth API v2 is deprecated, unable to check for mod updates.");
} else if (status == 200) {
Map<String, VersionUpdate> results = new HashMap<>();
JsonObject responseObject = JsonParser.parseString(latestVersionsResponse.body()).getAsJsonObject();
LOGGER.debug(String.valueOf(responseObject));
responseObject.asMap().forEach((lookupHash, versionJson) -> {
Expand All @@ -133,38 +266,39 @@ public static void checkForModrinthUpdates() {
return;
}

Instant date;

try {
date = Instant.parse(versionObj.get("date_published").getAsString());
} catch (DateTimeParseException e) {
return;
}

var updateChannel = UpdateCheckerUtil.getUpdateChannel(versionType);
var versionHash = primaryFile.get().getAsJsonObject().get("hashes").getAsJsonObject().get("sha512").getAsString();

if (!Objects.equals(versionHash, lookupHash)) {
// hashes different, there's an update.
modHashes.get(lookupHash).forEach(mod -> {
LOGGER.info("Update available for '{}@{}', (-> {})", mod.getId(), mod.getVersion(), versionNumber);
mod.setUpdateInfo(new ModrinthUpdateInfo(projectId, versionId, versionNumber, updateChannel));
});
}
results.put(lookupHash, new VersionUpdate(projectId, versionId, versionNumber, date, updateChannel, versionHash));
});

return results;
}
} catch (IOException | InterruptedException e) {
LOGGER.error("Error checking for updates: ", e);
}
}

private static UpdateChannel getUpdateChannel(String versionType) {
try {
return UpdateChannel.valueOf(versionType.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException | NullPointerException e) {
return UpdateChannel.RELEASE;
}
return null;
}

public static void triggerV2DeprecatedToast() {
if (modrinthApiV2Deprecated && ModMenuConfig.UPDATE_CHECKER.getValue()) {
MinecraftClient.getInstance().getToastManager().add(new SystemToast(
SystemToast.Type.PERIODIC_NOTIFICATION,
Text.translatable("modmenu.modrinth.v2_deprecated.title"),
Text.translatable("modmenu.modrinth.v2_deprecated.description")
));
private record VersionUpdate(
String projectId,
String versionId,
String versionNumber,
Instant releaseDate,
UpdateChannel updateChannel,
String hash
) {
private UpdateInfo asUpdateInfo() {
return new ModrinthUpdateInfo(this.projectId, this.versionId, this.versionNumber, this.updateChannel);
}
}

Expand Down
Loading
Loading