diff options
Diffstat (limited to 'src/main/java/de')
3 files changed, 232 insertions, 0 deletions
diff --git a/src/main/java/de/hysky/skyblocker/SkyblockerMod.java b/src/main/java/de/hysky/skyblocker/SkyblockerMod.java index 2cfd61a7..47ad0f41 100644 --- a/src/main/java/de/hysky/skyblocker/SkyblockerMod.java +++ b/src/main/java/de/hysky/skyblocker/SkyblockerMod.java @@ -116,6 +116,7 @@ public class SkyblockerMod implements ClientModInitializer { 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 00000000..2034de2f --- /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<Version> COMPARATOR = Version::compareTo; + @VisibleForTesting + protected static final Codec<SemanticVersion> 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<MrVersion> 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<MrVersion> 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<SemanticVersion> 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<Config> 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<MrVersion> 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<MrVersion>> LIST_CODEC = CODEC.listOf(); + } + + public enum Channel implements StringIdentifiable { + RELEASE, + BETA, + ALPHA; + + private static final Codec<Channel> 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 ff089432..12a669de 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; @@ -35,6 +36,21 @@ public class GeneralCategory { .controller(ConfigUtils::createBooleanController) .build()) .option(Option.<Boolean>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.<UpdateNotifications.Channel>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.<Boolean>createBuilder() .name(Text.translatable("skyblocker.config.general.acceptReparty")) .binding(defaults.general.acceptReparty, () -> config.general.acceptReparty, |
