Skip to content

Commit aa084c9

Browse files
authored
Add update checker via Modrinth (#50)
feat: update checker
2 parents 1ea47d4 + ea83aa6 commit aa084c9

2 files changed

Lines changed: 195 additions & 0 deletions

File tree

src/main/java/org/modernbeta/admintoolbox/AdminToolboxPlugin.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
public class AdminToolboxPlugin extends JavaPlugin {
2929
static AdminToolboxPlugin instance;
3030

31+
private ModrinthUpdateChecker updateChecker;
32+
3133
private AdminManager adminManager;
3234
private FreezeManager freezeManager;
3335
private @Nullable StreamerModeManager streamerModeManager;
@@ -47,11 +49,17 @@ public class AdminToolboxPlugin extends JavaPlugin {
4749
public static final String BROADCAST_EXEMPT_PERMISSION = "admintoolbox.broadcast.exempt";
4850

4951
private static final int BSTATS_PLUGIN_ID = 26406;
52+
private static final String MODRINTH_PROJECT_ID = "TYi0LZWN";
5053

5154
@Override
5255
public void onEnable() {
5356
instance = this;
5457

58+
boolean shouldCheckUpdates = getConfig().getBoolean("check-updates", false);
59+
if (shouldCheckUpdates)
60+
getComponentLogger()
61+
.info(new ModrinthUpdateChecker().getUpdateMessage(MODRINTH_PROJECT_ID));
62+
5563
this.adminManager = new AdminManager();
5664
this.freezeManager = new FreezeManager();
5765
this.broadcastAudience = new PermissionAudience(BROADCAST_AUDIENCE_PERMISSION);
@@ -185,6 +193,10 @@ public void reloadConfig() {
185193
public Configuration getConfigDefaults() {
186194
Configuration defaults = new YamlConfiguration();
187195

196+
defaults.set("check-updates", true);
197+
defaults.setInlineComments("check-updates", List.of("Enable update check. When enabled, AdminToolbox will notify via the server console that a new version is available."));
198+
199+
// streamer-mode section
188200
{
189201
ConfigurationSection streamerMode = defaults.createSection("streamer-mode");
190202
streamerMode.set("allow", true);
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package org.modernbeta.admintoolbox;
2+
3+
import com.google.gson.*;
4+
import net.kyori.adventure.text.Component;
5+
import net.kyori.adventure.text.TextComponent;
6+
import net.kyori.adventure.text.format.NamedTextColor;
7+
import net.kyori.adventure.text.format.TextDecoration;
8+
import org.bukkit.Bukkit;
9+
10+
import javax.annotation.Nullable;
11+
import java.io.IOException;
12+
import java.net.URI;
13+
import java.net.URISyntaxException;
14+
import java.net.URLEncoder;
15+
import java.net.http.HttpClient;
16+
import java.net.http.HttpRequest;
17+
import java.net.http.HttpResponse;
18+
import java.nio.charset.StandardCharsets;
19+
import java.time.Instant;
20+
import java.time.format.DateTimeParseException;
21+
import java.util.Map;
22+
import java.util.Optional;
23+
import java.util.regex.Matcher;
24+
import java.util.regex.Pattern;
25+
import java.util.stream.Collectors;
26+
27+
import static java.nio.charset.StandardCharsets.UTF_8;
28+
29+
public class ModrinthUpdateChecker {
30+
private static final int SEMVER_LENGTH = 3;
31+
32+
private final AdminToolboxPlugin plugin = AdminToolboxPlugin.getInstance();
33+
private final Gson gson = new Gson();
34+
35+
public TextComponent getUpdateMessage(String projectId) {
36+
String pluginName = plugin.getPluginMeta().getName();
37+
String currentVersion = plugin.getPluginMeta().getVersion();
38+
String loader = Bukkit.getName();
39+
String gameVersion = Bukkit.getServer().getMinecraftVersion();
40+
41+
Optional<ModrinthVersion> newestCompatibleVersion =
42+
getNewestCompatibleVersion(projectId, currentVersion, loader, gameVersion);
43+
44+
return newestCompatibleVersion.map((version) -> {
45+
TextComponent.Builder builder = Component.text()
46+
.color(NamedTextColor.GOLD)
47+
.appendNewline()
48+
.append(Component.text("Version " + version.versionNumber()
49+
+ " of " + pluginName + " is now available!"
50+
).decorate(TextDecoration.BOLD))
51+
.appendNewline()
52+
.append(Component.text("You are running version " + currentVersion + "."));
53+
54+
if (version.downloadUrl() != null)
55+
builder
56+
.appendNewline()
57+
.append(Component.text("Download it here: " + version.downloadUrl()));
58+
59+
return builder.appendNewline().build();
60+
}).orElseGet(() -> Component.text("You're running the latest release of " + pluginName + "."));
61+
}
62+
63+
public Optional<ModrinthVersion> getNewestCompatibleVersion(String projectId, String currentVersion, String loader, String gameVersion) {
64+
int[] currentVersionParsed;
65+
try {
66+
currentVersionParsed = parseSemverParts(currentVersion);
67+
} catch (NumberFormatException e) {
68+
plugin.getLogger().warning("Could not parse current version: " + currentVersion);
69+
return Optional.empty();
70+
}
71+
72+
plugin.getLogger()
73+
.info("Checking for updates compatible with " + loader + " " + gameVersion + "...");
74+
75+
try (HttpClient client = HttpClient.newHttpClient()) {
76+
// modrinth api has stupid non-standard query param expectations,
77+
// so we must wrap them in javascript arrays
78+
String queryString = Map.of(
79+
"loaders", "[\"" + loader.toLowerCase() + "\"]",
80+
"game_versions", "[\"" + gameVersion + "\"]",
81+
// Added 2026-01-15 - decreases metadata to parsed
82+
"include_changelog", "false"
83+
)
84+
.entrySet().stream()
85+
.map(entry ->
86+
URLEncoder.encode(entry.getKey(), UTF_8) + "="
87+
+ URLEncoder.encode(entry.getValue(), UTF_8))
88+
.collect(Collectors.joining("&"));
89+
90+
URI requestUri = new URI(
91+
"https",
92+
"api.modrinth.com",
93+
"/v2/project/" + URLEncoder.encode(projectId, StandardCharsets.UTF_8) + "/version",
94+
queryString,
95+
null
96+
);
97+
98+
HttpRequest req = HttpRequest.newBuilder()
99+
.uri(requestUri)
100+
.GET()
101+
.build();
102+
103+
HttpResponse<String> res = client.send(req, HttpResponse.BodyHandlers.ofString());
104+
String rawBody = res.body();
105+
106+
JsonArray rawVersionList = gson.fromJson(rawBody, JsonArray.class);
107+
for (JsonElement element : rawVersionList) {
108+
if (!element.isJsonObject()) continue;
109+
110+
JsonObject object = element.getAsJsonObject();
111+
112+
String versionType = object.get("version_type").getAsString();
113+
if (!versionType.equals("release")) continue;
114+
115+
String status = object.get("status").getAsString();
116+
if (!status.equals("listed")) continue;
117+
118+
String versionNumber = object.get("version_number").getAsString();
119+
Instant datePublished = Instant.parse(object.get("date_published").getAsString());
120+
121+
if (isGreaterVersion(parseSemverParts(versionNumber), currentVersionParsed)) {
122+
String downloadUrl = null;
123+
for (JsonElement rawFile : object.get("files").getAsJsonArray()) {
124+
if (!rawFile.isJsonObject()) continue;
125+
126+
JsonObject fileObject = rawFile.getAsJsonObject();
127+
boolean primary = fileObject.get("primary").getAsBoolean();
128+
if (!primary) continue;
129+
130+
downloadUrl = fileObject.get("url").getAsString();
131+
break;
132+
}
133+
134+
return Optional.of(
135+
new ModrinthVersion(versionNumber, datePublished, downloadUrl));
136+
}
137+
}
138+
} catch (IOException | InterruptedException e) {
139+
plugin.getLogger().severe("Failed request plugin versions from Modrinth: " + e.getMessage());
140+
return Optional.empty();
141+
} catch (JsonParseException e) {
142+
plugin.getLogger().severe("Failed to parse plugin versions response from Modrinth: " + e.getMessage());
143+
return Optional.empty();
144+
} catch (DateTimeParseException e) {
145+
plugin.getLogger().severe("Failed to parse version published_date from Modrinth: " + e.getMessage());
146+
return Optional.empty();
147+
} catch (URISyntaxException e) {
148+
plugin.getLogger().severe("Failed to create URL to check updates from Modrinth: " + e.getMessage());
149+
return Optional.empty();
150+
}
151+
152+
return Optional.empty(); // no newer version found
153+
}
154+
155+
public record ModrinthVersion(String versionNumber, Instant datePublished,
156+
@Nullable String downloadUrl) {
157+
}
158+
159+
private static boolean isGreaterVersion(int[] a, int[] b) throws IllegalArgumentException {
160+
if (a.length != SEMVER_LENGTH || b.length != SEMVER_LENGTH)
161+
throw new IllegalArgumentException("Compared semver version has incorrect size!");
162+
163+
for (int i = 0; i < SEMVER_LENGTH; i++) {
164+
if (a[i] > b[i]) return true;
165+
if (a[i] < b[i]) return false;
166+
}
167+
return false; // versions are equal
168+
}
169+
170+
private static final Pattern SEMVER_PATTERN = Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)");
171+
172+
private static int[] parseSemverParts(String version) throws NumberFormatException {
173+
Matcher matcher = SEMVER_PATTERN.matcher(version);
174+
if (!matcher.find())
175+
throw new NumberFormatException("Version is not valid semantic version! (expected: x.x.x)");
176+
177+
return new int[]{
178+
Integer.parseInt(matcher.group(1)),
179+
Integer.parseInt(matcher.group(2)),
180+
Integer.parseInt(matcher.group(3)),
181+
};
182+
}
183+
}

0 commit comments

Comments
 (0)