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));
+ }
+
+}