diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 277d3c76a..ed596d811 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,19 +9,20 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref_name != 'main' }} jobs: build: name: Build and Publish zMenu - uses: GroupeZ-dev/actions/.github/workflows/build.yml@main + uses: GroupeZ-dev/actions/.github/workflows/build.yml@v1.0.0 with: project-name: "zMenu" java-version: '25' publish: true project-to-publish: "API:publish" - build-command: './gradlew build' - discord-avatar-url: "https://minecraft-inventory-builder.com/storage/images/9UgcfGZyrmbVrXw5lbj5kXq6fW8F4nhwj6Cx4nVG.png" + discord-avatar-url: "https://groupez.dev/storage/images/253.png" + artifact-glob: "target/*.jar" + changelog-exclude-pattern: '^[a-f0-9]+\s+(build|chore)\(deps\)' secrets: WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }} MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} diff --git a/API/src/main/java/fr/maxlego08/menu/api/PacketManager.java b/API/src/main/java/fr/maxlego08/menu/api/PacketManager.java index 1d330714e..6bac78ae8 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/PacketManager.java +++ b/API/src/main/java/fr/maxlego08/menu/api/PacketManager.java @@ -10,6 +10,10 @@ public interface PacketManager { void onEnable(); + default boolean isReady() { + return true; + } + void onPostEnable(); void onDisable(); diff --git a/API/src/main/java/fr/maxlego08/menu/api/loader/ClassRegistry.java b/API/src/main/java/fr/maxlego08/menu/api/loader/ClassRegistry.java index 8f84d4e7a..8ccd6144b 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/loader/ClassRegistry.java +++ b/API/src/main/java/fr/maxlego08/menu/api/loader/ClassRegistry.java @@ -2,6 +2,7 @@ import org.bukkit.plugin.Plugin; +import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; @@ -37,6 +38,9 @@ public ClassRegistry tryNoArgsConstructor() { } public boolean load(P plugin, Class clazz) { + + Throwable failure = null; + for (ConstructorStrategy

strategy : this.strategies) { try { Object instance = strategy.instantiate(clazz, plugin); @@ -44,20 +48,28 @@ public boolean load(P plugin, Class clazz) { this.registrar.accept(this.expectedType.cast(instance)); return true; } - } catch (Exception ignored) { + } catch (NoSuchMethodException ignored) { + } catch (InvocationTargetException exception) { + if (failure == null) failure = exception.getCause() == null ? exception : exception.getCause(); + } catch (Throwable throwable) { + if (failure == null) failure = throwable; } } if (this.errorLogger != null) { - StringBuilder stringBuilder = new StringBuilder(); - stringBuilder.append("Could not find a valid constructor for ").append(clazz.getName()).append(". Available constructors: "); - for (int i = 0; i < clazz.getDeclaredConstructors().length; i++) { - stringBuilder.append(clazz.getDeclaredConstructors()[i]); - if (i < clazz.getDeclaredConstructors().length - 1) { - stringBuilder.append(", "); + if (failure != null) { + this.errorLogger.accept("Could not load " + clazz.getName() + ", its constructor threw " + failure + ". This usually means the plugin it hooks into is missing or failed to start."); + } else { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append("Could not find a valid constructor for ").append(clazz.getName()).append(". Available constructors: "); + for (int i = 0; i < clazz.getDeclaredConstructors().length; i++) { + stringBuilder.append(clazz.getDeclaredConstructors()[i]); + if (i < clazz.getDeclaredConstructors().length - 1) { + stringBuilder.append(", "); + } } + this.errorLogger.accept(stringBuilder.toString()); } - this.errorLogger.accept(stringBuilder.toString()); } return false; diff --git a/API/src/main/java/fr/maxlego08/menu/api/utils/version/VersionFilter.java b/API/src/main/java/fr/maxlego08/menu/api/utils/version/VersionFilter.java index d328bb3ff..857a630ae 100644 --- a/API/src/main/java/fr/maxlego08/menu/api/utils/version/VersionFilter.java +++ b/API/src/main/java/fr/maxlego08/menu/api/utils/version/VersionFilter.java @@ -36,9 +36,15 @@ public static boolean passes(@NotNull Class clazz) { String requiredVersionStr = requiresPlugin.version(); if (!requiredVersionStr.isEmpty()) { - PluginVersion pluginVersion = PluginVersion.parse(plugin.getDescription().getVersion()); - PluginVersion requiredVersion = PluginVersion.parse(requiredVersionStr); - if (!requiresPlugin.type().compare(pluginVersion.compareTo(requiredVersion))) { + try { + PluginVersion pluginVersion = PluginVersion.parse(plugin.getDescription().getVersion()); + PluginVersion requiredVersion = PluginVersion.parse(requiredVersionStr); + if (!requiresPlugin.type().compare(pluginVersion.compareTo(requiredVersion))) { + return false; + } + } catch (Throwable throwable) { + // An unparsable version from a third-party plugin must not abort the whole scan. + Logger.info("Could not compare the version of " + requiresPlugin.value() + " for " + clazz.getName() + ", skipping it: " + throwable.getMessage(), Logger.LogType.WARNING); return false; } } @@ -79,15 +85,16 @@ public static int scanAndRegister( int count = 0; for (Class clazz : reflection.getTypesAnnotatedWith(annotation)) { - if (!registry.getExpectedType().isAssignableFrom(clazz)) continue; - if (!passes(clazz)) continue; try { + if (!registry.getExpectedType().isAssignableFrom(clazz)) continue; + if (!passes(clazz)) continue; if (registry.load(plugin, clazz)) count++; - } catch (Exception e) { - if (Configuration.enableDebug) { - Logger.error("Failed to load class " + clazz.getName() + " for plugin " + plugin.getName() + " with annotation " + annotation.getSimpleName() + " due to: " + e.getMessage() + ". Please check reporte this error to the plugin developer (" + plugin.getDescription().getAuthors() + ")"); - Logger.error(e); - } + } catch (Throwable throwable) { + // Throwable, not Exception: a hook whose plugin is missing or broken fails with + // NoClassDefFoundError or ExceptionInInitializerError, and one of those must not + // stop the remaining classes from being registered. + Logger.error("Failed to load class " + clazz.getName() + " for plugin " + plugin.getName() + " with annotation " + annotation.getSimpleName() + " due to: " + throwable + ". The rest of zMenu is unaffected, please report this to the plugin developer (" + plugin.getDescription().getAuthors() + ")"); + if (Configuration.enableDebug) Logger.error(throwable); } } diff --git a/Common/build.gradle.kts b/Common/build.gradle.kts index b7ee220d2..4ac613b09 100644 --- a/Common/build.gradle.kts +++ b/Common/build.gradle.kts @@ -2,4 +2,5 @@ dependencies { api(projects.api) api(projects.nms.base) compileOnly(libs.paper.api) -} \ No newline at end of file + testImplementation(libs.paper.api) +} diff --git a/Common/src/test/java/fr/maxlego08/menu/test/common/ClassRegistryTest.java b/Common/src/test/java/fr/maxlego08/menu/test/common/ClassRegistryTest.java new file mode 100644 index 000000000..07abc0294 --- /dev/null +++ b/Common/src/test/java/fr/maxlego08/menu/test/common/ClassRegistryTest.java @@ -0,0 +1,96 @@ +package fr.maxlego08.menu.test.common; + +import fr.maxlego08.menu.api.loader.ClassRegistry; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A hook whose plugin is missing or broken fails while its loader is being constructed. Those + * failures must stay contained here, and must be reported for what they are, so that one bad hook + * neither aborts the scan nor gets logged as a missing constructor. + */ +class ClassRegistryTest { + + public static class Working implements Runnable { + @Override + public void run() { + } + } + + public static class ThrowsException implements Runnable { + public ThrowsException() { + throw new NullPointerException("the plugin API is null"); + } + + @Override + public void run() { + } + } + + public static class ThrowsError implements Runnable { + public ThrowsError() { + throw new NoClassDefFoundError("com/example/Missing"); + } + + @Override + public void run() { + } + } + + public static class NoUsableConstructor implements Runnable { + public NoUsableConstructor(String unsupported) { + } + + @Override + public void run() { + } + } + + private final List registered = new ArrayList<>(); + private final List errors = new ArrayList<>(); + + private ClassRegistry registry() { + return ClassRegistry.of(Runnable.class, this.registered::add) + .tryNoArgsConstructor() + .errorLogger(this.errors::add); + } + + @Test + void aWorkingClassIsRegistered() { + assertTrue(this.registry().load(null, Working.class)); + assertEquals(1, this.registered.size()); + assertTrue(this.errors.isEmpty(), "a successful load must not log an error: " + this.errors); + } + + @Test + void aConstructorThrowingAnExceptionIsContained() { + assertFalse(this.registry().load(null, ThrowsException.class)); + assertTrue(this.registered.isEmpty()); + assertEquals(1, this.errors.size()); + assertTrue(this.errors.getFirst().contains("constructor threw"), "the real cause must be reported, not a missing constructor: " + this.errors.getFirst()); + assertTrue(this.errors.getFirst().contains("the plugin API is null"), "the message of the failure must be kept: " + this.errors.getFirst()); + } + + @Test + void aConstructorThrowingAnErrorIsContained() { + assertFalse(this.registry().load(null, ThrowsError.class)); + assertTrue(this.registered.isEmpty()); + assertEquals(1, this.errors.size()); + assertTrue(this.errors.getFirst().contains("NoClassDefFoundError"), "the linkage failure must be named: " + this.errors.getFirst()); + } + + @Test + void aMissingConstructorIsStillReportedAsSuch() { + assertFalse(this.registry().load(null, NoUsableConstructor.class)); + assertTrue(this.registered.isEmpty()); + assertEquals(1, this.errors.size()); + assertTrue(this.errors.getFirst().contains("Could not find a valid constructor"), "a genuinely missing constructor keeps its own message: " + this.errors.getFirst()); + } +} diff --git a/Hooks/PacketEvents/build.gradle.kts b/Hooks/PacketEvents/build.gradle.kts index b6240730f..c78103c5f 100644 --- a/Hooks/PacketEvents/build.gradle.kts +++ b/Hooks/PacketEvents/build.gradle.kts @@ -3,4 +3,8 @@ group = "Hooks:PacketEvents" dependencies { compileOnly(projects.common) compileOnly(libs.packetevents) -} \ No newline at end of file + + testImplementation(projects.common) + testImplementation(libs.packetevents) + testImplementation(libs.paper.api) +} diff --git a/Hooks/PacketEvents/src/main/java/fr/maxlego08/menu/hooks/packetevents/PacketUtils.java b/Hooks/PacketEvents/src/main/java/fr/maxlego08/menu/hooks/packetevents/PacketUtils.java index f805cb4bd..cfa33bba9 100644 --- a/Hooks/PacketEvents/src/main/java/fr/maxlego08/menu/hooks/packetevents/PacketUtils.java +++ b/Hooks/PacketEvents/src/main/java/fr/maxlego08/menu/hooks/packetevents/PacketUtils.java @@ -1,6 +1,7 @@ package fr.maxlego08.menu.hooks.packetevents; import com.github.retrooper.packetevents.PacketEvents; +import com.github.retrooper.packetevents.PacketEventsAPI; import com.github.retrooper.packetevents.event.EventManager; import com.github.retrooper.packetevents.event.PacketListenerPriority; import com.github.retrooper.packetevents.manager.player.PlayerManager; @@ -31,7 +32,6 @@ import java.util.UUID; public class PacketUtils implements InventoryListener, PacketManager { - private final PlayerManager playerManager = PacketEvents.getAPI().getPlayerManager(); private PacketAnimationListener packetAnimationListener; private PacketTitleListener packetTitleListener; @@ -40,20 +40,53 @@ public class PacketUtils implements InventoryListener, PacketManager { public static final Map fakeContents = new HashMap<>(); private final MenuPlugin plugin; + private boolean ownsApi; + private boolean ready; + public PacketUtils(MenuPlugin plugin) { this.plugin = plugin; } + + private PacketEventsAPI api() { + PacketEventsAPI api = PacketEvents.getAPI(); + if (api == null) { + throw new IllegalStateException("The packetevents API is not available, the packetevents plugin most likely failed to load."); + } + return api; + } + @Override + @SuppressWarnings("ConstantConditions") + public boolean isReady() { + return this.ready && PacketEvents.getAPI() != null; + } + + @Override + @SuppressWarnings("ConstantConditions") public void onLoad() { - PacketEvents.setAPI(SpigotPacketEventsBuilder.build(this.plugin)); - PacketEvents.getAPI().load(); + if (PacketEvents.getAPI() == null) { + PacketEvents.setAPI(SpigotPacketEventsBuilder.build(this.plugin)); + this.ownsApi = true; + } + if (this.ownsApi) { + this.api().load(); + } } @Override + @SuppressWarnings("ConstantConditions") public void onEnable() { - PacketEvents.getAPI().init(); - EventManager eventManager = PacketEvents.getAPI().getEventManager(); + if (PacketEvents.getAPI() == null) { + Logger.info("The packetevents API is not available, packet features are disabled.", Logger.LogType.WARNING); + return; + } + + if (this.ownsApi) { + this.api().init(); + } + + EventManager eventManager = this.api().getEventManager(); // eventManager.registerListener(new PacketListener(), PacketListenerPriority.LOW); eventManager.registerListener(this.packetAnimationListener = new PacketAnimationListener(this.plugin), PacketListenerPriority.LOW); eventManager.registerListener(this.packetTitleListener = new PacketTitleListener(), PacketListenerPriority.LOW); @@ -61,6 +94,8 @@ public void onEnable() { this.packetEventClickLimiterListener = new PacketEventClickLimiterListener(); eventManager.registerListener(this.packetEventClickLimiterListener, PacketListenerPriority.HIGH); } + + this.ready = true; } @Override @@ -71,8 +106,12 @@ public void onPostEnable() { } @Override + @SuppressWarnings("ConstantConditions") public void onDisable() { - PacketEvents.getAPI().terminate(); + this.ready = false; + if (this.ownsApi && PacketEvents.getAPI() != null) { + this.api().terminate(); + } } @Override @@ -132,13 +171,16 @@ public PacketTitleListener getPacketTitleListener() { @Override public void editInventoryTitleName(@NotNull Player player, @NotNull Component title) { + if (!this.isReady()) return; + this.packetTitleListener.getPlayerPacketInformation(player.getUniqueId()).ifPresent(playerPacketInformation -> { WrapperPlayServerOpenWindow wrapperPlayServerOpenWindow = playerPacketInformation.getWrapperPlayServerOpenWindow(); WrapperPlayServerOpenWindow newWrapperPlayServerOpenWindow1 = new WrapperPlayServerOpenWindow(wrapperPlayServerOpenWindow.getContainerId(), wrapperPlayServerOpenWindow.getType(), title); - this.playerManager.sendPacket(player, newWrapperPlayServerOpenWindow1); - this.playerManager.sendPacket(player, playerPacketInformation.getWrapperPlayServerWindowItems()); + PlayerManager playerManager = this.api().getPlayerManager(); + playerManager.sendPacket(player, newWrapperPlayServerOpenWindow1); + playerManager.sendPacket(player, playerPacketInformation.getWrapperPlayServerWindowItems()); }); } diff --git a/Hooks/PacketEvents/src/main/java/fr/maxlego08/menu/hooks/packetevents/animation/PacketPlayerTitleAnimation.java b/Hooks/PacketEvents/src/main/java/fr/maxlego08/menu/hooks/packetevents/animation/PacketPlayerTitleAnimation.java index 9f772678d..2550ba93c 100644 --- a/Hooks/PacketEvents/src/main/java/fr/maxlego08/menu/hooks/packetevents/animation/PacketPlayerTitleAnimation.java +++ b/Hooks/PacketEvents/src/main/java/fr/maxlego08/menu/hooks/packetevents/animation/PacketPlayerTitleAnimation.java @@ -17,10 +17,14 @@ public class PacketPlayerTitleAnimation extends PlayerTitleAnimation { private final WrapperPlayServerOpenWindow wrapperPlayServerOpenWindow; - private final PlayerManager playerManager = PacketEvents.getAPI().getPlayerManager(); private WrapperPlayServerWindowItems wrapperPlayServerWindowItems; + @SuppressWarnings("ConstantConditions") + private PlayerManager playerManager() { + return PacketEvents.getAPI() == null ? null : PacketEvents.getAPI().getPlayerManager(); + } + public PacketPlayerTitleAnimation(MenuPlugin plugin, TitleAnimationSettings settings, int containerId, InventoryType type, int size, Object... args) { super(plugin, settings, containerId, type, size); this.wrapperPlayServerOpenWindow = (WrapperPlayServerOpenWindow) args[0]; @@ -32,14 +36,18 @@ public void setWrapperPlayServerWindowItems(WrapperPlayServerWindowItems wrapper @Override public void sendTitle(@NonNull Player player, @NonNull String title) { + PlayerManager playerManager = this.playerManager(); + if (playerManager == null) return; + this.wrapperPlayServerOpenWindow.setTitle(this.metaUpdater.getComponent(title)); - this.playerManager.sendPacket(player, this.wrapperPlayServerOpenWindow); + playerManager.sendPacket(player, this.wrapperPlayServerOpenWindow); } @Override public void sendInventoryContent(@NotNull Player player, @NotNull List inventoryContents) { - if (this.wrapperPlayServerWindowItems != null) { - this.playerManager.sendPacket(player, this.wrapperPlayServerWindowItems); + PlayerManager playerManager = this.playerManager(); + if (playerManager != null && this.wrapperPlayServerWindowItems != null) { + playerManager.sendPacket(player, this.wrapperPlayServerWindowItems); } } } \ No newline at end of file diff --git a/Hooks/PacketEvents/src/test/java/fr/maxlego08/menu/hooks/packetevents/PacketUtilsTest.java b/Hooks/PacketEvents/src/test/java/fr/maxlego08/menu/hooks/packetevents/PacketUtilsTest.java new file mode 100644 index 000000000..7a3d60ad2 --- /dev/null +++ b/Hooks/PacketEvents/src/test/java/fr/maxlego08/menu/hooks/packetevents/PacketUtilsTest.java @@ -0,0 +1,78 @@ +package fr.maxlego08.menu.hooks.packetevents; + +import com.github.retrooper.packetevents.PacketEvents; +import fr.maxlego08.menu.zcore.logger.Logger; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PacketUtilsTest { + + private static final class CapturingLogger extends Logger { + private final List messages = new ArrayList<>(); + + private CapturingLogger() { + super("zMenu-Test"); + } + + @Override + public void log(@NotNull String message, @NotNull LogType type, @NotNull Object... args) { + this.messages.add(message); + } + } + + private CapturingLogger logger; + + @BeforeEach + void setUp() { + PacketEvents.setAPI(null); + this.logger = new CapturingLogger(); + } + + @AfterEach + void tearDown() { + PacketEvents.setAPI(null); + } + + @Test + void theHookCanBeBuiltWithoutTheApi() { + assertDoesNotThrow(() -> new PacketUtils(null), "constructing the hook must never touch the packetevents API"); + } + + @Test + void theHookIsNotReadyBeforeItIsEnabled() { + assertFalse(new PacketUtils(null).isReady()); + } + + @Test + void enablingWithoutTheApiIsReportedAndLeavesTheHookUnused() { + PacketUtils packetUtils = new PacketUtils(null); + + assertDoesNotThrow(packetUtils::onEnable, "a missing packetevents API must not fail zMenu's enable"); + assertFalse(packetUtils.isReady(), "the hook must not claim to be usable without the API"); + assertTrue(this.logger.messages.stream().anyMatch(message -> message.contains("packetevents API is not available")), "the administrator must be told why packet features are off: " + this.logger.messages); + } + + @Test + void disablingWithoutTheApiDoesNothing() { + PacketUtils packetUtils = new PacketUtils(null); + packetUtils.onEnable(); + + assertDoesNotThrow(packetUtils::onDisable); + } + + @Test + void editingATitleWithoutTheApiIsIgnored() { + PacketUtils packetUtils = new PacketUtils(null); + + assertDoesNotThrow(() -> packetUtils.editInventoryTitleName(null, net.kyori.adventure.text.Component.text("title"))); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24981c68a..d5e3b671f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ folialib = "0.5.1" xseries = "13.8.0" exp4j = "0.4.8" java-websocket = "1.6.0" -paper-dispatch = "1.0.3" +paper-dispatch = "1.1.0" faststats = "0.30.1" # Integration Hooks diff --git a/src/main/java/fr/maxlego08/menu/ZInventoryManager.java b/src/main/java/fr/maxlego08/menu/ZInventoryManager.java index 7f9c662be..0187f0111 100644 --- a/src/main/java/fr/maxlego08/menu/ZInventoryManager.java +++ b/src/main/java/fr/maxlego08/menu/ZInventoryManager.java @@ -44,7 +44,6 @@ import fr.maxlego08.menu.zcore.logger.Logger; import fr.maxlego08.menu.zcore.logger.Logger.LogType; import fr.maxlego08.menu.zcore.utils.PerformanceDebug; -import fr.maxlego08.menu.zcore.utils.plugins.Plugins; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; @@ -462,10 +461,7 @@ public void loadButtons() { if (this.plugin.getDialogManager() != null) { buttonManager.registerAction(new DialogLoader(this.plugin, this.plugin.getDialogManager())); } - if (this.plugin.isActive(Plugins.PACKETEVENTS)) { - Optional packetManager = this.plugin.getPacketManager(); - packetManager.ifPresent(manager -> buttonManager.registerAction(new PacketEventChangeTitleNameLoader(manager))); - } + this.plugin.getPacketManager().ifPresent(manager -> buttonManager.registerAction(new PacketEventChangeTitleNameLoader(manager))); if (this.plugin.getBedrockManager() != null) { buttonManager.registerAction(new BedrockLoader(this.plugin, this.plugin.getBedrockManager())); } diff --git a/src/main/java/fr/maxlego08/menu/ZMenuPlugin.java b/src/main/java/fr/maxlego08/menu/ZMenuPlugin.java index f1bb21952..1156237a4 100644 --- a/src/main/java/fr/maxlego08/menu/ZMenuPlugin.java +++ b/src/main/java/fr/maxlego08/menu/ZMenuPlugin.java @@ -178,10 +178,11 @@ public static ZMenuPlugin getInstance() { @Override public void onLoad() { if (this.isActive(Plugins.PACKETEVENTS)) { - this.packetManager = new PacketUtils(this); - } - if (this.packetManager != null) { - this.packetManager.onLoad(); + this.safeHook("packetevents", () -> { + PacketManager manager = new PacketUtils(this); + manager.onLoad(); + this.packetManager = manager; + }); } } @@ -200,11 +201,14 @@ public void onEnable() { this.websiteManager = new ZWebsiteManager(this); // Create a website manager after loading config.yml, for API URL. Never change the URL, only for dev purposes Configuration.HAS_DIALOG_SUPPORT = this.isDialogCapableServer() && Configuration.enableMiniMessageFormat; - Configuration.HAS_BEDROCK_INVENTORY_SUPPORT = this.isActive(Plugins.GEYSER) || this.isActive(Plugins.FLOODGATE); + Configuration.HAS_BEDROCK_INVENTORY_SUPPORT = this.hasBedrockSupport(); OfflinePlayerCache.install(this); if (this.packetManager != null) { - this.packetManager.onEnable(); + boolean enabled = this.safeHook("packetevents", () -> this.packetManager.onEnable()); + if (!enabled || !this.packetManager.isReady()) { + this.packetManager = null; + } } fr.traqueur.currencies.CurrenciesAPI.init(this); @@ -254,57 +258,73 @@ public void onEnable() { // Order matters: translation plugins are asked first because they rewrite the // handshake protocol number Paper reads, which would otherwise report the server // version for every translated client. - if (this.isActive(Plugins.VIAVERSION)) { - Logger.info("ViaVersion detected, loading client version detection"); - this.clientVersionManager.registerProvider(new ViaVersionClientVersionProvider()); + if (this.isEnable(Plugins.VIAVERSION)) { + this.safeHook("ViaVersion", () -> { + Logger.info("ViaVersion detected, loading client version detection"); + this.clientVersionManager.registerProvider(new ViaVersionClientVersionProvider()); + }); } - if (this.isActive(Plugins.PROTOCOLSUPPORT)) { - Logger.info("ProtocolSupport detected, loading client version detection"); - this.clientVersionManager.registerProvider(new ProtocolSupportClientVersionProvider()); + if (this.isEnable(Plugins.PROTOCOLSUPPORT)) { + this.safeHook("ProtocolSupport", () -> { + Logger.info("ProtocolSupport detected, loading client version detection"); + this.clientVersionManager.registerProvider(new ProtocolSupportClientVersionProvider()); + }); } if (this.isPaperOrFolia()) { - this.clientVersionManager.registerProvider(new PaperProtocolClientVersionProvider()); + this.safeHook("Paper client version", () -> this.clientVersionManager.registerProvider(new PaperProtocolClientVersionProvider())); } this.addListener(this.clientVersionManager); servicesManager.register(ClientVersionManager.class, this.clientVersionManager, this, ServicePriority.Highest); if (this.isDialogCapableServer()) { if (Configuration.enableMiniMessageFormat) { - Logger.info("Paper server detected, loading Dialogs support"); - ConfigManager configManager = new ConfigManager(this); - this.dialogManager = new ZDialogManager(this, configManager); - this.addListener((ZDialogManager) this.dialogManager); - servicesManager.register(DialogManager.class, this.dialogManager, this, ServicePriority.Highest); - ConfigDialogBuilder configDialogBuilder = new ConfigDialogBuilder("zMenu Config", "zMenu Configuration"); - configManager.registerConfig(configDialogBuilder, Configuration.class, this); + boolean loaded = this.safeHook("Dialogs", () -> { + Logger.info("Paper server detected, loading Dialogs support"); + ConfigManager configManager = new ConfigManager(this); + this.dialogManager = new ZDialogManager(this, configManager); + this.addListener((ZDialogManager) this.dialogManager); + servicesManager.register(DialogManager.class, this.dialogManager, this, ServicePriority.Highest); + ConfigDialogBuilder configDialogBuilder = new ConfigDialogBuilder("zMenu Config", "zMenu Configuration"); + configManager.registerConfig(configDialogBuilder, Configuration.class, this); + }); + if (!loaded) { + this.dialogManager = null; + Configuration.HAS_DIALOG_SUPPORT = false; + } } else { Logger.info("Paper server detected but MiniMessage format is disabled, Dialogs support will not be loaded. Enable MiniMessage format in config.yml to use Dialogs."); } } - if (this.isActive(Plugins.GEYSER) || this.isActive(Plugins.FLOODGATE)) { - Logger.info("Geyser or Floodgate detected, loading Bedrock Inventory support"); - this.bedrockManager = new ZBedrockManager(this); - this.addListener(new BedrockReplacementListener(this.bedrockManager)); - servicesManager.register(BedrockManager.class, this.bedrockManager, this, ServicePriority.Highest); + if (this.hasBedrockSupport()) { + boolean loaded = this.safeHook("Bedrock", () -> { + Logger.info("Geyser or Floodgate detected, loading Bedrock Inventory support"); + this.bedrockManager = new ZBedrockManager(this); + this.addListener(new BedrockReplacementListener(this.bedrockManager)); + servicesManager.register(BedrockManager.class, this.bedrockManager, this, ServicePriority.Highest); + }); + if (!loaded) { + this.bedrockManager = null; + Configuration.HAS_BEDROCK_INVENTORY_SUPPORT = false; + } } this.registerInventory(EnumInventory.INVENTORY_DEFAULT, new InventoryDefault()); if (MinecraftVersion.getCurrentVersion().isAtLeast(MinecraftVersion.parse("1.21")) && !this.isMockBukkitServer) { this.vinventoryManager.registerInventory(EnumInventory.INVENTORY_DEFAULT.getId(), InventoryType.ANVIL, new AnvilInventoryDefault()); } - this.commandManagerLib.registerCommand(this.commandMenu = new CommandMenu(this)); - this.commandManagerLib.registerCommands(); + this.commandManagerLib.trackCommand(this.commandMenu = new CommandMenu(this)); + this.commandManagerLib.flushRegistrations(); /* Add Listener */ - this.registerAutoListeners(); + this.safeHook("auto listeners", this::registerAutoListeners); this.addListener(this.vinventoryManager); this.addListener(this.inventoriesPlayer); this.addListener(new ItemUpdaterListener(this.itemManager)); this.addListener(this.inventoryManager); - this.registerMaterialLoaders(); - this.registerHooks(); + this.safeHook("material loaders", this::registerMaterialLoaders); + this.safeHook("plugin hooks", this::registerHooks); this.inventoryManager.load(); this.commandManager.loadCommands(); @@ -335,7 +355,7 @@ public void onEnable() { new VersionChecker(this, 253).useLastVersion(); - context.ready(); + this.context.ready(); Bukkit.getMessenger().registerOutgoingPluginChannel(this, "BungeeCord"); @@ -354,15 +374,36 @@ public void onEnable() { this.dataManager.loadDefaultValues(); // this.inventoryManager.registerInventoryListener(this.packetUtils); - if (this.isActive(Plugins.PACKETEVENTS)) this.inventoryManager.registerInventoryListener(new PacketEventPlayerInventoryManager(this)); - if (this.packetManager != null) { - this.packetManager.onPostEnable(); + this.safeHook("packetevents", () -> { + this.inventoryManager.registerInventoryListener(new PacketEventPlayerInventoryManager(this)); + this.packetManager.onPostEnable(); + }); } + this.logFailedHooks(); + this.postEnable(); } + private void logFailedHooks() { + List failedHooks = this.getFailedHooks(); + if (failedHooks.isEmpty()) return; + + Logger.info("The following hooks failed and are disabled for this session: " + String.join(", ", failedHooks), Logger.LogType.WARNING); + Logger.info("zMenu is running normally, but the features provided by those plugins are unavailable.", Logger.LogType.WARNING); + Logger.info("If you need help, please contact the zMenu support team.", Logger.LogType.WARNING); + + this.clearFailedHooks(); + } + + /** + * @return true when a Bedrock bridge is installed and healthy + */ + private boolean hasBedrockSupport() { + return this.isEnable(Plugins.GEYSER) || this.isEnable(Plugins.FLOODGATE); + } + private void registerAutoListeners() { ClassRegistry registry = ClassRegistry.of(Listener.class, this::addListener).tryConstructor((clazz, plugin) -> clazz.getDeclaredConstructor(MenuPlugin.class).newInstance(plugin)).tryNoArgsConstructor().errorLogger(Logger::error); @@ -387,14 +428,16 @@ private boolean hasClass(String className) { * This method will be called only once, after the plugin has been enabled. */ private void registerHooks() { - if (this.isActive(Plugins.NEXO)) { - if (this.metaUpdater instanceof ComponentMeta componentMeta) { - new NexoTagResolverLoader(this, componentMeta); - } + if (this.isEnable(Plugins.NEXO)) { + this.safeHook("Nexo", () -> { + if (this.metaUpdater instanceof ComponentMeta componentMeta) { + new NexoTagResolverLoader(this, componentMeta); + } + }); } - if (this.isActive(Plugins.PACKETEVENTS)) { - this.titleAnimationManager.registerLoader("packet-events", new PacketEventTitleAnimationLoader()); + if (this.packetManager != null) { + this.safeHook("packetevents animations", () -> this.titleAnimationManager.registerLoader("packet-events", new PacketEventTitleAnimationLoader())); } ClassRegistry registry = ClassRegistry.of(FontImage.class, fontImage1 -> this.fontImage = fontImage1).tryNoArgsConstructor().errorLogger(Logger::error); @@ -439,7 +482,7 @@ private List getInventoriesFiles() { files.add("dialogs/dynamic-dialog-example.yml"); } - if (this.isActive(Plugins.GEYSER) || this.isActive(Plugins.FLOODGATE)) { + if (this.hasBedrockSupport()) { files.add("bedrock/custom-form.yml"); files.add("bedrock/modal-form.yml"); files.add("bedrock/simple-form.yml"); @@ -453,37 +496,40 @@ private List getInventoriesFiles() { @Override public void onDisable() { - context.shutdown(); + + this.safeDisable("metrics", this.context::shutdown); if (this.packetManager != null) { - this.packetManager.onDisable(); + this.safeDisable("packetevents", () -> this.packetManager.onDisable()); } this.preDisable(); - if (this.vinventoryManager != null) this.vinventoryManager.close(); - this.inventoriesPlayer.restoreAllInventories(); + if (this.vinventoryManager != null) this.safeDisable("inventories", this.vinventoryManager::close); + this.safeDisable("player inventories", this.inventoriesPlayer::restoreAllInventories); + + if (this.storageManager != null) this.safeDisable("storage", this.storageManager::flush); - if (this.storageManager != null) this.storageManager.flush(); + this.safeDisable("configuration", () -> Configuration.getInstance().save(this.getConfig(), this.configFile)); - Configuration.getInstance().save(this.getConfig(), this.configFile); + this.safeDisable("file cache", YamlFileCache::clearCache); - YamlFileCache.clearCache(); + if (this.websiteManager != null) this.safeDisable("website manager", this.websiteManager::onDisable); - if (this.websiteManager != null) this.websiteManager.onDisable(); - if (!this.isMockBukkitServer) { - NMSMenuPacketListener nmsMenuPacketListener = NMSMenuPacketListener.get(); - if (nmsMenuPacketListener != null) { - nmsMenuPacketListener.shutdown(); - } + this.safeDisable("packet listener", () -> { + NMSMenuPacketListener nmsMenuPacketListener = NMSMenuPacketListener.get(); + if (nmsMenuPacketListener != null) { + nmsMenuPacketListener.shutdown(); + } + }); } - OfflinePlayerCache.uninstall(this); + this.safeDisable("offline player cache", () -> OfflinePlayerCache.uninstall(this)); - this.itemManager.unloadListeners(); + this.safeDisable("item manager", this.itemManager::unloadListeners); - this.getServer().getServicesManager().unregisterAll(this); + this.safeDisable("services", () -> this.getServer().getServicesManager().unregisterAll(this)); this.postDisable(); } @@ -634,7 +680,8 @@ public MenuItemStack loadItemStack(YamlConfiguration configuration, String path, @Override public Optional getPacketManager() { - return Optional.ofNullable(this.packetManager); + if (this.packetManager == null || !this.packetManager.isReady()) return Optional.empty(); + return Optional.of(this.packetManager); } /** diff --git a/src/main/java/fr/maxlego08/menu/zcore/ZPlugin.java b/src/main/java/fr/maxlego08/menu/zcore/ZPlugin.java index ea825aade..560f1ca0f 100644 --- a/src/main/java/fr/maxlego08/menu/zcore/ZPlugin.java +++ b/src/main/java/fr/maxlego08/menu/zcore/ZPlugin.java @@ -39,6 +39,7 @@ public abstract class ZPlugin extends JavaPlugin { private final List listenerAdapters = new ArrayList<>(); + private final List failedHooks = new ArrayList<>(); protected VCommandManager zCommandManager; protected VInventoryManager vinventoryManager; private long enableTime; @@ -162,6 +163,35 @@ protected Plugin getPlugin(Plugins plugin) { return Bukkit.getPluginManager().getPlugin(plugin.getName()); } + protected boolean safeHook(String name, Runnable action) { + try { + action.run(); + return true; + } catch (Throwable throwable) { + if (!this.failedHooks.contains(name)) this.failedHooks.add(name); + Logger.error("Hook '" + name + "' failed to initialize, zMenu will continue without it.", throwable); + return false; + } + } + + + public List getFailedHooks() { + return this.failedHooks; + } + + + protected void clearFailedHooks() { + this.failedHooks.clear(); + } + + protected void safeDisable(String name, Runnable action) { + try { + action.run(); + } catch (Throwable throwable) { + Logger.error("Failed to shut down " + name + " cleanly.", throwable); + } + } + /** * Register command * diff --git a/src/main/java/fr/maxlego08/menu/zcore/utils/discord/DiscordWebhookChecker.java b/src/main/java/fr/maxlego08/menu/zcore/utils/discord/DiscordWebhookChecker.java index 8d19771c8..f2de91464 100644 --- a/src/main/java/fr/maxlego08/menu/zcore/utils/discord/DiscordWebhookChecker.java +++ b/src/main/java/fr/maxlego08/menu/zcore/utils/discord/DiscordWebhookChecker.java @@ -7,8 +7,7 @@ import java.net.URL; import java.util.Map; import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.*; /** * Checks that a Discord webhook exists, without ever blocking the thread that asks. @@ -25,11 +24,18 @@ public final class DiscordWebhookChecker { private static final Set VALID_WEBHOOKS = ConcurrentHashMap.newKeySet(); private static final Map PENDING = new ConcurrentHashMap<>(); + private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(0, 1, 30, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(), runnable -> { + Thread thread = new Thread(runnable, "zMenu-webhook-check"); + thread.setDaemon(true); + return thread; + }); + private DiscordWebhookChecker() { } /** - * Verifies the webhook in the background and logs a warning if it does not answer. + * Verifies the webhook in the background and logs a warning if it does not exist. * * @param webhookUrl the webhook to check, may be null * @param file the file the webhook was declared in, used in the warning @@ -39,20 +45,16 @@ public static void verifyAsync(String webhookUrl, String file) { if (VALID_WEBHOOKS.contains(webhookUrl)) return; if (PENDING.putIfAbsent(webhookUrl, Boolean.TRUE) != null) return; - CompletableFuture.runAsync(() -> { + EXECUTOR.execute(() -> { try { - if (check(webhookUrl)) { - VALID_WEBHOOKS.add(webhookUrl); - } else { - Logger.info("The discord webhook used in " + file + " does not answer, the action will fail: " + webhookUrl, Logger.LogType.WARNING); - } + check(webhookUrl, file); } finally { PENDING.remove(webhookUrl); } }); } - private static boolean check(String webhookUrl) { + private static void check(String webhookUrl, String file) { HttpURLConnection connection = null; try { URL url = new URI(webhookUrl).toURL(); @@ -60,9 +62,15 @@ private static boolean check(String webhookUrl) { connection.setRequestMethod("GET"); connection.setConnectTimeout(5000); connection.setReadTimeout(5000); - return connection.getResponseCode() == 200; + + int code = connection.getResponseCode(); + if (code == 200) { + VALID_WEBHOOKS.add(webhookUrl); + } else if (code == 401 || code == 403 || code == 404) { + Logger.info("The discord webhook used in " + file + " does not exist, the action will fail: " + webhookUrl, Logger.LogType.WARNING); + } } catch (Exception exception) { - return false; + Logger.info("The discord webhook used in " + file + " could not be reached, so it was not checked: " + webhookUrl, Logger.LogType.WARNING); } finally { if (connection != null) connection.disconnect(); }