diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5c0e2d8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +* text=auto eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf +*.jar binary +*.zip binary +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af97384..be6220d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,8 +46,6 @@ jobs: with: distribution: temurin java-version: '21' - cache: maven - cache-dependency-path: '**/pom.xml' - name: Verify API and core run: mvn -B -ntp -pl headdb-api,headdb-core -am verify @@ -79,11 +77,17 @@ jobs: with: distribution: temurin java-version: '25' - cache: maven - cache-dependency-path: '**/pom.xml' - name: Package Paper plugin - run: mvn -B -ntp -pl headdb-platforms/headdb-paper -am package + shell: bash + run: | + set -euo pipefail + + mvn -B -ntp -U -pl headdb-platforms/headdb-paper -am package || { + echo "Initial Maven build failed. Clearing potentially corrupted Maven Resolver artifact and retrying once." + rm -rf "$HOME/.m2/repository/org/apache/maven/resolver/maven-resolver-named-locks/1.9.18" + mvn -B -ntp -U -pl headdb-platforms/headdb-paper -am package + } - name: Inspect packaged plugin shell: bash @@ -242,4 +246,4 @@ jobs: check_reachable(artifact_url(manifest, resource_id)) print(f"Validated manifest revision {manifest['revision']} from {manifest_url}") - PY \ No newline at end of file + PY diff --git a/headdb-platforms/headdb-paper/pom.xml b/headdb-platforms/headdb-paper/pom.xml index 601d98c..b919bc8 100644 --- a/headdb-platforms/headdb-paper/pom.xml +++ b/headdb-platforms/headdb-paper/pom.xml @@ -198,6 +198,12 @@ provided + + org.junit.jupiter + junit-jupiter + test + + diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java index 251ea86..bdf349c 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java @@ -34,6 +34,7 @@ import io.github.silentdevelopment.headdb.paper.runtime.RuntimeDiagnostics; import io.github.silentdevelopment.headdb.paper.runtime.StartupChecks; import io.github.silentdevelopment.headdb.paper.service.PaperHeadDBService; +import io.github.silentdevelopment.headdb.paper.updater.UpdateService; import io.github.silentdevelopment.hermes.id.LocaleId; import io.github.silentdevelopment.hermes.paper.core.Hermes; import io.github.silentdevelopment.hermes.paper.messenger.PaperMessenger; @@ -59,6 +60,7 @@ public final class HeadDBPlugin extends JavaPlugin { private FavoriteHeadService favoriteHeadService; private CustomCategoryService customCategoryService; private EconomyService economyService; + private UpdateService updateService; @Override public void onEnable() { @@ -74,6 +76,8 @@ public void onEnable() { getServer().getPluginManager().registerEvents(new HeadEditListener(this), this); registerCommands(); + cleanupSuccessfulUpdateBackup(); + startUpdater(); } catch (ConfigException exception) { getSLF4JLogger().error("HeadDB config could not be loaded.", exception); @@ -98,10 +102,17 @@ public void onDisable() { promptInputService.shutdown(); } + if (updateService != null) { + updateService.close(); + } + + HeadDBMetrics.unregister(this); + adminModes.clear(); favoriteHeadService = null; customCategoryService = null; economyService = null; + updateService = null; runtime = null; config = null; guiConfig = null; @@ -131,6 +142,7 @@ public synchronized void reload() throws ConfigException { CustomCategoryService createdCustomCategoryService = new CustomCategoryService(localStoreDatabase); EconomyService createdEconomyService = EconomyService.create(this, loadedEconomyConfig); GuiService createdGuiService = new GuiService(this, createdItemFactory); + UpdateService createdUpdateService = new UpdateService(this, loadedConfig); RuntimeDiagnostics.logConfig(this, loadedConfig); @@ -141,6 +153,7 @@ public synchronized void reload() throws ConfigException { createdRuntime.start(); PluginRuntime previousRuntime = this.runtime; + UpdateService previousUpdateService = this.updateService; this.config = loadedConfig; this.guiConfig = loadedGuiConfig; @@ -152,10 +165,15 @@ public synchronized void reload() throws ConfigException { this.favoriteHeadService = createdFavoriteHeadService; this.customCategoryService = createdCustomCategoryService; this.economyService = createdEconomyService; + this.updateService = createdUpdateService; clearItemCache(); clearSearchCache(); + if (previousUpdateService != null) { + previousUpdateService.close(); + } + if (previousRuntime != null) { previousRuntime.close(); } @@ -167,6 +185,27 @@ public synchronized void reload() throws ConfigException { HeadDBMetrics.register(this); } + + public synchronized void startUpdater() { + UpdateService currentUpdateService = updateService; + + if (currentUpdateService == null) { + throw new IllegalStateException("HeadDB update service is not initialized"); + } + + currentUpdateService.start(); + } + + public synchronized void cleanupSuccessfulUpdateBackup() { + UpdateService currentUpdateService = updateService; + + if (currentUpdateService == null) { + throw new IllegalStateException("HeadDB update service is not initialized"); + } + + currentUpdateService.cleanupBackupAfterSuccessfulLoad(); + } + public @NotNull HeadItemFactory itemFactory() { HeadItemFactory currentItemFactory = itemFactory; @@ -271,6 +310,14 @@ public synchronized void reloadGuiConfigOnly() throws ConfigException { return currentEconomyService; } + public @NotNull UpdateService updater() { + UpdateService currentUpdateService = updateService; + if (currentUpdateService == null) { + throw new IllegalStateException("HeadDB update service is not initialized"); + } + return currentUpdateService; + } + public @NotNull Messages messages() { Messages currentMessages = messages; @@ -387,4 +434,4 @@ public boolean isPaper() { } } -} \ No newline at end of file +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/RootCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/RootCommand.java index 11dff62..739d67a 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/RootCommand.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/RootCommand.java @@ -18,7 +18,9 @@ import io.github.silentdevelopment.headdb.paper.command.subcommand.ReloadCommand; import io.github.silentdevelopment.headdb.paper.command.subcommand.StatusCommand; import io.github.silentdevelopment.headdb.paper.command.subcommand.TagsCommand; +import io.github.silentdevelopment.headdb.paper.command.subcommand.UpdateCommand; import io.github.silentdevelopment.headdb.paper.command.subcommand.VerifyCommand; +import io.github.silentdevelopment.headdb.paper.command.subcommand.VersionCommand; import io.github.silentdevelopment.headdb.paper.command.subcommand.search.SearchCommand; import io.github.silentdevelopment.headdb.paper.permission.Permissions; import io.github.silentdevelopment.relay.command.CommandDefinition; @@ -42,11 +44,13 @@ public RootCommand(@NotNull HeadDBPlugin plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); this.children = List.of( new HelpCommand(plugin), + new VersionCommand(plugin), new StatusCommand(plugin), new DebugCommand(plugin), new VerifyCommand(plugin), new RefreshCommand(plugin), new ReloadCommand(plugin), + new UpdateCommand(plugin), new GiveCommand(plugin), new PlayerCommand(plugin), new CustomCommand(plugin), @@ -94,4 +98,4 @@ protected void handle(@NotNull PaperCommandContext context) { .suggestAliases(true) .noArgs(); } -} \ No newline at end of file +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/format/HelpFormatter.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/format/HelpFormatter.java index 02d3643..cd1e1dc 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/format/HelpFormatter.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/format/HelpFormatter.java @@ -35,6 +35,7 @@ private HelpFormatter() { addSection(lines, sender, new HelpSection("General", List.of( new HelpEntry(List.of("help"), "h", List.of(), "/hdb help", "Show this command reference.", Permissions.HELP), + new HelpEntry(List.of("version"), null, List.of(), "/hdb version", "Show version and build information.", Permissions.VERSION), new HelpEntry(List.of("open"), "o", List.of(), "/hdb open", "Open the main HeadDB GUI.", Permissions.OPEN) ))); @@ -79,6 +80,7 @@ private HelpFormatter() { ))); addSection(lines, sender, new HelpSection("Admin", List.of( + new HelpEntry(List.of("update"), null, List.of(), "/hdb update", "Check for and download the latest version.", Permissions.UPDATE), new HelpEntry(List.of("itemcache", "clear"), "ic clear", List.of(), "/hdb itemcache clear", "Clear generated item cache.", Permissions.ITEM_CACHE) ))); @@ -234,4 +236,4 @@ private enum ArgumentKind { REQUIRED, OPTIONAL } -} \ No newline at end of file +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/format/VersionFormatter.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/format/VersionFormatter.java new file mode 100644 index 0000000..1bcd657 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/format/VersionFormatter.java @@ -0,0 +1,166 @@ +package io.github.silentdevelopment.headdb.paper.command.format; + +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.runtime.BuildInfo; +import io.github.silentdevelopment.headdb.paper.updater.GitHubRelease; +import io.github.silentdevelopment.headdb.paper.updater.UpdateCheckResult; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class VersionFormatter { + + private VersionFormatter() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static @NotNull Component startup( + @NotNull HeadDBPlugin plugin, + @Nullable UpdateCheckResult updateResult + ) { + Objects.requireNonNull(plugin, "plugin"); + + BuildInfo buildInfo = BuildInfo.read(plugin); + List lines = new ArrayList<>(); + + lines.add(Component.empty()); + lines.add(Component.empty()); + + lines.add(Component.empty()); + lines.add(runningLine(plugin)); + lines.add(Component.empty()); + lines.add(versionLine(buildInfo.version(), updateResult)); + + if (plugin.config().isDebug()) { + lines.add(field("Build", value(buildInfo.buildNumber()))); + lines.add(field("Branch", value(buildInfo.branch()))); + lines.add(field("Commit", value(buildInfo.commit()))); + lines.add(field("Timestamp", value(buildInfo.buildTime()))); + } + + lines.add(Component.empty()); + lines.add(Component.empty()); + + return joinLines(lines); + } + + public static @NotNull List command(@NotNull HeadDBPlugin plugin) { + Objects.requireNonNull(plugin, "plugin"); + + BuildInfo buildInfo = BuildInfo.read(plugin); + UpdateCheckResult updateResult = plugin.updater().lastResult(); + List lines = new ArrayList<>(); + + lines.add(Component.empty()); + lines.add(runningLine(plugin)); + lines.add(versionLine(buildInfo.version(), updateResult)); + lines.add(field("Build", value(buildInfo.buildNumber()))); + lines.add(field("Branch", value(buildInfo.branch()))); + lines.add(field("Commit", value(buildInfo.commit()))); + lines.add(field("Timestamp", value(buildInfo.buildTime()))); + + Component actions = updateActions(updateResult); + + if (actions != null) { + lines.add(Component.empty()); + lines.add(actions); + } + + lines.add(Component.empty()); + return List.copyOf(lines); + } + + private static @NotNull Component runningLine(@NotNull HeadDBPlugin plugin) { + List authors = plugin.getPluginMeta().getAuthors(); + String authorText = authors.isEmpty() ? "Unknown" : String.join(", ", authors); + + return Component.text("Running ", NamedTextColor.GRAY) + .append(Component.text(plugin.getPluginMeta().getName(), NamedTextColor.RED)) + .append(Component.text(" by ", NamedTextColor.GRAY)) + .append(Component.text(authorText, NamedTextColor.GOLD)); + } + + private static @NotNull Component versionLine( + @NotNull String version, + @Nullable UpdateCheckResult updateResult + ) { + return Component.text("Version: ", NamedTextColor.GRAY) + .append(Component.text(version, NamedTextColor.GOLD)) + .append(Component.text(" (", NamedTextColor.GRAY)) + .append(status(updateResult)) + .append(Component.text(")", NamedTextColor.GRAY)); + } + + private static @NotNull Component status(@Nullable UpdateCheckResult updateResult) { + if (updateResult == null) { + return Component.text("Not Checked", NamedTextColor.DARK_GRAY); + } + + if (updateResult.failed()) { + return Component.text("Check Failed", NamedTextColor.RED); + } + + if (updateResult.updateAvailable()) { + return Component.text("Update Available", NamedTextColor.YELLOW); + } + + return Component.text("Latest", NamedTextColor.GOLD); + } + + private static @NotNull Component field(@NotNull String key, @NotNull String value) { + return Component.text(key + ": ", NamedTextColor.GRAY).append(Component.text(value, NamedTextColor.GOLD)); + } + + private static @Nullable Component updateActions(@Nullable UpdateCheckResult updateResult) { + if (updateResult == null || !updateResult.updateAvailable()) { + return null; + } + + GitHubRelease release = updateResult.release(); + + if (release == null) { + return null; + } + + Component open = Component.text("[OPEN]", NamedTextColor.GOLD) + .clickEvent(ClickEvent.openUrl(release.htmlUrl())) + .hoverEvent(HoverEvent.showText(Component.text("Open the release page.", NamedTextColor.GRAY))); + + Component update = Component.text("[UPDATE]", NamedTextColor.GOLD) + .clickEvent(ClickEvent.runCommand("/hdb update")) + .hoverEvent(HoverEvent.showText(Component.text("Download and install this update.", NamedTextColor.GRAY))); + + return open + .append(Component.text(" -=- ", NamedTextColor.GRAY)) + .append(update); + } + + private static @NotNull Component joinLines(@NotNull List lines) { + if (lines.isEmpty()) { + return Component.empty(); + } + + Component result = lines.getFirst(); + + for (int index = 1; index < lines.size(); index++) { + result = result.appendNewline().append(lines.get(index)); + } + + return result; + } + + private static @NotNull String value(@Nullable String value) { + if (value == null || value.isBlank()) { + return "Unavailable"; + } + + return value; + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/ReloadCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/ReloadCommand.java index d0918e0..fad89ed 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/ReloadCommand.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/ReloadCommand.java @@ -25,6 +25,7 @@ protected void handle(@NotNull PaperCommandContext context) { try { plugin.reload(); + plugin.startUpdater(); } catch (Exception exception) { plugin.getSLF4JLogger().error("Failed to reload HeadDB.", exception); context.reply(plugin.messages().reloadFailed(context.sender())); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/UpdateCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/UpdateCommand.java new file mode 100644 index 0000000..7f8f593 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/UpdateCommand.java @@ -0,0 +1,44 @@ +package io.github.silentdevelopment.headdb.paper.command.subcommand; + +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.command.CommandRequirements; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.relay.command.Command; +import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand; +import io.github.silentdevelopment.relay.paper.command.PaperCommands; +import io.github.silentdevelopment.relay.paper.command.context.PaperCommandContext; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +public final class UpdateCommand extends AbstractPaperCommand { + + private final HeadDBPlugin plugin; + + public UpdateCommand(@NotNull HeadDBPlugin plugin) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + } + + @Override + protected void handle(@NotNull PaperCommandContext context) { + boolean accepted = plugin.updater().checkAndInstallAsync(context.sender()); + + if (!accepted) { + return; + } + + context.reply(Component.text("Checking for updates...", NamedTextColor.GRAY)); + } + + @Override + protected @NotNull Command buildCommand() { + return PaperCommands.literal("update") + .description("Checks for updates and installs the latest available version.") + .requirement(CommandRequirements.permission(Permissions.UPDATE)) + .noArgs() + .build(); + } + +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VerifyCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VerifyCommand.java index f6d600a..6977a14 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VerifyCommand.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VerifyCommand.java @@ -50,7 +50,6 @@ protected void handle(@NotNull PaperCommandContext context) { @Override protected @NotNull Command buildCommand() { return PaperCommands.literal("verify") - .alias("v") .description("Verifies the remote database without replacing the active database.") .requirement(CommandRequirements.permission(Permissions.VERIFY)) .noArgs() diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VersionCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VersionCommand.java new file mode 100644 index 0000000..6068e3e --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VersionCommand.java @@ -0,0 +1,40 @@ +package io.github.silentdevelopment.headdb.paper.command.subcommand; + +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.command.CommandRequirements; +import io.github.silentdevelopment.headdb.paper.command.format.VersionFormatter; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.relay.command.Command; +import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand; +import io.github.silentdevelopment.relay.paper.command.PaperCommands; +import io.github.silentdevelopment.relay.paper.command.context.PaperCommandContext; +import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +public final class VersionCommand extends AbstractPaperCommand { + + private final HeadDBPlugin plugin; + + public VersionCommand(@NotNull HeadDBPlugin plugin) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + } + + @Override + protected void handle(@NotNull PaperCommandContext context) { + for (Component line : VersionFormatter.command(plugin)) { + context.reply(line); + } + } + + @Override + protected @NotNull Command buildCommand() { + return PaperCommands.literal("version") + .alias("v") + .description("Shows plugin version and build information.") + .requirement(CommandRequirements.permission(Permissions.VERSION)) + .noArgs() + .build(); + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/config/ConfigDefaultsMerger.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/config/ConfigDefaultsMerger.java new file mode 100644 index 0000000..84a7bf0 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/config/ConfigDefaultsMerger.java @@ -0,0 +1,186 @@ +package io.github.silentdevelopment.headdb.paper.config; + +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +final class ConfigDefaultsMerger { + + private static final List DEFAULTS = List.of( + new NestedDefault("update-checker", "enabled", "true"), + new NestedDefault("update-checker", "check-on-startup", "true"), + new NestedDefault("update-checker", "notify-console", "true"), + new NestedDefault("update-checker", "notify-admins", "true"), + new NestedDefault("update-checker", "include-prereleases", "true"), + new NestedDefault("update-checker", "include-builds", "true"), + new NestedDefault("auto-updater", "install-updates", "false") + ); + + private ConfigDefaultsMerger() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + static @NotNull String mergeMissingDefaults(@NotNull String content) { + Objects.requireNonNull(content, "content"); + + String lineSeparator = detectLineSeparator(content); + boolean trailingLineSeparator = content.endsWith("\n") || content.endsWith("\r"); + List lines = splitLines(content); + boolean changed = false; + + for (NestedDefault nestedDefault : DEFAULTS) { + changed |= addMissingNestedKey(lines, nestedDefault.section(), nestedDefault.key(), nestedDefault.value()); + } + + if (!changed) { + return content; + } + + String merged = String.join(lineSeparator, lines); + + if (!trailingLineSeparator) { + return merged; + } + + return merged + lineSeparator; + } + + private static @NotNull String detectLineSeparator(@NotNull String content) { + if (content.contains("\r\n")) { + return "\r\n"; + } + + return "\n"; + } + + private static @NotNull List splitLines(@NotNull String content) { + String[] split = content.split("\\R", -1); + int length = split.length; + + if (length > 0 && split[length - 1].isEmpty() && (content.endsWith("\n") || content.endsWith("\r"))) { + length--; + } + + List lines = new ArrayList<>(length); + + for (int index = 0; index < length; index++) { + lines.add(split[index]); + } + + return lines; + } + + private static boolean addMissingNestedKey(@NotNull List lines, @NotNull String section, @NotNull String key, @NotNull String value) { + if (hasNestedKey(lines, section, key)) { + return false; + } + + addNestedKey(lines, section, key, value); + return true; + } + + private static boolean hasNestedKey(@NotNull List lines, @NotNull String section, @NotNull String key) { + boolean inSection = false; + + for (String line : lines) { + String stripped = stripComment(line); + + if (stripped.isBlank()) { + continue; + } + + if (isTopLevel(line)) { + inSection = stripped.trim().equals(section + ":"); + continue; + } + + if (!inSection) { + continue; + } + + if (stripped.trim().startsWith(key + ":")) { + return true; + } + } + + return false; + } + + private static void addNestedKey(@NotNull List lines, @NotNull String section, @NotNull String key, @NotNull String value) { + int sectionIndex = findTopLevelSection(lines, section); + + if (sectionIndex < 0) { + ensureBlankLine(lines); + lines.add(section + ":"); + lines.add(" " + key + ": " + value); + return; + } + + int insertIndex = findSectionEnd(lines, sectionIndex); + lines.add(insertIndex, " " + key + ": " + value); + } + + private static int findTopLevelSection(@NotNull List lines, @NotNull String section) { + for (int index = 0; index < lines.size(); index++) { + String line = lines.get(index); + + if (!isTopLevel(line)) { + continue; + } + + if (stripComment(line).trim().equals(section + ":")) { + return index; + } + } + + return -1; + } + + private static int findSectionEnd(@NotNull List lines, int sectionIndex) { + for (int index = sectionIndex + 1; index < lines.size(); index++) { + String line = lines.get(index); + + if (line.isBlank()) { + continue; + } + + if (isTopLevel(line)) { + return index; + } + } + + return lines.size(); + } + + private static boolean isTopLevel(@NotNull String line) { + return !line.startsWith(" ") && !line.startsWith("\t"); + } + + private static void ensureBlankLine(@NotNull List lines) { + if (lines.isEmpty()) { + return; + } + + String last = lines.get(lines.size() - 1); + + if (!last.isBlank()) { + lines.add(""); + } + } + + private static @NotNull String stripComment(@NotNull String line) { + int commentIndex = line.indexOf('#'); + + if (commentIndex < 0) { + return line; + } + + return line.substring(0, commentIndex); + } + + private record NestedDefault(@NotNull String section, @NotNull String key, @NotNull String value) { + } + +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/config/ConfigLoader.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/config/ConfigLoader.java index c25a449..9a29a38 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/config/ConfigLoader.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/config/ConfigLoader.java @@ -10,6 +10,8 @@ import io.github.silentdevelopment.atlas.io.PathConfigResource; import org.jetbrains.annotations.NotNull; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Objects; @@ -33,6 +35,8 @@ public ConfigLoader(@NotNull Path dataDirectory) { if (!resource.exists()) { generateDefaultConfig(resource, codec); + } else { + appendMissingDefaults(configPath); } BoundConfig boundConfig = ConfigBindings.config() @@ -44,8 +48,6 @@ public ConfigLoader(@NotNull Path dataDirectory) { PluginConfig config = boundConfig.value(); config.validate(); - saveConfig(resource, codec, config); - return config; } catch (Exception exception) { throw new ConfigException("Failed to load HeadDB config from " + configPath, exception); @@ -68,4 +70,15 @@ private void saveConfig(@NotNull PathConfigResource resource, @NotNull YamlConfi loader.save(document); } -} \ No newline at end of file + + private void appendMissingDefaults(@NotNull Path configPath) throws IOException { + String content = Files.readString(configPath, StandardCharsets.UTF_8); + String merged = ConfigDefaultsMerger.mergeMissingDefaults(content); + + if (merged.equals(content)) { + return; + } + + Files.writeString(configPath, merged, StandardCharsets.UTF_8); + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/config/PluginConfig.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/config/PluginConfig.java index fda5ca0..9beaea5 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/config/PluginConfig.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/config/PluginConfig.java @@ -71,7 +71,6 @@ public final class PluginConfig { @Comment("HTTP read timeout in seconds.") private int readTimeoutSeconds = 30; - @Required @Key("storage.sqlite.file") @Comment({ @@ -132,6 +131,43 @@ public final class PluginConfig { }) private boolean guiOpenMainCommand = true; + @Key("update-checker.enabled") + @Comment({ + "Update checker settings.", + "Checks the official HeadDB GitHub Releases feed for newer versions and builds.", + "The GitHub repository is hardcoded by the plugin." + }) + private boolean updateCheckerEnabled = true; + + @Key("update-checker.check-on-startup") + @Comment("Checks for updates shortly after HeadDB starts or reloads.") + private boolean updateCheckerCheckOnStartup = true; + + @Key("update-checker.notify-console") + @Comment("Logs update notifications to console when an update is available.") + private boolean updateCheckerNotifyConsole = true; + + @Key("update-checker.notify-admins") + @Comment("Notifies online players with headdb.admin.update when an update is available.") + private boolean updateCheckerNotifyAdmins = true; + + @Key("update-checker.include-prereleases") + @Comment("Includes prerelease GitHub Releases such as alpha, beta, and rc versions.") + private boolean updateCheckerIncludePrereleases = true; + + @Key("update-checker.include-builds") + @Comment("Includes newer build metadata releases such as 7.0.0-rc.2+build.5.") + private boolean updateCheckerIncludeBuilds = false; + + @Key("auto-updater.install-updates") + @Comment({ + "Auto updater settings.", + "If true, the newest plugin jar is installed in place when possible or staged in the server update folder.", + "The server is not restarted automatically. Restart the server to load the new version.", + "Disabled by default." + }) + private boolean autoUpdaterInstallUpdates = false; + @Key("debug") @Comment({ "Diagnostics.", @@ -198,7 +234,6 @@ public boolean refreshOnStartup() { return Duration.ofSeconds(readTimeoutSeconds); } - public @NotNull Path localStoreDatabase(@NotNull Path pluginDataDirectory) { Objects.requireNonNull(pluginDataDirectory, "pluginDataDirectory"); return pluginDataDirectory.resolve(localStoreSqliteFile).normalize(); @@ -249,6 +284,34 @@ public boolean guiOpenMainCommand() { return guiOpenMainCommand; } + public boolean updateCheckerEnabled() { + return updateCheckerEnabled; + } + + public boolean updateCheckerCheckOnStartup() { + return updateCheckerCheckOnStartup; + } + + public boolean updateCheckerNotifyConsole() { + return updateCheckerNotifyConsole; + } + + public boolean updateCheckerNotifyAdmins() { + return updateCheckerNotifyAdmins; + } + + public boolean updateCheckerIncludePrereleases() { + return updateCheckerIncludePrereleases; + } + + public boolean updateCheckerIncludeBuilds() { + return updateCheckerIncludeBuilds; + } + + public boolean autoUpdaterInstallUpdates() { + return autoUpdaterInstallUpdates; + } + public boolean isDebug() { return debug; } @@ -326,4 +389,4 @@ private static void validateRelativeDirectory(@NotNull String key, String value) throw new ConfigException(key + " cannot escape the plugin data folder"); } } -} \ No newline at end of file +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/metrics/HeadDBMetrics.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/metrics/HeadDBMetrics.java index 7891ece..3cd6396 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/metrics/HeadDBMetrics.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/metrics/HeadDBMetrics.java @@ -7,22 +7,30 @@ public final class HeadDBMetrics { - /* - * Replace this after registering HeadDB on bStats. - */ private static final int BSTATS_PLUGIN_ID = 9152; private static Metrics metrics; private HeadDBMetrics() {} - public static void register(@NotNull HeadDBPlugin plugin) { + public static synchronized void register(@NotNull HeadDBPlugin plugin) { Objects.requireNonNull(plugin, "plugin"); + + if (metrics != null) { + metrics.shutdown(); + } + metrics = new Metrics(plugin, BSTATS_PLUGIN_ID); } - public static void unregister(@NotNull HeadDBPlugin plugin) { + public static synchronized void unregister(@NotNull HeadDBPlugin plugin) { Objects.requireNonNull(plugin, "plugin"); + + if (metrics == null) { + return; + } + metrics.shutdown(); + metrics = null; } -} \ No newline at end of file +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/permission/Permissions.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/permission/Permissions.java index fb87743..65f9a85 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/permission/Permissions.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/permission/Permissions.java @@ -12,11 +12,13 @@ public final class Permissions { public static final String ADMIN = "headdb.admin"; public static final String HELP = "headdb.command.help"; + public static final String VERSION = "headdb.command.version"; public static final String STATUS = "headdb.command.status"; public static final String DEBUG = "headdb.command.debug"; public static final String VERIFY = "headdb.command.verify"; public static final String REFRESH = "headdb.command.refresh"; public static final String RELOAD = "headdb.command.reload"; + public static final String UPDATE = "headdb.admin.update"; public static final String SEARCH = "headdb.command.search"; public static final String INFO = "headdb.command.info"; public static final String GIVE = "headdb.command.give"; @@ -154,4 +156,4 @@ public static boolean canViewCategory(@NotNull CommandSender sender, @NotNull St return CATEGORY_PREFIX + normalized; } -} \ No newline at end of file +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/GitHubRelease.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/GitHubRelease.java new file mode 100644 index 0000000..f9e6e15 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/GitHubRelease.java @@ -0,0 +1,31 @@ +package io.github.silentdevelopment.headdb.paper.updater; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +import java.util.Objects; + +public record GitHubRelease( + @NotNull String tagName, + @NotNull String name, + @NotNull String htmlUrl, + boolean prerelease, + @Nullable Instant publishedAt, + @NotNull HeadDBVersion version, + @Nullable String assetName, + @Nullable String assetDownloadUrl +) { + + public GitHubRelease { + Objects.requireNonNull(tagName, "tagName"); + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(htmlUrl, "htmlUrl"); + Objects.requireNonNull(version, "version"); + } + + public boolean hasPluginAsset() { + return assetDownloadUrl != null && !assetDownloadUrl.isBlank(); + } + +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/GitHubReleaseUpdateChecker.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/GitHubReleaseUpdateChecker.java new file mode 100644 index 0000000..a9a4143 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/GitHubReleaseUpdateChecker.java @@ -0,0 +1,241 @@ +package io.github.silentdevelopment.headdb.paper.updater; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.Objects; + +public final class GitHubReleaseUpdateChecker { + + public static final String REPOSITORY = "SilentDevelopment/HeadDB"; + + private static final int MAX_RELEASES = 50; + private final HttpClient httpClient; + private final Duration readTimeout; + private final String userAgent; + + public GitHubReleaseUpdateChecker( + @NotNull Duration connectTimeout, + @NotNull Duration readTimeout, + @NotNull String userAgent + ) { + Objects.requireNonNull(connectTimeout, "connectTimeout"); + this.readTimeout = Objects.requireNonNull(readTimeout, "readTimeout"); + this.userAgent = Objects.requireNonNull(userAgent, "userAgent"); + this.httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(connectTimeout) + .build(); + } + + public @NotNull UpdateCheckResult check(@NotNull String currentVersion, boolean includePrereleases, boolean includeBuilds) throws IOException, InterruptedException { + Objects.requireNonNull(currentVersion, "currentVersion"); + + HeadDBVersion current = HeadDBVersion.parse(currentVersion); + JsonArray releases = fetchReleases(); + GitHubRelease bestRelease = null; + UpdateKind bestKind = UpdateKind.NONE; + + for (JsonElement element : releases) { + if (!element.isJsonObject()) { + continue; + } + + JsonObject object = element.getAsJsonObject(); + + if (booleanValue(object, "draft")) { + continue; + } + + boolean prerelease = booleanValue(object, "prerelease"); + + if (prerelease && !includePrereleases) { + continue; + } + + String tagName = stringValue(object, "tag_name"); + + if (tagName == null || tagName.isBlank()) { + continue; + } + + HeadDBVersion candidateVersion = HeadDBVersion.parse(tagName); + UpdateKind candidateKind = candidateVersion.updateKindComparedTo(current, includeBuilds); + + if (candidateKind == UpdateKind.NONE) { + continue; + } + + GitHubRelease release = releaseFrom(object, tagName, prerelease, candidateVersion); + + if (bestRelease == null || release.version().compareTo(bestRelease.version()) > 0) { + bestRelease = release; + bestKind = candidateKind; + } + } + + if (bestRelease == null) { + return UpdateCheckResult.upToDate(currentVersion, Instant.now()); + } + + return UpdateCheckResult.available(currentVersion, Instant.now(), bestRelease, bestKind); + } + + private @NotNull JsonArray fetchReleases() throws IOException, InterruptedException { + URI uri = URI.create("https://api.github.com/repos/" + encodeRepository(REPOSITORY) + "/releases?per_page=" + MAX_RELEASES); + HttpRequest request = HttpRequest.newBuilder(uri) + .timeout(readTimeout) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", userAgent) + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("GitHub releases request failed with HTTP " + response.statusCode() + ": " + response.body()); + } + + JsonElement parsed = JsonParser.parseString(response.body()); + + if (!parsed.isJsonArray()) { + throw new IOException("GitHub releases response was not a JSON array."); + } + + return parsed.getAsJsonArray(); + } + + private static @NotNull GitHubRelease releaseFrom(@NotNull JsonObject object, @NotNull String tagName, boolean prerelease, @NotNull HeadDBVersion version) { + String name = stringValue(object, "name"); + + if (name == null || name.isBlank()) { + name = tagName; + } + + String htmlUrl = stringValue(object, "html_url"); + + if (htmlUrl == null || htmlUrl.isBlank()) { + htmlUrl = "https://github.com/" + REPOSITORY + "/releases/tag/" + URLEncoder.encode(tagName, StandardCharsets.UTF_8); + } + + ReleaseAsset asset = pluginAsset(object); + + return new GitHubRelease(tagName, name, htmlUrl, prerelease, instantValue(object, "published_at"), version, asset.name(), asset.downloadUrl()); + } + + private static @NotNull ReleaseAsset pluginAsset(@NotNull JsonObject release) { + JsonElement assetsElement = release.get("assets"); + + if (assetsElement == null || !assetsElement.isJsonArray()) { + return ReleaseAsset.empty(); + } + + ReleaseAsset fallback = ReleaseAsset.empty(); + JsonArray assets = assetsElement.getAsJsonArray(); + + for (JsonElement assetElement : assets) { + if (!assetElement.isJsonObject()) { + continue; + } + + JsonObject asset = assetElement.getAsJsonObject(); + String name = stringValue(asset, "name"); + String downloadUrl = stringValue(asset, "browser_download_url"); + + if (name == null || downloadUrl == null) { + continue; + } + + String normalized = name.toLowerCase(Locale.ROOT); + + if (!normalized.endsWith(".jar")) { + continue; + } + + if (normalized.contains("sources") || normalized.contains("javadoc") || normalized.contains("original")) { + continue; + } + + ReleaseAsset candidate = new ReleaseAsset(name, downloadUrl); + + if (fallback.downloadUrl() == null) { + fallback = candidate; + } + + if (normalized.contains("headdb") || normalized.equals("headdb.jar")) { + return candidate; + } + } + + return fallback; + } + + private static @NotNull String encodeRepository(@NotNull String repository) { + String normalized = repository.trim(); + int slash = normalized.indexOf('/'); + + if (slash < 1 || slash == normalized.length() - 1) { + return URLEncoder.encode(normalized, StandardCharsets.UTF_8); + } + + String owner = URLEncoder.encode(normalized.substring(0, slash), StandardCharsets.UTF_8); + String name = URLEncoder.encode(normalized.substring(slash + 1), StandardCharsets.UTF_8); + return owner + "/" + name; + } + + private static boolean booleanValue(@NotNull JsonObject object, @NotNull String key) { + JsonElement element = object.get(key); + + if (element == null || element.isJsonNull()) { + return false; + } + + return element.getAsBoolean(); + } + + private static @Nullable String stringValue(@NotNull JsonObject object, @NotNull String key) { + JsonElement element = object.get(key); + + if (element == null || element.isJsonNull()) { + return null; + } + + return element.getAsString(); + } + + private static @Nullable Instant instantValue(@NotNull JsonObject object, @NotNull String key) { + String value = stringValue(object, key); + + if (value == null || value.isBlank()) { + return null; + } + + try { + return Instant.parse(value); + } catch (RuntimeException ignored) { + return null; + } + } + + private record ReleaseAsset(@Nullable String name, @Nullable String downloadUrl) { + + private static @NotNull ReleaseAsset empty() { + return new ReleaseAsset(null, null); + } + + } + +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/HeadDBPluginJarMetadata.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/HeadDBPluginJarMetadata.java new file mode 100644 index 0000000..c9b4c48 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/HeadDBPluginJarMetadata.java @@ -0,0 +1,186 @@ +package io.github.silentdevelopment.headdb.paper.updater; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Properties; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +final class HeadDBPluginJarMetadata { + + static final String PLUGIN_NAME = "HeadDB"; + static final String PLUGIN_MAIN_CLASS = "io.github.silentdevelopment.headdb.paper.HeadDBPlugin"; + + private final String name; + private final String mainClass; + private final String paperPluginVersion; + private final String buildVersion; + + private HeadDBPluginJarMetadata(@Nullable String name, @Nullable String mainClass, @Nullable String paperPluginVersion, @Nullable String buildVersion) { + this.name = name; + this.mainClass = mainClass; + this.paperPluginVersion = paperPluginVersion; + this.buildVersion = buildVersion; + } + + static @NotNull HeadDBPluginJarMetadata read(@NotNull Path jar) throws IOException { + Objects.requireNonNull(jar, "jar"); + + if (!Files.isRegularFile(jar)) { + throw new IOException("Plugin jar is not a regular file: " + jar); + } + + try (JarFile jarFile = new JarFile(jar.toFile())) { + return read(jarFile); + } + } + + static @NotNull HeadDBPluginJarMetadata read(@NotNull JarFile jarFile) throws IOException { + Objects.requireNonNull(jarFile, "jarFile"); + + PaperPluginYaml paperPluginYaml = readPaperPluginYaml(jarFile); + String buildVersion = readGitPropertiesVersion(jarFile); + return new HeadDBPluginJarMetadata(paperPluginYaml.name(), paperPluginYaml.mainClass(), paperPluginYaml.version(), buildVersion); + } + + void validateDownloadedUpdate(@NotNull String expectedVersion) throws IOException { + Objects.requireNonNull(expectedVersion, "expectedVersion"); + + if (!PLUGIN_NAME.equals(name)) { + throw new IOException("Downloaded jar plugin name is not " + PLUGIN_NAME + "."); + } + + if (!PLUGIN_MAIN_CLASS.equals(mainClass)) { + throw new IOException("Downloaded jar main class is not " + PLUGIN_MAIN_CLASS + "."); + } + + String actualVersion = preferredVersion(); + + if (actualVersion == null || actualVersion.isBlank()) { + throw new IOException("Downloaded jar version could not be verified."); + } + + HeadDBVersion expected = HeadDBVersion.parse(expectedVersion); + HeadDBVersion actual = HeadDBVersion.parse(actualVersion); + + if (actual.compareTo(expected) == 0) { + return; + } + + throw new IOException("Downloaded jar version " + actualVersion + " does not match expected release version " + expectedVersion + "."); + } + + @Nullable String preferredVersion() { + if (buildVersion != null && !buildVersion.isBlank()) { + return buildVersion; + } + + return paperPluginVersion; + } + + @Nullable String name() { + return name; + } + + @Nullable String mainClass() { + return mainClass; + } + + @Nullable String paperPluginVersion() { + return paperPluginVersion; + } + + @Nullable String buildVersion() { + return buildVersion; + } + + private static @NotNull PaperPluginYaml readPaperPluginYaml(@NotNull JarFile jarFile) throws IOException { + JarEntry entry = jarFile.getJarEntry("paper-plugin.yml"); + + if (entry == null) { + throw new IOException("Plugin jar does not contain paper-plugin.yml."); + } + + String name = null; + String mainClass = null; + String version = null; + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(jarFile.getInputStream(entry), StandardCharsets.UTF_8))) { + String line; + + while ((line = reader.readLine()) != null) { + String stripped = stripComment(line).trim(); + + if (stripped.isBlank()) { + continue; + } + + if (stripped.startsWith("name:")) { + name = scalarValue(stripped, "name"); + continue; + } + + if (stripped.startsWith("main:")) { + mainClass = scalarValue(stripped, "main"); + continue; + } + + if (stripped.startsWith("version:")) { + version = scalarValue(stripped, "version"); + } + } + } + + return new PaperPluginYaml(name, mainClass, version); + } + + private static @Nullable String readGitPropertiesVersion(@NotNull JarFile jarFile) throws IOException { + JarEntry entry = jarFile.getJarEntry("git.properties"); + + if (entry == null) { + return null; + } + + Properties properties = new Properties(); + + try (InputStream input = jarFile.getInputStream(entry)) { + properties.load(input); + } + + String version = properties.getProperty("headdb.build.version"); + return version == null || version.isBlank() ? null : version.trim(); + } + + private static @NotNull String scalarValue(@NotNull String line, @NotNull String key) { + String value = line.substring((key + ":").length()).trim(); + + if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) { + return value.substring(1, value.length() - 1).trim(); + } + + return value; + } + + private static @NotNull String stripComment(@NotNull String line) { + int commentIndex = line.indexOf('#'); + + if (commentIndex < 0) { + return line; + } + + return line.substring(0, commentIndex); + } + + private record PaperPluginYaml(@Nullable String name, @Nullable String mainClass, @Nullable String version) { + } + +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/HeadDBVersion.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/HeadDBVersion.java new file mode 100644 index 0000000..e249ae3 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/HeadDBVersion.java @@ -0,0 +1,298 @@ +package io.github.silentdevelopment.headdb.paper.updater; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class HeadDBVersion implements Comparable { + + private static final Pattern BUILD_PATTERN = Pattern.compile("(?:^|[.\\-+])build[.\\-]?(\\d+)(?:$|[.\\-])", Pattern.CASE_INSENSITIVE); + + private final String raw; + private final List numbers; + private final List prereleaseParts; + private final Integer buildNumber; + + private HeadDBVersion(@NotNull String raw, @NotNull List numbers, @NotNull List prereleaseParts, @Nullable Integer buildNumber) { + this.raw = Objects.requireNonNull(raw, "raw"); + this.numbers = List.copyOf(numbers); + this.prereleaseParts = List.copyOf(prereleaseParts); + this.buildNumber = buildNumber; + } + + public static @NotNull HeadDBVersion parse(@NotNull String value) { + Objects.requireNonNull(value, "value"); + + String normalized = normalize(value); + String withoutBuild = normalized; + String buildMetadata = ""; + int buildIndex = normalized.indexOf('+'); + + if (buildIndex >= 0) { + withoutBuild = normalized.substring(0, buildIndex); + buildMetadata = normalized.substring(buildIndex + 1); + } + + String numericPart = withoutBuild; + String prereleasePart = ""; + int prereleaseIndex = withoutBuild.indexOf('-'); + + if (prereleaseIndex >= 0) { + numericPart = withoutBuild.substring(0, prereleaseIndex); + prereleasePart = withoutBuild.substring(prereleaseIndex + 1); + } + + List numbers = parseNumbers(numericPart); + List prereleaseParts = parseIdentifiers(prereleasePart); + Integer buildNumber = parseBuildNumber(buildMetadata); + + return new HeadDBVersion(normalized, numbers, prereleaseParts, buildNumber); + } + + public @NotNull String raw() { + return raw; + } + + public boolean hasBuildNumber() { + return buildNumber != null; + } + + public @Nullable Integer buildNumber() { + return buildNumber; + } + + public @NotNull UpdateKind updateKindComparedTo(@NotNull HeadDBVersion current, boolean includeBuilds) { + Objects.requireNonNull(current, "current"); + + int versionComparison = compareVersionWithoutBuild(current); + + if (versionComparison > 0) { + return UpdateKind.VERSION; + } + + if (versionComparison < 0) { + return UpdateKind.NONE; + } + + if (!includeBuilds) { + return UpdateKind.NONE; + } + + if (current.hasBuildNumber() && !hasBuildNumber()) { + return UpdateKind.VERSION; + } + + if (!current.hasBuildNumber() || !hasBuildNumber()) { + return UpdateKind.NONE; + } + + if (buildNumber > current.buildNumber) { + return UpdateKind.BUILD; + } + + return UpdateKind.NONE; + } + + @Override + public int compareTo(@NotNull HeadDBVersion other) { + Objects.requireNonNull(other, "other"); + + int versionComparison = compareVersionWithoutBuild(other); + + if (versionComparison != 0) { + return versionComparison; + } + + if (buildNumber == null && other.buildNumber == null) { + return 0; + } + + if (buildNumber == null) { + return -1; + } + + if (other.buildNumber == null) { + return 1; + } + + return Integer.compare(buildNumber, other.buildNumber); + } + + private int compareVersionWithoutBuild(@NotNull HeadDBVersion other) { + int maxNumbers = Math.max(numbers.size(), other.numbers.size()); + + for (int index = 0; index < maxNumbers; index++) { + int left = numberAt(numbers, index); + int right = numberAt(other.numbers, index); + int comparison = Integer.compare(left, right); + + if (comparison != 0) { + return comparison; + } + } + + if (prereleaseParts.isEmpty() && other.prereleaseParts.isEmpty()) { + return 0; + } + + if (prereleaseParts.isEmpty()) { + return 1; + } + + if (other.prereleaseParts.isEmpty()) { + return -1; + } + + int maxPrerelease = Math.max(prereleaseParts.size(), other.prereleaseParts.size()); + + for (int index = 0; index < maxPrerelease; index++) { + String left = partAt(prereleaseParts, index); + String right = partAt(other.prereleaseParts, index); + + if (left.isEmpty() && right.isEmpty()) { + return 0; + } + + if (left.isEmpty()) { + return -1; + } + + if (right.isEmpty()) { + return 1; + } + + int comparison = compareIdentifier(left, right); + + if (comparison != 0) { + return comparison; + } + } + + return 0; + } + + private static @NotNull String normalize(@NotNull String value) { + String normalized = value.trim(); + + if (normalized.startsWith("v") || normalized.startsWith("V")) { + normalized = normalized.substring(1); + } + + return normalized; + } + + private static @NotNull List parseNumbers(@NotNull String value) { + List numbers = new ArrayList<>(); + + for (String part : value.split("\\.")) { + if (part.isBlank()) { + numbers.add(0); + continue; + } + + try { + numbers.add(Integer.parseInt(part)); + } catch (NumberFormatException ignored) { + numbers.add(0); + } + } + + while (numbers.size() < 3) { + numbers.add(0); + } + + return numbers; + } + + private static @NotNull List parseIdentifiers(@NotNull String value) { + List identifiers = new ArrayList<>(); + + if (value.isBlank()) { + return identifiers; + } + + for (String part : value.split("[.\\-]")) { + String normalized = part.trim().toLowerCase(Locale.ROOT); + + if (!normalized.isBlank()) { + identifiers.add(normalized); + } + } + + return identifiers; + } + + private static @Nullable Integer parseBuildNumber(@NotNull String metadata) { + if (metadata.isBlank()) { + return null; + } + + Matcher matcher = BUILD_PATTERN.matcher(metadata); + + if (!matcher.find()) { + return null; + } + + try { + return Integer.parseInt(matcher.group(1)); + } catch (NumberFormatException ignored) { + return null; + } + } + + private static int numberAt(@NotNull List numbers, int index) { + if (index >= numbers.size()) { + return 0; + } + + return numbers.get(index); + } + + private static @NotNull String partAt(@NotNull List parts, int index) { + if (index >= parts.size()) { + return ""; + } + + return parts.get(index); + } + + private static int compareIdentifier(@NotNull String left, @NotNull String right) { + boolean leftNumeric = isNumeric(left); + boolean rightNumeric = isNumeric(right); + + if (leftNumeric && rightNumeric) { + return Integer.compare(Integer.parseInt(left), Integer.parseInt(right)); + } + + if (leftNumeric) { + return -1; + } + + if (rightNumeric) { + return 1; + } + + return left.compareTo(right); + } + + private static boolean isNumeric(@NotNull String value) { + if (value.isBlank()) { + return false; + } + + for (int index = 0; index < value.length(); index++) { + if (!Character.isDigit(value.charAt(index))) { + return false; + } + } + + return true; + } + +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/UpdateCheckResult.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/UpdateCheckResult.java new file mode 100644 index 0000000..4af064c --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/UpdateCheckResult.java @@ -0,0 +1,75 @@ +package io.github.silentdevelopment.headdb.paper.updater; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.Objects; + +public final class UpdateCheckResult { + + private final String currentVersion; + private final Instant checkedAt; + private final GitHubRelease release; + private final UpdateKind kind; + private final String failureMessage; + private volatile Path installedPath; + + private UpdateCheckResult(@NotNull String currentVersion, @NotNull Instant checkedAt, @Nullable GitHubRelease release, @NotNull UpdateKind kind, @Nullable String failureMessage) { + this.currentVersion = Objects.requireNonNull(currentVersion, "currentVersion"); + this.checkedAt = Objects.requireNonNull(checkedAt, "checkedAt"); + this.release = release; + this.kind = Objects.requireNonNull(kind, "kind"); + this.failureMessage = failureMessage; + } + + public static @NotNull UpdateCheckResult upToDate(@NotNull String currentVersion, @NotNull Instant checkedAt) { + return new UpdateCheckResult(currentVersion, checkedAt, null, UpdateKind.NONE, null); + } + + public static @NotNull UpdateCheckResult available(@NotNull String currentVersion, @NotNull Instant checkedAt, @NotNull GitHubRelease release, @NotNull UpdateKind kind) { + return new UpdateCheckResult(currentVersion, checkedAt, Objects.requireNonNull(release, "release"), kind, null); + } + + public static @NotNull UpdateCheckResult failed(@NotNull String currentVersion, @NotNull Instant checkedAt, @NotNull String failureMessage) { + return new UpdateCheckResult(currentVersion, checkedAt, null, UpdateKind.NONE, Objects.requireNonNull(failureMessage, "failureMessage")); + } + + public @NotNull String currentVersion() { + return currentVersion; + } + + public @NotNull Instant checkedAt() { + return checkedAt; + } + + public @Nullable GitHubRelease release() { + return release; + } + + public @NotNull UpdateKind kind() { + return kind; + } + + public @Nullable String failureMessage() { + return failureMessage; + } + + public @Nullable Path installedPath() { + return installedPath; + } + + public void markInstalled(@NotNull Path installedPath) { + this.installedPath = Objects.requireNonNull(installedPath, "installedPath"); + } + + public boolean updateAvailable() { + return release != null && kind != UpdateKind.NONE; + } + + public boolean failed() { + return failureMessage != null && !failureMessage.isBlank(); + } + +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/UpdateKind.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/UpdateKind.java new file mode 100644 index 0000000..9a6d988 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/UpdateKind.java @@ -0,0 +1,7 @@ +package io.github.silentdevelopment.headdb.paper.updater; + +public enum UpdateKind { + NONE, + BUILD, + VERSION +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/UpdateService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/UpdateService.java new file mode 100644 index 0000000..170a2f6 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/UpdateService.java @@ -0,0 +1,739 @@ +package io.github.silentdevelopment.headdb.paper.updater; + +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.command.format.VersionFormatter; +import io.github.silentdevelopment.headdb.paper.config.PluginConfig; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.runtime.BuildInfo; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.CodeSource; +import java.util.Locale; +import java.util.Objects; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class UpdateService { + + private static final String UPDATE_STATE_FILE = "updater-state.properties"; + private static final String STATE_EXPECTED_VERSION = "expected-version"; + private static final String STATE_ACTIVE_JAR = "active-jar"; + private static final String STATE_BACKUP_JAR = "backup-jar"; + + private final HeadDBPlugin plugin; + private final PluginConfig config; + private final GitHubReleaseUpdateChecker checker; + private final HttpClient downloadClient; + private final String userAgent; + private final AtomicBoolean running; + private final AtomicBoolean closed; + private volatile UpdateCheckResult lastResult; + + public UpdateService(@NotNull HeadDBPlugin plugin, @NotNull PluginConfig config) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.config = Objects.requireNonNull(config, "config"); + this.userAgent = UpdaterUserAgent.create(plugin, BuildInfo.read(plugin).version()); + this.checker = new GitHubReleaseUpdateChecker(config.connectTimeout(), config.readTimeout(), userAgent); + this.downloadClient = HttpClient.newBuilder() + .connectTimeout(config.connectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + this.running = new AtomicBoolean(false); + this.closed = new AtomicBoolean(false); + } + + public void start() { + if (!config.updateCheckerEnabled() || !config.updateCheckerCheckOnStartup()) { + plugin.getComponentLogger().info(VersionFormatter.startup(plugin, null)); + return; + } + + if (!runAsync(null, true, false)) { + plugin.getComponentLogger().info(VersionFormatter.startup(plugin, null)); + } + } + + /** + * Performs an explicit administrator-requested update check and installs an available update. + * This intentionally bypasses the automatic-install configuration flag. + */ + public boolean checkAndInstallAsync(@NotNull CommandSender requester) { + Objects.requireNonNull(requester, "requester"); + return runAsync(requester, false, true); + } + + public boolean running() { + return running.get(); + } + + public @Nullable UpdateCheckResult lastResult() { + return lastResult; + } + + public void close() { + closed.set(true); + } + + /** + * Called only after the plugin has completed its startup sequence. A backup is removed only when the + * running build is at least the version recorded when the update was staged. + */ + public void cleanupBackupAfterSuccessfulLoad() { + PendingUpdate pendingUpdate = readPendingUpdate(); + + if (pendingUpdate != null) { + cleanupPendingUpdate(currentPluginJar(), pendingUpdate); + return; + } + + Path currentJar = currentPluginJar(); + + if (currentJar != null) { + cleanupLegacyBackup(currentJar); + } + } + + private void cleanupPendingUpdate(@Nullable Path currentJar, @NotNull PendingUpdate pendingUpdate) { + String runningVersion = BuildInfo.read(plugin).version(); + HeadDBVersion running = HeadDBVersion.parse(runningVersion); + HeadDBVersion expected = HeadDBVersion.parse(pendingUpdate.expectedVersion()); + + if (running.compareTo(expected) < 0) { + plugin.getSLF4JLogger().info( + "Update backup retained because the loaded version is {} while the pending update expects {}. Restart is still required.", + runningVersion, + pendingUpdate.expectedVersion() + ); + return; + } + + if (currentJar != null && config.isDebug()) { + Path normalizedCurrentJar = currentJar.toAbsolutePath().normalize(); + Path recordedActiveJar = pendingUpdate.activeJar().toAbsolutePath().normalize(); + + if (!normalizedCurrentJar.equals(recordedActiveJar)) { + plugin.getSLF4JLogger().debug( + "Loaded plugin jar path differs from the update record. Loaded={}, recorded={}.", + normalizedCurrentJar, + recordedActiveJar + ); + } + } + + Path backup = pendingUpdate.backupJar().toAbsolutePath().normalize(); + + if (Files.exists(backup) && !deleteBackup(backup)) { + return; + } + + deleteUpdateState(); + + if (config.isDebug()) { + plugin.getSLF4JLogger().debug( + "Removed the previous update backup after successfully loading version {}.", + runningVersion + ); + } + } + + /** + * Cleans up backups created by older updater builds that did not persist an update state file. + */ + private void cleanupLegacyBackup(@NotNull Path currentJar) { + Path normalizedCurrentJar = currentJar.toAbsolutePath().normalize(); + Path backup = backupPath(normalizedCurrentJar); + + if (!Files.isRegularFile(backup)) { + return; + } + + String runningVersion = BuildInfo.read(plugin).version(); + String diskVersion = readJarVersion(normalizedCurrentJar); + String backupVersion = readJarVersion(backup); + + if (diskVersion == null || diskVersion.isBlank()) { + plugin.getSLF4JLogger().warn("Update backup retained because the active jar version could not be verified."); + return; + } + + if (backupVersion == null || backupVersion.isBlank()) { + plugin.getSLF4JLogger().warn("Update backup retained because the backup jar version could not be verified."); + return; + } + + HeadDBVersion running = HeadDBVersion.parse(runningVersion); + HeadDBVersion installed = HeadDBVersion.parse(diskVersion); + HeadDBVersion previous = HeadDBVersion.parse(backupVersion); + + if (running.compareTo(installed) != 0) { + plugin.getSLF4JLogger().info( + "Update backup retained because the jar on disk is version {} while the loaded version is {}. Restart is still required.", + diskVersion, + runningVersion + ); + return; + } + + if (installed.compareTo(previous) <= 0) { + plugin.getSLF4JLogger().warn( + "Update backup retained because its version ({}) is not older than the active version ({}).", + backupVersion, + diskVersion + ); + return; + } + + if (deleteBackup(backup) && config.isDebug()) { + plugin.getSLF4JLogger().debug( + "Removed the previous update backup after successfully loading version {}.", + runningVersion + ); + } + } + + private boolean deleteBackup(@NotNull Path backup) { + try { + return Files.deleteIfExists(backup); + } catch (IOException exception) { + plugin.getSLF4JLogger().warn("The previous update backup could not be deleted: {}", backup); + debugFailure("Backup cleanup failure details.", exception); + return false; + } + } + + private boolean deleteUnneededBackup(@NotNull Path backup) { + try { + boolean deleted = Files.deleteIfExists(backup); + + if (deleted && config.isDebug()) { + plugin.getSLF4JLogger().debug( + "Removed unneeded update backup because the active plugin jar was not replaced: {}.", + backup.toAbsolutePath().normalize() + ); + } + + return true; + } catch (IOException exception) { + plugin.getSLF4JLogger().warn("An unneeded update backup could not be deleted: {}", backup); + debugFailure("Unneeded update backup cleanup failure details.", exception); + return false; + } + } + + private boolean runAsync(@Nullable CommandSender requester, boolean startup, boolean forceInstall) { + if (closed.get()) { + return false; + } + + if (!running.compareAndSet(false, true)) { + if (requester != null) { + reply(requester, Component.text("An update check is already running.", NamedTextColor.RED)); + } + + return false; + } + + plugin.getServer().getAsyncScheduler().runNow(plugin, task -> check(requester, startup, forceInstall)); + return true; + } + + private void check(@Nullable CommandSender requester, boolean startup, boolean forceInstall) { + try { + BuildInfo buildInfo = BuildInfo.read(plugin); + UpdateCheckResult result = checker.check( + buildInfo.version(), + config.updateCheckerIncludePrereleases(), + config.updateCheckerIncludeBuilds() + ); + + lastResult = result; + + if (result.updateAvailable() && (forceInstall || config.autoUpdaterInstallUpdates())) { + installUpdate(result); + } + + notifyResult(requester, result, startup); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + UpdateCheckResult result = UpdateCheckResult.failed(currentVersion(), java.time.Instant.now(), "Update check was interrupted."); + lastResult = result; + notifyResult(requester, result, startup); + } catch (Exception exception) { + String message = exception.getMessage(); + + if (message == null || message.isBlank()) { + message = exception.getClass().getSimpleName(); + } + + UpdateCheckResult result = UpdateCheckResult.failed(currentVersion(), java.time.Instant.now(), message); + lastResult = result; + notifyResult(requester, result, startup); + debugFailure("Update check failure details.", exception); + } finally { + running.set(false); + } + } + + private void installUpdate(@NotNull UpdateCheckResult result) throws IOException, InterruptedException { + GitHubRelease release = result.release(); + + if (release == null) { + return; + } + + if (!release.hasPluginAsset()) { + throw new IOException("Release " + release.tagName() + " does not contain a downloadable jar asset."); + } + + String downloadUrl = release.assetDownloadUrl(); + + if (downloadUrl == null || downloadUrl.isBlank()) { + throw new IOException("Release " + release.tagName() + " has no downloadable jar asset."); + } + + Path currentJar = currentPluginJar(); + + if (currentJar == null) { + Path fallback = downloadToUpdateDirectory(downloadUrl, safeFileName(release.assetName()), release.version().raw()); + result.markInstalled(fallback); + return; + } + + InstallResult installResult = downloadAndInstall(downloadUrl, currentJar, release.version().raw()); + result.markInstalled(installResult.installedPath()); + + if (installResult.backupPath() != null && Files.isRegularFile(installResult.backupPath())) { + writePendingUpdate(release.version().raw(), currentJar, installResult.backupPath()); + } + } + + private @NotNull InstallResult downloadAndInstall(@NotNull String downloadUrl, @NotNull Path currentJar, @NotNull String expectedVersion) throws IOException, InterruptedException { + Path normalizedCurrentJar = currentJar.toAbsolutePath().normalize(); + Path pluginDirectory = normalizedCurrentJar.getParent(); + + if (pluginDirectory == null) { + Path fallback = downloadToUpdateDirectory(downloadUrl, normalizedCurrentJar.getFileName().toString(), expectedVersion); + return new InstallResult(fallback, null); + } + + Files.createDirectories(pluginDirectory); + + String currentFileName = normalizedCurrentJar.getFileName().toString(); + Path temporary = pluginDirectory.resolve(currentFileName + ".download"); + Files.deleteIfExists(temporary); + downloadJar(downloadUrl, temporary, expectedVersion); + + Path backup = backupPath(normalizedCurrentJar); + + try { + Files.copy(normalizedCurrentJar, backup, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException exception) { + plugin.getSLF4JLogger().warn( + "Could not create an update backup at {}. The update will be staged in the server update folder instead.", + backup + ); + debugFailure("Update backup creation failure details.", exception); + Path fallback = moveDownloadedJarToUpdateDirectory(temporary, currentFileName); + return new InstallResult(fallback, null); + } + + try { + moveReplacing(temporary, normalizedCurrentJar); + return new InstallResult(normalizedCurrentJar, backup); + } catch (IOException exception) { + if (config.isDebug()) { + plugin.getSLF4JLogger().debug( + "The active plugin jar could not be replaced while running; staging the update in the server update folder instead. Active jar: {}.", + normalizedCurrentJar, + exception + ); + } + + Path fallback = moveDownloadedJarToUpdateDirectory(temporary, currentFileName); + + if (deleteUnneededBackup(backup)) { + return new InstallResult(fallback, null); + } + + return new InstallResult(fallback, backup); + } + } + + private @NotNull Path downloadToUpdateDirectory(@NotNull String downloadUrl, @NotNull String fileName, @NotNull String expectedVersion) throws IOException, InterruptedException { + Path updateDirectory = updateDirectory(); + Files.createDirectories(updateDirectory); + + Path target = updateDirectory.resolve(safeFileName(fileName)); + Path temporary = updateDirectory.resolve(target.getFileName() + ".download"); + Files.deleteIfExists(temporary); + + downloadJar(downloadUrl, temporary, expectedVersion); + moveReplacing(temporary, target); + return target; + } + + private @NotNull Path moveDownloadedJarToUpdateDirectory(@NotNull Path downloadedJar, @NotNull String activeFileName) throws IOException { + Path updateDirectory = updateDirectory(); + Files.createDirectories(updateDirectory); + + Path target = updateDirectory.resolve(safeFileName(activeFileName)); + moveReplacing(downloadedJar, target); + return target; + } + + private void writePendingUpdate(@NotNull String expectedVersion, @NotNull Path activeJar, @NotNull Path backupJar) { + Path stateFile = updateStateFile(); + Path temporary = stateFile.resolveSibling(stateFile.getFileName() + ".download"); + Properties properties = new Properties(); + properties.setProperty(STATE_EXPECTED_VERSION, expectedVersion); + properties.setProperty(STATE_ACTIVE_JAR, activeJar.toAbsolutePath().normalize().toString()); + properties.setProperty(STATE_BACKUP_JAR, backupJar.toAbsolutePath().normalize().toString()); + + try { + Files.createDirectories(stateFile.getParent()); + Files.deleteIfExists(temporary); + + try (OutputStream output = Files.newOutputStream(temporary)) { + properties.store(output, "Pending plugin update state"); + } + + moveReplacing(temporary, stateFile); + } catch (IOException exception) { + plugin.getSLF4JLogger().warn("Could not persist updater state; backup cleanup will use metadata fallback checks."); + debugFailure("Updater state write failure details.", exception); + + try { + Files.deleteIfExists(temporary); + } catch (IOException cleanupException) { + debugFailure("Updater state temporary-file cleanup failure details.", cleanupException); + } + } + } + + private @Nullable PendingUpdate readPendingUpdate() { + Path stateFile = updateStateFile(); + + if (!Files.isRegularFile(stateFile)) { + return null; + } + + Properties properties = new Properties(); + + try (InputStream input = Files.newInputStream(stateFile)) { + properties.load(input); + + String expectedVersion = requiredProperty(properties, STATE_EXPECTED_VERSION); + Path activeJar = Path.of(requiredProperty(properties, STATE_ACTIVE_JAR)).toAbsolutePath().normalize(); + Path backupJar = Path.of(requiredProperty(properties, STATE_BACKUP_JAR)).toAbsolutePath().normalize(); + return new PendingUpdate(expectedVersion, activeJar, backupJar); + } catch (IOException | IllegalArgumentException exception) { + plugin.getSLF4JLogger().warn("Could not read updater state; backup cleanup will use metadata fallback checks."); + debugFailure("Updater state read failure details.", exception); + return null; + } + } + + private void deleteUpdateState() { + Path stateFile = updateStateFile(); + + try { + Files.deleteIfExists(stateFile); + } catch (IOException exception) { + plugin.getSLF4JLogger().warn("The updater state file could not be deleted: {}", stateFile); + debugFailure("Updater state cleanup failure details.", exception); + } + } + + private @NotNull Path updateStateFile() { + return plugin.getDataFolder().toPath().resolve(UPDATE_STATE_FILE).toAbsolutePath().normalize(); + } + + private static @NotNull String requiredProperty(@NotNull Properties properties, @NotNull String key) { + String value = properties.getProperty(key); + + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing updater state property: " + key); + } + + return value.trim(); + } + + private @NotNull Path backupPath(@NotNull Path currentJar) { + Path normalizedCurrentJar = currentJar.toAbsolutePath().normalize(); + Path parent = normalizedCurrentJar.getParent(); + + if (parent == null) { + return Path.of(normalizedCurrentJar.getFileName().toString() + ".backup").toAbsolutePath().normalize(); + } + + return parent.resolve(normalizedCurrentJar.getFileName().toString() + ".backup").toAbsolutePath().normalize(); + } + + private @Nullable String readJarVersion(@NotNull Path jar) { + try { + return HeadDBPluginJarMetadata.read(jar).preferredVersion(); + } catch (IOException exception) { + plugin.getSLF4JLogger().warn("Could not inspect plugin jar metadata at {}.", jar.toAbsolutePath().normalize()); + debugFailure("Plugin jar metadata inspection failure details.", exception); + return null; + } + } + + private void downloadJar(@NotNull String downloadUrl, @NotNull Path temporary, @NotNull String expectedVersion) throws IOException, InterruptedException { + Path parent = temporary.getParent(); + + if (parent != null) { + Files.createDirectories(parent); + } + + Files.deleteIfExists(temporary); + + HttpRequest request = HttpRequest.newBuilder(URI.create(downloadUrl)) + .timeout(config.readTimeout()) + .header("User-Agent", userAgent) + .GET() + .build(); + + HttpResponse response = downloadClient.send(request, HttpResponse.BodyHandlers.ofFile(temporary)); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + Files.deleteIfExists(temporary); + throw new IOException("Failed to download update. HTTP " + response.statusCode()); + } + + validateDownloadedJar(temporary, expectedVersion); + } + + private void validateDownloadedJar(@NotNull Path jar, @NotNull String expectedVersion) throws IOException { + if (!Files.isRegularFile(jar)) { + throw new IOException("Downloaded update is not a regular file: " + jar); + } + + if (Files.size(jar) <= 0) { + Files.deleteIfExists(jar); + throw new IOException("Downloaded update jar is empty."); + } + + try { + HeadDBPluginJarMetadata.read(jar).validateDownloadedUpdate(expectedVersion); + } catch (IOException exception) { + Files.deleteIfExists(jar); + throw exception; + } + } + + private void moveReplacing(@NotNull Path source, @NotNull Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private @Nullable Path currentPluginJar() { + CodeSource codeSource = plugin.getClass().getProtectionDomain().getCodeSource(); + + if (codeSource == null || codeSource.getLocation() == null) { + return null; + } + + try { + Path path = Path.of(codeSource.getLocation().toURI()).toAbsolutePath().normalize(); + + if (!Files.isRegularFile(path)) { + return null; + } + + String fileName = path.getFileName().toString().toLowerCase(Locale.ROOT); + return fileName.endsWith(".jar") ? path : null; + } catch (IllegalArgumentException | URISyntaxException exception) { + plugin.getSLF4JLogger().warn("Could not resolve the currently loaded plugin jar path."); + debugFailure("Loaded jar path resolution failure details.", exception); + return null; + } + } + + private void notifyResult(@Nullable CommandSender requester, @NotNull UpdateCheckResult result, boolean startup) { + if (closed.get()) { + return; + } + + plugin.getServer().getGlobalRegionScheduler().execute(plugin, () -> { + if (closed.get()) { + return; + } + + if (requester != null) { + requester.sendMessage(formatResult(result)); + return; + } + + if (startup) { + plugin.getComponentLogger().info(VersionFormatter.startup(plugin, result)); + } + + logResult(result); + + if (!startup || !result.updateAvailable() || !config.updateCheckerNotifyAdmins()) { + return; + } + + notifyAdmins(result); + }); + } + + private void logResult(@NotNull UpdateCheckResult result) { + if (result.failed()) { + if (config.updateCheckerNotifyConsole()) { + plugin.getSLF4JLogger().warn("Update check failed: {}", result.failureMessage()); + } + + return; + } + + if (!result.updateAvailable()) { + return; + } + + GitHubRelease release = result.release(); + + if (release == null) { + return; + } + + if (result.installedPath() != null) { + plugin.getComponentLogger().info(downloadedMessage(release)); + + if (config.isDebug()) { + plugin.getSLF4JLogger().debug("Update staged at {}.", result.installedPath().toAbsolutePath().normalize()); + } + + return; + } + + if (config.updateCheckerNotifyConsole()) { + plugin.getComponentLogger().info(availableMessage(release)); + } + } + + private void notifyAdmins(@NotNull UpdateCheckResult result) { + Component message = formatResult(result); + + for (Player player : plugin.getServer().getOnlinePlayers()) { + if (Permissions.has(player, Permissions.UPDATE)) { + player.sendMessage(message); + } + } + } + + private @NotNull Component formatResult(@NotNull UpdateCheckResult result) { + if (result.failed()) { + return Component.text("Update check failed: ", NamedTextColor.RED) + .append(Component.text(result.failureMessage(), NamedTextColor.GRAY)); + } + + if (!result.updateAvailable()) { + return Component.text("Latest version is already installed: ", NamedTextColor.GRAY) + .append(Component.text(result.currentVersion(), NamedTextColor.GOLD)); + } + + GitHubRelease release = result.release(); + + if (release == null) { + return Component.text("Update state is unavailable.", NamedTextColor.RED); + } + + if (result.installedPath() != null) { + return downloadedMessage(release); + } + + return availableMessage(release); + } + + private @NotNull Component downloadedMessage(@NotNull GitHubRelease release) { + return Component.text("Downloaded new version: ", NamedTextColor.GREEN) + .append(Component.text(release.tagName(), NamedTextColor.GOLD)) + .append(Component.text(" | ", NamedTextColor.GRAY)) + .append(Component.text("Restart the server to load it.", NamedTextColor.GREEN)); + } + + private @NotNull Component availableMessage(@NotNull GitHubRelease release) { + String url = downloadLink(release); + + return Component.text("New version available: ", NamedTextColor.GRAY) + .append(Component.text(release.tagName(), NamedTextColor.GOLD)) + .append(Component.text(" | Run /hdb reload or download: ", NamedTextColor.GRAY)) + .append(link(url, url)); + } + + private static @NotNull String downloadLink(@NotNull GitHubRelease release) { + String assetDownloadUrl = release.assetDownloadUrl(); + return assetDownloadUrl == null || assetDownloadUrl.isBlank() ? release.htmlUrl() : assetDownloadUrl; + } + + private @NotNull Component link(@NotNull String label, @NotNull String url) { + return Component.text(label, NamedTextColor.GOLD) + .decorate(TextDecoration.UNDERLINED) + .clickEvent(ClickEvent.openUrl(url)) + .hoverEvent(HoverEvent.showText(Component.text(url, NamedTextColor.GRAY))); + } + + private void reply(@NotNull CommandSender sender, @NotNull Component message) { + plugin.getServer().getGlobalRegionScheduler().execute(plugin, () -> sender.sendMessage(message)); + } + + private @NotNull String currentVersion() { + return BuildInfo.read(plugin).version(); + } + + private @NotNull Path updateDirectory() { + return plugin.getServer().getUpdateFolderFile().toPath().toAbsolutePath().normalize(); + } + + private void debugFailure(@NotNull String message, @NotNull Throwable throwable) { + if (config.isDebug()) { + plugin.getSLF4JLogger().debug(message, throwable); + } + } + + private static @NotNull String safeFileName(@Nullable String value) { + if (value == null || value.isBlank()) { + return "HeadDB.jar"; + } + + String cleaned = value.replace('\\', '_').replace('/', '_').trim(); + + if (cleaned.isBlank() || !cleaned.toLowerCase(Locale.ROOT).endsWith(".jar")) { + return "HeadDB.jar"; + } + + return cleaned; + } + + private record InstallResult(@NotNull Path installedPath, @Nullable Path backupPath) { + } + + private record PendingUpdate(@NotNull String expectedVersion, @NotNull Path activeJar, @NotNull Path backupJar) { + } + +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/UpdaterUserAgent.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/UpdaterUserAgent.java new file mode 100644 index 0000000..f9b3ff6 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/updater/UpdaterUserAgent.java @@ -0,0 +1,74 @@ +package io.github.silentdevelopment.headdb.paper.updater; + +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +public final class UpdaterUserAgent { + + private static final String USER_AGENT = "HeadDB-Updater"; + + private UpdaterUserAgent() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static @NotNull String create(@NotNull HeadDBPlugin plugin, @NotNull String pluginVersion) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(pluginVersion, "pluginVersion"); + + String version = sanitize(pluginVersion); + String osName = systemProperty("os.name"); + String osVersion = systemProperty("os.version"); + String osArchitecture = systemProperty("os.arch"); + String javaVersion = systemProperty("java.version"); + String serverName = sanitize(plugin.getServer().getName()); + String serverVersion = sanitize(plugin.getServer().getVersion()); + + return USER_AGENT + "/" + version + + " (" + osName + " " + osVersion + + "; " + osArchitecture + + "; Java " + javaVersion + + "; " + serverName + " " + serverVersion + + ")"; + } + + private static @NotNull String systemProperty(@NotNull String name) { + return sanitize(System.getProperty(name, "unknown")); + } + + private static @NotNull String sanitize(@NotNull String value) { + StringBuilder result = new StringBuilder(value.length()); + boolean previousWhitespace = false; + + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + + if (character < 0x20 || character > 0x7E || character == '(' || character == ')' || character == ';') { + result.append('_'); + previousWhitespace = false; + continue; + } + + if (Character.isWhitespace(character)) { + if (!previousWhitespace) { + result.append(' '); + } + + previousWhitespace = true; + continue; + } + + result.append(character); + previousWhitespace = false; + } + + String normalized = result.toString().trim(); + + if (normalized.isBlank()) { + return "unknown"; + } + + return normalized; + } +} diff --git a/headdb-platforms/headdb-paper/src/main/resources/paper-plugin.yml b/headdb-platforms/headdb-paper/src/main/resources/paper-plugin.yml index dc7c468..3230e8c 100644 --- a/headdb-platforms/headdb-paper/src/main/resources/paper-plugin.yml +++ b/headdb-platforms/headdb-paper/src/main/resources/paper-plugin.yml @@ -45,6 +45,7 @@ permissions: headdb.head-edit: true headdb.gui-admin: true headdb.database: true + headdb.admin.update: true headdb.category.*: true headdb.command.help: true @@ -52,6 +53,7 @@ permissions: description: Allows basic player HeadDB usage. children: headdb.command.help: true + headdb.command.version: true headdb.settings: true headdb.open: true headdb.browse: true @@ -199,6 +201,7 @@ permissions: headdb.command.verify: true headdb.command.refresh: true headdb.command.reload: true + headdb.admin.update: true headdb.command.itemcache: true # Command actions @@ -206,6 +209,9 @@ permissions: headdb.command.help: description: Allows using /hdb help. + headdb.command.version: + description: Allows using /hdb version. + headdb.command.status: description: Allows using /hdb status. @@ -221,6 +227,9 @@ permissions: headdb.command.reload: description: Allows using /hdb reload. + headdb.admin.update: + description: Allows using /hdb update. + headdb.command.search: description: Allows executing HeadDB searches with /hdb search and GUI search input. @@ -352,4 +361,4 @@ permissions: # Category visibility headdb.category.*: - description: Allows viewing all HeadDB categories. \ No newline at end of file + description: Allows viewing all HeadDB categories. diff --git a/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/config/ConfigDefaultsMergerTest.java b/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/config/ConfigDefaultsMergerTest.java new file mode 100644 index 0000000..7000474 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/config/ConfigDefaultsMergerTest.java @@ -0,0 +1,60 @@ +package io.github.silentdevelopment.headdb.paper.config; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConfigDefaultsMergerTest { + + @Test + void appendsMissingUpdaterSections() { + String input = "remote:\n manifest-url: https://data.headsdb.com/manifest.json\n"; + String merged = ConfigDefaultsMerger.mergeMissingDefaults(input); + + assertTrue(merged.contains("update-checker:\n")); + assertTrue(merged.contains(" enabled: true\n")); + assertTrue(merged.contains(" include-builds: true\n")); + assertTrue(merged.contains("auto-updater:\n")); + assertTrue(merged.contains(" install-updates: false\n")); + } + + @Test + void completesPartialExistingSectionWithoutDuplicatingKeys() { + String input = "update-checker:\n enabled: false\n\ndebug: false\n"; + String merged = ConfigDefaultsMerger.mergeMissingDefaults(input); + + assertEquals(1, count(merged, " enabled:")); + assertTrue(merged.contains(" check-on-startup: true\n")); + assertTrue(merged.indexOf(" include-builds: true") < merged.indexOf("debug: false")); + } + + @Test + void preservesCrlfLineSeparators() { + String input = "update-checker:\r\n enabled: false\r\n"; + String merged = ConfigDefaultsMerger.mergeMissingDefaults(input); + + assertTrue(merged.contains("\r\n check-on-startup: true\r\n")); + } + + @Test + void leavesCompleteConfigUnchanged() { + String input = "update-checker:\n enabled: true\n check-on-startup: true\n notify-console: true\n notify-admins: true\n include-prereleases: true\n include-builds: true\nauto-updater:\n install-updates: false\n"; + String merged = ConfigDefaultsMerger.mergeMissingDefaults(input); + + assertEquals(input, merged); + } + + private static int count(String text, String needle) { + int count = 0; + int index = text.indexOf(needle); + + while (index >= 0) { + count++; + index = text.indexOf(needle, index + needle.length()); + } + + return count; + } + +} diff --git a/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/updater/HeadDBPluginJarMetadataTest.java b/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/updater/HeadDBPluginJarMetadataTest.java new file mode 100644 index 0000000..ded32f5 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/updater/HeadDBPluginJarMetadataTest.java @@ -0,0 +1,82 @@ +package io.github.silentdevelopment.headdb.paper.updater; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class HeadDBPluginJarMetadataTest { + + @TempDir + private Path tempDirectory; + + @Test + void readsPaperPluginAndGitMetadata() throws Exception { + Path jar = writeJar("HeadDB", HeadDBPluginJarMetadata.PLUGIN_MAIN_CLASS, "7.0.0-rc.2", "7.0.0-rc.2+build.5"); + HeadDBPluginJarMetadata metadata = HeadDBPluginJarMetadata.read(jar); + + assertEquals("HeadDB", metadata.name()); + assertEquals(HeadDBPluginJarMetadata.PLUGIN_MAIN_CLASS, metadata.mainClass()); + assertEquals("7.0.0-rc.2", metadata.paperPluginVersion()); + assertEquals("7.0.0-rc.2+build.5", metadata.buildVersion()); + assertEquals("7.0.0-rc.2+build.5", metadata.preferredVersion()); + } + + @Test + void validatesMatchingDownloadedUpdate() throws Exception { + Path jar = writeJar("HeadDB", HeadDBPluginJarMetadata.PLUGIN_MAIN_CLASS, "7.0.0-rc.2", "7.0.0-rc.2"); + HeadDBPluginJarMetadata.read(jar).validateDownloadedUpdate("v7.0.0-rc.2"); + } + + @Test + void rejectsWrongPluginName() throws Exception { + Path jar = writeJar("OtherPlugin", HeadDBPluginJarMetadata.PLUGIN_MAIN_CLASS, "7.0.0-rc.2", "7.0.0-rc.2"); + HeadDBPluginJarMetadata metadata = HeadDBPluginJarMetadata.read(jar); + + assertThrows(IOException.class, () -> metadata.validateDownloadedUpdate("7.0.0-rc.2")); + } + + @Test + void rejectsWrongMainClass() throws Exception { + Path jar = writeJar("HeadDB", "example.Plugin", "7.0.0-rc.2", "7.0.0-rc.2"); + HeadDBPluginJarMetadata metadata = HeadDBPluginJarMetadata.read(jar); + + assertThrows(IOException.class, () -> metadata.validateDownloadedUpdate("7.0.0-rc.2")); + } + + @Test + void rejectsMismatchedVersion() throws Exception { + Path jar = writeJar("HeadDB", HeadDBPluginJarMetadata.PLUGIN_MAIN_CLASS, "7.0.0-rc.1", "7.0.0-rc.1"); + HeadDBPluginJarMetadata metadata = HeadDBPluginJarMetadata.read(jar); + + assertThrows(IOException.class, () -> metadata.validateDownloadedUpdate("7.0.0-rc.2")); + } + + private Path writeJar(String name, String mainClass, String paperVersion, String buildVersion) throws IOException { + Path jar = tempDirectory.resolve(name + "-" + paperVersion.replace('+', '-') + ".jar"); + + try (JarOutputStream output = new JarOutputStream(java.nio.file.Files.newOutputStream(jar))) { + output.putNextEntry(new JarEntry("paper-plugin.yml")); + output.write(("name: \"" + name + "\"\nmain: " + mainClass + "\nversion: '" + paperVersion + "'\n").getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + + if (buildVersion == null) { + return jar; + } + + output.putNextEntry(new JarEntry("git.properties")); + output.write(("headdb.build.version=" + buildVersion + "\n").getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + + return jar; + } + +} diff --git a/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/updater/HeadDBVersionTest.java b/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/updater/HeadDBVersionTest.java new file mode 100644 index 0000000..960260b --- /dev/null +++ b/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/updater/HeadDBVersionTest.java @@ -0,0 +1,44 @@ +package io.github.silentdevelopment.headdb.paper.updater; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HeadDBVersionTest { + + @Test + void releaseVersionBeatsPrerelease() { + HeadDBVersion release = HeadDBVersion.parse("7.0.0"); + HeadDBVersion candidate = HeadDBVersion.parse("7.0.0-rc.2"); + + assertTrue(release.compareTo(candidate) > 0); + assertEquals(UpdateKind.VERSION, release.updateKindComparedTo(candidate, true)); + } + + @Test + void buildMetadataCanBeTreatedAsUpdate() { + HeadDBVersion current = HeadDBVersion.parse("7.0.0-rc.2+build.4"); + HeadDBVersion candidate = HeadDBVersion.parse("v7.0.0-rc.2+build.5"); + + assertEquals(UpdateKind.BUILD, candidate.updateKindComparedTo(current, true)); + assertEquals(UpdateKind.NONE, candidate.updateKindComparedTo(current, false)); + } + + @Test + void candidateWithoutBuildBeatsCurrentBuildOfSameBaseVersion() { + HeadDBVersion current = HeadDBVersion.parse("7.0.0-rc.2+build.5"); + HeadDBVersion candidate = HeadDBVersion.parse("7.0.0-rc.2"); + + assertEquals(UpdateKind.VERSION, candidate.updateKindComparedTo(current, true)); + } + + @Test + void olderVersionIsNotUpdate() { + HeadDBVersion current = HeadDBVersion.parse("7.0.0"); + HeadDBVersion candidate = HeadDBVersion.parse("7.0.0-rc.9"); + + assertEquals(UpdateKind.NONE, candidate.updateKindComparedTo(current, true)); + } + +}