diff --git a/src/main/java/de/hysky/skyblocker/SkyblockerMod.java b/src/main/java/de/hysky/skyblocker/SkyblockerMod.java index 2cfd61a76f..47ad0f41c7 100644 --- a/src/main/java/de/hysky/skyblocker/SkyblockerMod.java +++ b/src/main/java/de/hysky/skyblocker/SkyblockerMod.java @@ -116,6 +116,7 @@ public void onInitializeClient() { SkyblockerScreen.initClass(); ProfileViewerScreen.initClass(); Tips.init(); + UpdateNotifications.init(); NEURepoManager.init(); //ImageRepoLoader.init(); ItemRepository.init(); diff --git a/src/main/java/de/hysky/skyblocker/UpdateNotifications.java b/src/main/java/de/hysky/skyblocker/UpdateNotifications.java new file mode 100644 index 0000000000..2034de2f20 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/UpdateNotifications.java @@ -0,0 +1,215 @@ +package de.hysky.skyblocker; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import org.jetbrains.annotations.VisibleForTesting; +import org.slf4j.Logger; + +import com.google.gson.JsonParser; +import com.mojang.logging.LogUtils; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.JsonOps; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import de.hysky.skyblocker.events.SkyblockEvents; +import de.hysky.skyblocker.utils.Constants; +import de.hysky.skyblocker.utils.Http; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.loader.api.SemanticVersion; +import net.fabricmc.loader.api.Version; +import net.fabricmc.loader.api.VersionParsingException; +import net.minecraft.SharedConstants; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.resource.language.I18n; +import net.minecraft.client.toast.SystemToast; +import net.minecraft.text.ClickEvent; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.StringIdentifiable; + +public class UpdateNotifications { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final MinecraftClient CLIENT = MinecraftClient.getInstance(); + private static final String BASE_URL = "https://api.modrinth.com/v2/project/y6DuFGwJ/version?loaders=[%22fabric%22]&game_versions="; + private static final Version MOD_VERSION = SkyblockerMod.SKYBLOCKER_MOD.getMetadata().getVersion(); + private static final String MC_VERSION = SharedConstants.getGameVersion().getId(); + private static final Path CONFIG_PATH = SkyblockerMod.CONFIG_DIR.resolve("update_notifications.json"); + @VisibleForTesting + protected static final Comparator COMPARATOR = Version::compareTo; + @VisibleForTesting + protected static final Codec SEM_VER_CODEC = Codec.STRING.comapFlatMap(UpdateNotifications::parseVersion, SemanticVersion::toString); + private static final SystemToast.Type TOAST_TYPE = new SystemToast.Type(10000L); + + public static Config config = Config.DEFAULT; + private static boolean sentUpdateNotification; + + static void init() { + ClientLifecycleEvents.CLIENT_STARTED.register(client -> loadConfig()); + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> saveConfig()); + SkyblockEvents.JOIN.register(() -> { + if (config.enabled() && !sentUpdateNotification) checkForNewVersion(); + }); + } + + private static void loadConfig() { + CompletableFuture.supplyAsync(() -> { + try (BufferedReader reader = Files.newBufferedReader(CONFIG_PATH)) { + return Config.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseReader(reader)).getOrThrow(); + } catch (NoSuchFileException ignored) { + } catch (Exception e) { + LOGGER.error("[Skyblocker Update Notifications] Failed to load config!", e); + } + + return Config.DEFAULT; + }).thenAccept(loadedConfig -> config = loadedConfig); + } + + private static void saveConfig() { + try (BufferedWriter writer = Files.newBufferedWriter(CONFIG_PATH)) { + SkyblockerMod.GSON.toJson(Config.CODEC.encodeStart(JsonOps.INSTANCE, config).getOrThrow(), writer); + } catch (Exception e) { + LOGGER.error("[Skyblocker Update Notifications] Failed to save config :(", e); + } + } + + private static void checkForNewVersion() { + CompletableFuture.runAsync(() -> { + try { + SemanticVersion version = (SemanticVersion) MOD_VERSION; //Would only fail because someone changed it themselves + String response = Http.sendGetRequest(BASE_URL + "[%22" + MC_VERSION + "%22]"); + List mrVersions = MrVersion.LIST_CODEC.parse(JsonOps.INSTANCE, JsonParser.parseString(response)).getOrThrow(); + + //Set it to true now so that we don't keep re-checking if the data should be discarded + sentUpdateNotification = true; + + Optional newestVersion = mrVersions.stream() + .filter(ver -> Arrays.stream(config.includedChannels()).anyMatch(channel -> channel == ver.channel())) + .filter(mrv -> COMPARATOR.compare(mrv.version(), version) > 0) + .max(Comparator.comparing(MrVersion::version, COMPARATOR)); + + if (newestVersion.isPresent() && CLIENT.player != null && !shouldDiscard(version, newestVersion.get().version())) { + MrVersion newVersion = newestVersion.get(); + String downloadLink = "https://modrinth.com/mod/skyblocker-liap/version/" + newVersion.id(); + Text versionText = Text.literal(newVersion.name()).styled(style -> style + .withFormatting(Formatting.GRAY) + .withUnderline(true) + .withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, downloadLink))); + + CLIENT.player.sendMessage(Constants.PREFIX.get().append(Text.translatable("skyblocker.updateNotifications.newUpdateMessage", versionText))); + SystemToast.add(CLIENT.getToastManager(), TOAST_TYPE, Text.translatable("skyblocker.updateNotifications.newUpdateToast.title"), Text.stringifiedTranslatable("skyblocker.updateNotifications.newUpdateToast.description", newVersion.version())); + } + } catch (Exception e) { + LOGGER.error("[Skyblocker Update Notifications] Failed to determine if an update is available or not!", e); + } + }); + } + + private static DataResult parseVersion(String version) { + String formattedVersion = switch (version) { + case String s when s.charAt(0) == 'v' -> s.substring(1); + + default -> version; + }; + + try { + return DataResult.success(SemanticVersion.parse(formattedVersion)); + } catch (VersionParsingException e) { + return DataResult.error(() -> "Failed to parse semantic version from string: " + formattedVersion); + } + } + + private static boolean isUnofficialAlphaOrBeta(SemanticVersion version) { + return switch (version.getPrereleaseKey().orElse("")) { + case String s when s.startsWith("alpha") -> s.substring(5).charAt(0) == '-'; + case String s when s.startsWith("beta") -> s.substring(4).charAt(0) == '-'; + + default -> false; + }; + } + + /** + * Since our "unofficial" betas and alphas (from actions) take after the latest release number we want to discard them from the checker + * if the current version is "unofficial" and the major, minor, and patch versions match. + */ + @VisibleForTesting + protected static boolean shouldDiscard(SemanticVersion currentVersion, SemanticVersion latestVersion) { + if (isUnofficialAlphaOrBeta(currentVersion)) { + //We will expect all 3 components to be present + + int currentMajor = currentVersion.getVersionComponent(0); + int currentMinor = currentVersion.getVersionComponent(1); + int currentPatch = currentVersion.getVersionComponent(2); + + int latestMajor = latestVersion.getVersionComponent(0); + int latestMinor = latestVersion.getVersionComponent(1); + int latestPatch = latestVersion.getVersionComponent(2); + + return currentMajor == latestMajor && currentMinor == latestMinor && currentPatch == latestPatch; + } + + return false; + } + + public record Config(boolean enabled, Channel channel) { + public static final Config DEFAULT = new Config(true, Channel.RELEASE); + private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.BOOL.fieldOf("enabled").forGetter(Config::enabled), + Channel.CODEC.fieldOf("channel").forGetter(Config::channel)) + .apply(instance, Config::new)); + + private Channel[] includedChannels() { + return switch (this.channel) { + case BETA -> new Channel[] { Channel.RELEASE, Channel.BETA }; + case ALPHA -> Channel.values(); + + default -> new Channel[] { this.channel }; + }; + } + + public Config withEnabled(boolean newEnabled) { + return new Config(newEnabled, this.channel); + } + + public Config withChannel(Channel newChannel) { + return new Config(this.enabled, newChannel); + } + } + + private record MrVersion(String id, String name, SemanticVersion version, Channel channel) { + private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.STRING.fieldOf("id").forGetter(MrVersion::id), + Codec.STRING.fieldOf("name").forGetter(MrVersion::name), + SEM_VER_CODEC.fieldOf("version_number").forGetter(MrVersion::version), + Channel.CODEC.fieldOf("version_type").forGetter(MrVersion::channel)) + .apply(instance, MrVersion::new)); + private static final Codec> LIST_CODEC = CODEC.listOf(); + } + + public enum Channel implements StringIdentifiable { + RELEASE, + BETA, + ALPHA; + + private static final Codec CODEC = StringIdentifiable.createBasicCodec(Channel::values); + + @Override + public String toString() { + return I18n.translate("skyblocker.config.general.updateChannel.channel." + name()); + } + + @Override + public String asString() { + return name().toLowerCase(); + } + } +} diff --git a/src/main/java/de/hysky/skyblocker/config/categories/GeneralCategory.java b/src/main/java/de/hysky/skyblocker/config/categories/GeneralCategory.java index ff0894321b..12a669de96 100644 --- a/src/main/java/de/hysky/skyblocker/config/categories/GeneralCategory.java +++ b/src/main/java/de/hysky/skyblocker/config/categories/GeneralCategory.java @@ -1,6 +1,7 @@ package de.hysky.skyblocker.config.categories; import de.hysky.skyblocker.SkyblockerScreen; +import de.hysky.skyblocker.UpdateNotifications; import de.hysky.skyblocker.config.ConfigUtils; import de.hysky.skyblocker.config.SkyblockerConfig; import de.hysky.skyblocker.config.configs.GeneralConfig; @@ -34,6 +35,21 @@ public static ConfigCategory create(SkyblockerConfig defaults, SkyblockerConfig newValue -> config.general.enableTips = newValue) .controller(ConfigUtils::createBooleanController) .build()) + .option(Option.createBuilder() + .name(Text.translatable("skyblocker.config.general.updateNotifications")) + .binding(UpdateNotifications.Config.DEFAULT.enabled(), + () -> UpdateNotifications.config.enabled(), + newValue -> UpdateNotifications.config = UpdateNotifications.config.withEnabled(newValue)) + .controller(ConfigUtils::createBooleanController) + .build()) + .option(Option.createBuilder() + .name(Text.translatable("skyblocker.config.general.updateChannel")) + .description(OptionDescription.of(Text.translatable("skyblocker.config.general.updateChannel.@Tooltip"))) + .binding(UpdateNotifications.Config.DEFAULT.channel(), + () -> UpdateNotifications.config.channel(), + newValue -> UpdateNotifications.config = UpdateNotifications.config.withChannel(newValue)) + .controller(ConfigUtils::createEnumCyclingListController) + .build()) .option(Option.createBuilder() .name(Text.translatable("skyblocker.config.general.acceptReparty")) .binding(defaults.general.acceptReparty, diff --git a/src/main/resources/assets/skyblocker/lang/en_us.json b/src/main/resources/assets/skyblocker/lang/en_us.json index 158b3cc98c..cbea310d04 100644 --- a/src/main/resources/assets/skyblocker/lang/en_us.json +++ b/src/main/resources/assets/skyblocker/lang/en_us.json @@ -320,6 +320,13 @@ "skyblocker.config.general.specialEffects.rareDyeDropEffects": "Rare Dye Drop Effects", "skyblocker.config.general.specialEffects.rareDyeDropEffects.@Tooltip": "Adds a special visual effect triggered upon dropping a rare dye!", + "skyblocker.config.general.updateChannel": "Update Channel", + "skyblocker.config.general.updateChannel.@Tooltip": "Choose between receiving notifications for releases, for both releases and betas, or for releases; betas; and alphas.", + "skyblocker.config.general.updateChannel.channel.ALPHA": "Alpha", + "skyblocker.config.general.updateChannel.channel.BETA": "Beta", + "skyblocker.config.general.updateChannel.channel.RELEASE": "Release", + "skyblocker.config.general.updateNotifications": "Update Notifications", + "skyblocker.config.general.wikiLookup": "Wiki Lookup", "skyblocker.config.general.wikiLookup.enableWikiLookup": "Enable Wiki Lookup", "skyblocker.config.general.wikiLookup.enableWikiLookup.@Tooltip": "Opens the wiki page of the hovered item with the F4 key.", @@ -785,6 +792,10 @@ "skyblocker.config.uiAndVisuals.waypoints.waypointType.@Tooltip": "Waypoint: Displays a highlight and a beacon beam.\n\nOutlined Waypoint: Displays both a waypoint and an outline.\n\nHighlight: Only displays a highlight.\n\nOutlined Highlight: Displays both a highlight and an outline.\n\nOutline: Only displays an outline.", "skyblocker.config.uiAndVisuals.waypoints.waypointType.generalNote": "\n\n\nThis option does not apply to all waypoints. Some waypoints such as secret waypoints have their own waypoint type option.", + "skyblocker.updateNotifications.newUpdateMessage": "There's a new Skyblocker update available! %s", + "skyblocker.updateNotifications.newUpdateToast.title": "Skyblocker Update Available!", + "skyblocker.updateNotifications.newUpdateToast.description": "Download version %s!", + "skyblocker.utils.locationUpdateError": "Failed to update your location! Some features of the mod may not work properly :(", "skyblocker.reparty.notInPartyOrNotLeader": "You must be in a party and be the leader of it to reparty!", diff --git a/src/test/java/de/hysky/skyblocker/UpdateNotificationsTest.java b/src/test/java/de/hysky/skyblocker/UpdateNotificationsTest.java new file mode 100644 index 0000000000..751ab5705b --- /dev/null +++ b/src/test/java/de/hysky/skyblocker/UpdateNotificationsTest.java @@ -0,0 +1,67 @@ +package de.hysky.skyblocker; + +import java.util.Comparator; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.JavaOps; + +import net.fabricmc.loader.api.SemanticVersion; +import net.fabricmc.loader.api.Version; +import net.minecraft.Bootstrap; +import net.minecraft.SharedConstants; + +public class UpdateNotificationsTest { + private final Comparator COMPARATOR = UpdateNotifications.COMPARATOR; + private final Codec SEM_VER_CODEC = UpdateNotifications.SEM_VER_CODEC; + private final SemanticVersion LATEST_VERSION = SEM_VER_CODEC.parse(JavaOps.INSTANCE, "1.22.0+1.21").getOrThrow(); + + @BeforeAll + public static void setupEnvironment() { + SharedConstants.createGameVersion(); + Bootstrap.initialize(); + } + + @Test + void testLatestAgainstRegular() { + SemanticVersion regular = SEM_VER_CODEC.parse(JavaOps.INSTANCE, "1.21.1+1.21").getOrThrow(); + + //Requires that the latest be newer than this normal release version + Assertions.assertTrue(COMPARATOR.compare(LATEST_VERSION, regular) > 0); + } + + @Test + void testLatestAgainstBeta() { + SemanticVersion beta = SEM_VER_CODEC.parse(JavaOps.INSTANCE, "1.22.0-beta.1+1.21").getOrThrow(); + + //Requires that the latest be newer than the beta + Assertions.assertTrue(COMPARATOR.compare(LATEST_VERSION, beta) > 0); + } + + @Test + void testLatestAgainstAlpha() { + SemanticVersion alpha = SEM_VER_CODEC.parse(JavaOps.INSTANCE, "1.22.0-alpha.1+1.21").getOrThrow(); + + //Requires that the latest be newer than the alpha + Assertions.assertTrue(COMPARATOR.compare(LATEST_VERSION, alpha) > 0); + } + + @Test + void testLatestAgainstOldAlpha() { + SemanticVersion oldAlpha = SEM_VER_CODEC.parse(JavaOps.INSTANCE, "1.21.1-alpha-pr-888-afc81df+1.21").getOrThrow(); + + //Requires the alpha is older than the latest + Assertions.assertEquals(COMPARATOR.compare(oldAlpha, LATEST_VERSION), -1); + } + + @Test + void testThatTheCurrentAlphaAgainstLatestShouldBeDiscarded() { + SemanticVersion currentAlpha = SEM_VER_CODEC.parse(JavaOps.INSTANCE, "1.22.0-alpha-pr-908-fe7d89a+1.21").getOrThrow(); + + //Requires that the current alpha be discarded against the latest version + Assertions.assertTrue(UpdateNotifications.shouldDiscard(currentAlpha, LATEST_VERSION)); + } +}