-
-
Notifications
You must be signed in to change notification settings - Fork 84
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #877 from AzureAaron/update-notification
Update Notifications
- Loading branch information
Showing
5 changed files
with
310 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
215 changes: 215 additions & 0 deletions
215
src/main/java/de/hysky/skyblocker/UpdateNotifications.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
67 changes: 67 additions & 0 deletions
67
src/test/java/de/hysky/skyblocker/UpdateNotificationsTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Version> COMPARATOR = UpdateNotifications.COMPARATOR; | ||
private final Codec<SemanticVersion> 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)); | ||
} | ||
} |