Skip to content
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 @@ -21,7 +21,9 @@ public interface UpdateChecker {
boolean isUpdateAvailable();

/**
* Gets the latest release of uSkyBlock. Returns NULL if the version info hasn't been fetched yet or is unavailable.
* Gets the latest release of uSkyBlock on the running major version; a newer major release is not reported here.
* On the STAGING channel, this is the version of the latest staging build, whatever its major version.
* Returns NULL if the version info hasn't been fetched yet or is unavailable.
* @return Latest release of uSkyBlock, NULL when unavailable.
*/
@Nullable String getLatestVersion();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package us.talabrek.ultimateskyblock;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
Expand All @@ -23,24 +24,52 @@

import java.io.IOException;
import java.net.URI;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@Singleton
public class SkyUpdateChecker implements UpdateChecker {

// Skips drafts and prereleases, so a withdrawn release can be hidden by turning it into either.
private static final URI GITHUB_LATEST_RELEASE =
URI.create("https://api.github.com/repos/uskyblock/uSkyBlock/releases/latest");
// Drafts and prereleases are skipped, so a withdrawn release can be hidden by turning it into either.
private static final URI GITHUB_RELEASES =
URI.create("https://api.github.com/repos/uskyblock/uSkyBlock/releases?per_page=100");

// A version starts with major.minor, e.g. 3.7.1 or 3.8.0-SNAPSHOT.
private static final Pattern VERSION = Pattern.compile("(\\d{1,9})\\.\\d+.*");

private final Logger logger;
private final RuntimeConfigs runtimeConfigs;
private final uSkyBlock plugin;

private String latestVersion;
private volatile Release newMajorRelease;
private volatile boolean lastCheckFailed;

/**
* A published uSkyBlock release.
*
* @param version The version, without the tag's leading "v".
* @param url The release page, with the release notes.
*/
public record Release(@NotNull String version, @NotNull String url) {
}

/**
* What an update check found.
*
* @param latestVersion The latest release on the running major version, or the version feed's version, NULL if
* there is none.
* @param newMajorRelease The latest release, if it has a higher major version than the running one.
*/
record CheckResult(@Nullable String latestVersion, @Nullable Release newMajorRelease) {
}

@Inject
public SkyUpdateChecker(
@NotNull uSkyBlock plugin,
Expand All @@ -53,19 +82,20 @@ public SkyUpdateChecker(
}

/**
* Triggers an update of the latest version info (the latest GitHub release, or the staging feed on the
* STAGING branch), and will log an INFO message if an update is available or the check failed.
* Triggers an update of the latest version info (the GitHub releases, or the staging feed on the STAGING
* branch), and will log an INFO message if an update or a new major version is available or the check failed.
*/
public void checkForUpdates() {
URI uri = updateUri(runtimeConfigs.current().pluginUpdates().branch());

fetchLatestVersion(uri).thenAccept(this::handleCheckResult);
fetchCheckResult(uri).thenAccept(this::handleCheckResult);
}

void handleCheckResult(@Nullable String version) {
void handleCheckResult(@Nullable CheckResult result) {
boolean failedBefore = lastCheckFailed;
latestVersion = version;
lastCheckFailed = version == null;
latestVersion = result != null ? result.latestVersion() : null;
newMajorRelease = result != null ? result.newMajorRelease() : null;
lastCheckFailed = result == null;
// Only the first failure in a row is logged, so a lasting outage doesn't repeat it on every check.
if (lastCheckFailed && !failedBefore) {
logger.info("Failed to check for new uSkyBlock versions. "
Expand All @@ -76,6 +106,10 @@ void handleCheckResult(@Nullable String version) {
logger.info("There is a new version of uSkyBlock available: " + getLatestVersion());
logger.info("Visit https://uskyblock.github.io/ to download.");
}
if (newMajorRelease != null) {
logger.info("There is a new major version of uSkyBlock available: " + newMajorRelease.version());
logger.info("Read its release notes before upgrading: " + newMajorRelease.url());
}
}

/**
Expand All @@ -85,8 +119,16 @@ public boolean lastCheckFailed() {
return lastCheckFailed;
}

/**
* @return The latest release if it has a higher major version than the running one, NULL otherwise. It is
* announced apart from regular updates, as upgrading to it may need more than replacing the jar.
*/
public @Nullable Release getNewMajorRelease() {
return newMajorRelease;
}

static @NotNull URI updateUri(@NotNull String branch) {
return branch.equalsIgnoreCase("STAGING") ? URL_STAGING : GITHUB_LATEST_RELEASE;
return branch.equalsIgnoreCase("STAGING") ? URL_STAGING : GITHUB_RELEASES;
}

public boolean isUpdateAvailable() {
Expand All @@ -105,10 +147,14 @@ public boolean isUpdateAvailable() {
}

public CompletableFuture<String> fetchLatestVersion(URI uri) {
CompletableFuture<String> future = new CompletableFuture<>();
return fetchCheckResult(uri).thenApply(result -> result != null ? result.latestVersion() : null);
}

CompletableFuture<CheckResult> fetchCheckResult(URI uri) {
CompletableFuture<CheckResult> future = new CompletableFuture<>();
future.completeAsync(() -> {
try {
return requestLatestVersion(uri);
return requestCheckResult(uri);
} catch (IOException ex) {
// The update source can't be reached (no network, DNS, timeout, TLS). That is expected on some
// hosts and not a plugin bug, so nothing is logged here; checkForUpdates logs the manual-check hint.
Expand All @@ -128,7 +174,7 @@ public CompletableFuture<String> fetchLatestVersion(URI uri) {
return future;
}

@Nullable String requestLatestVersion(@NotNull URI uri) throws IOException {
@Nullable CheckResult requestCheckResult(@NotNull URI uri) throws IOException {
String userAgent = "uSkyBlock-Plugin/v" + getCurrentVersion() + " (www.uskyblock.ovh)";
Timeout timeout = Timeout.ofSeconds(10);
ConnectionConfig connectionConfig = ConnectionConfig.custom()
Expand All @@ -149,25 +195,24 @@ public CompletableFuture<String> fetchLatestVersion(URI uri) {
.build()) {

HttpGet request = new HttpGet(uri);
if (GITHUB_LATEST_RELEASE.getHost().equals(uri.getHost())) {
if (GITHUB_RELEASES.getHost().equals(uri.getHost())) {
request.addHeader("Accept", "application/vnd.github+json");
request.addHeader("X-GitHub-Api-Version", "2026-03-10");
}
return httpclient.execute(request, response -> {
HttpEntity entity = response.getEntity();
String body = entity != null ? EntityUtils.toString(entity) : null;
return parseLatestVersion(response.getCode(), body);
return parseCheckResult(response.getCode(), body, getCurrentVersion());
});
}
}

/**
* Reads the version from a GitHub release ({@code tag_name}, without a leading "v") or from a version
* feed ({@code version}).
* Reads a GitHub release list, or the version of a version feed ({@code version}).
*
* @return The version, or NULL for a non-2xx status (e.g. GitHub's rate limit) or an unexpected body.
* @return The result, or NULL for a non-2xx status (e.g. GitHub's rate limit) or an unexpected body.
*/
static @Nullable String parseLatestVersion(int status, @Nullable String body) {
static @Nullable CheckResult parseCheckResult(int status, @Nullable String body, @NotNull String currentVersion) {
if (status < 200 || status >= 300 || body == null) {
return null;
}
Expand All @@ -178,19 +223,74 @@ public CompletableFuture<String> fetchLatestVersion(URI uri) {
} catch (JsonParseException ex) {
return null;
}
if (!json.isJsonObject()) {
return null;
if (json.isJsonArray()) {
return newestReleases(json.getAsJsonArray(), currentVersion);
}
if (json.isJsonObject() && json.getAsJsonObject().has("version")) {
return new CheckResult(json.getAsJsonObject().get("version").getAsString(), null);
}
return null;
}

/**
* Picks the latest release on the running major version, and the latest release overall if its major
* version is higher. Drafts, prereleases and tags that aren't a version are skipped.
*/
private static @NotNull CheckResult newestReleases(@NotNull JsonArray list, @NotNull String currentVersion) {
Integer currentMajor = majorVersion(currentVersion);
List<Release> releases = list.asList().stream()
.map(SkyUpdateChecker::publishedRelease)
.filter(Objects::nonNull)
.toList();
Comparator<Release> byVersion = Comparator.comparing(release -> new ComparableVersion(release.version()));

JsonObject obj = json.getAsJsonObject();
if (obj.has("tag_name")) {
String tag = obj.get("tag_name").getAsString();
return tag.startsWith("v") ? tag.substring(1) : tag;
String latestVersion = releases.stream()
.filter(release -> Objects.equals(majorVersion(release.version()), currentMajor))
.max(byVersion)
.map(Release::version)
.orElse(null);
Release newMajorRelease = releases.stream()
.max(byVersion)
.filter(release -> currentMajor != null && majorVersion(release.version()) > currentMajor)
.orElse(null);
return new CheckResult(latestVersion, newMajorRelease);
}

/**
* @return The release, or NULL for a draft, a prerelease or an entry without a version tag.
*/
private static @Nullable Release publishedRelease(@NotNull JsonElement element) {
if (!element.isJsonObject()) {
return null;
}
if (obj.has("version")) {
return obj.get("version").getAsString();
JsonObject release = element.getAsJsonObject();
String tag = stringMember(release, "tag_name");
String url = stringMember(release, "html_url");
if (isTrue(release, "draft") || isTrue(release, "prerelease") || tag == null || url == null) {
return null;
}
return null;
String version = tag.startsWith("v") ? tag.substring(1) : tag;
return majorVersion(version) != null ? new Release(version, url) : null;
}

private static @Nullable String stringMember(@NotNull JsonObject obj, @NotNull String name) {
JsonElement member = obj.get(name);
return member != null && member.isJsonPrimitive() && member.getAsJsonPrimitive().isString()
? member.getAsString() : null;
}

private static boolean isTrue(@NotNull JsonObject obj, @NotNull String name) {
JsonElement member = obj.get(name);
return member != null && member.isJsonPrimitive() && member.getAsJsonPrimitive().isBoolean()
&& member.getAsBoolean();
}

/**
* @return The major version, or NULL if the version doesn't start with major.minor.
*/
private static @Nullable Integer majorVersion(@NotNull String version) {
Matcher matcher = VERSION.matcher(version);
return matcher.matches() ? Integer.valueOf(matcher.group(1)) : null;
}

public boolean isNewerVersion(String currentVersion, String newVersion) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,16 +112,24 @@ public String getVersionInfo(boolean checkEnabled) {
}

@NotNull String updateNotice() {
if (updateChecker.isUpdateAvailable()) {
return miniToLegacy("<muted>A new update of uSkyBlock is available: <version><newline>",
unparsed("version", updateChecker.getLatestVersion(), PRIMARY))
+ miniToLegacy("<muted>Visit <url> to download.<newline>",
unparsed("url", "https://uskyblock.github.io/", PRIMARY));
}
if (updateChecker.lastCheckFailed()) {
return miniToLegacy("<muted>Failed to check for new uSkyBlock versions. Please check for updates manually at <url><newline>",
unparsed("url", "https://uskyblock.github.io/", PRIMARY));
}
return "";
StringBuilder notice = new StringBuilder();
if (updateChecker.isUpdateAvailable()) {
notice.append(miniToLegacy("<muted>A new update of uSkyBlock is available: <version><newline>",
unparsed("version", updateChecker.getLatestVersion(), PRIMARY)));
notice.append(miniToLegacy("<muted>Visit <url> to download.<newline>",
unparsed("url", "https://uskyblock.github.io/", PRIMARY)));
}
SkyUpdateChecker.Release newMajorRelease = updateChecker.getNewMajorRelease();
if (newMajorRelease != null) {
notice.append(miniToLegacy("<muted>A new major version of uSkyBlock is available: <version><newline>",
unparsed("version", newMajorRelease.version(), PRIMARY)));
notice.append(miniToLegacy("<muted>Read its release notes before upgrading: <url><newline>",
unparsed("url", newMajorRelease.url(), PRIMARY)));
}
return notice.toString();
}
}
Loading