From 4549a95ee5589d89a31aefb48cdc5cc88a4cffe2 Mon Sep 17 00:00:00 2001 From: Aurorawr Date: Wed, 8 Oct 2025 20:51:47 +0100 Subject: [PATCH] Feature: Extension dependencies (#5839) * Add loading order to dependencies # Conflicts: # core/src/main/resources/mappings * Add softdepend capability, add default load and required values * Prevent an extension from loading if it uses an old api version with the new dependency system. * Add translation strings to dependency messages * Account for language string changes, remove class loader warning when extension has dependencies * Update languages module to latest * Update version in GeyserExtensionLoader for dependencies to match 1.21.9 branch * revert mapping update --------- Co-authored-by: onebeastchris --- .../extension/GeyserExtensionClassLoader.java | 6 +- .../extension/GeyserExtensionDescription.java | 22 ++- .../extension/GeyserExtensionLoader.java | 145 ++++++++++++++++-- core/src/main/resources/languages | 2 +- 4 files changed, 159 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionClassLoader.java b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionClassLoader.java index dca11dfcd..a8f5c5584 100644 --- a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionClassLoader.java +++ b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionClassLoader.java @@ -40,11 +40,11 @@ import java.nio.file.Path; public class GeyserExtensionClassLoader extends URLClassLoader { private final GeyserExtensionLoader loader; - private final ExtensionDescription description; + private final GeyserExtensionDescription description; private final Object2ObjectMap> classes = new Object2ObjectOpenHashMap<>(); private boolean warnedForExternalClassAccess; - public GeyserExtensionClassLoader(GeyserExtensionLoader loader, ClassLoader parent, Path path, ExtensionDescription description) throws MalformedURLException { + public GeyserExtensionClassLoader(GeyserExtensionLoader loader, ClassLoader parent, Path path, GeyserExtensionDescription description) throws MalformedURLException { super(new URL[] { path.toUri().toURL() }, parent); this.loader = loader; this.description = description; @@ -89,7 +89,7 @@ public class GeyserExtensionClassLoader extends URLClassLoader { // If class is not found in current extension, check in the global class loader // This is used for classes that are not in the extension, but are in other extensions if (checkGlobal) { - if (!warnedForExternalClassAccess) { + if (!warnedForExternalClassAccess && this.description.dependencies().isEmpty()) { // Don't warn when the extension has dependencies, it is probably using it's dependencies! GeyserImpl.getInstance().getLogger().warning("Extension " + this.description.name() + " loads class " + name + " from an external source. " + "This can change at any time and break the extension, additionally to potentially causing unexpected behaviour!"); warnedForExternalClassAccess = true; diff --git a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionDescription.java b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionDescription.java index a84f12813..702ab0f68 100644 --- a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionDescription.java +++ b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionDescription.java @@ -47,7 +47,8 @@ public record GeyserExtensionDescription(@NonNull String id, int majorApiVersion, int minorApiVersion, @NonNull String version, - @NonNull List authors) implements ExtensionDescription { + @NonNull List authors, + @NonNull Map dependencies) implements ExtensionDescription { private static final Yaml YAML = new Yaml(new CustomClassLoaderConstructor(Source.class.getClassLoader(), new LoaderOptions())); @@ -94,7 +95,12 @@ public record GeyserExtensionDescription(@NonNull String id, authors.addAll(source.authors); } - return new GeyserExtensionDescription(id, name, main, humanApi, majorApi, minorApi, version, authors); + Map dependencies = new LinkedHashMap<>(); + if (source.dependencies != null) { + dependencies.putAll(source.dependencies); + } + + return new GeyserExtensionDescription(id, name, main, humanApi, majorApi, minorApi, version, authors, dependencies); } @NonNull @@ -116,5 +122,17 @@ public record GeyserExtensionDescription(@NonNull String id, String version; String author; List authors; + Map dependencies; + } + + @Getter + @Setter + public static class Dependency { + boolean required = true; // Defaults to true + LoadOrder load = LoadOrder.BEFORE; // Defaults to ensure the dependency loads before this extension + } + + public enum LoadOrder { + BEFORE, AFTER } } diff --git a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLoader.java b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLoader.java index 2bfa6cce6..2be6d2f8f 100644 --- a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLoader.java +++ b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLoader.java @@ -54,11 +54,16 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.regex.Pattern; @RequiredArgsConstructor @@ -167,6 +172,8 @@ public class GeyserExtensionLoader extends ExtensionLoader { Map extensions = new LinkedHashMap<>(); Map loadedExtensions = new LinkedHashMap<>(); + Map descriptions = new LinkedHashMap<>(); + Map extensionPaths = new LinkedHashMap<>(); Path updateDirectory = extensionsDirectory.resolve("update"); if (Files.isDirectory(updateDirectory)) { @@ -195,10 +202,126 @@ public class GeyserExtensionLoader extends ExtensionLoader { }); } - // Step 3: Load the extensions + // Step 3: Order the extensions to allow dependencies to load in the correct order this.processExtensionsFolder(extensionsDirectory, (path, description) -> { - String name = description.name(); String id = description.id(); + descriptions.put(id, description); + extensionPaths.put(id, path); + + }, (path, e) -> { + logger.error(GeyserLocale.getLocaleStringLog("geyser.extensions.load.failed_with_name", path.getFileName(), path.toAbsolutePath()), e); + }); + + // The graph to back out loading order (Funny I just learnt these too) + Map> loadOrderGraph = new HashMap<>(); + + // Looks like the graph needs to be prepopulated otherwise issues happen + for (String id : descriptions.keySet()) { + loadOrderGraph.putIfAbsent(id, new ArrayList<>()); + } + + for (GeyserExtensionDescription description : descriptions.values()) { + for (Map.Entry dependency : description.dependencies().entrySet()) { + String from = null; + String to = null; // Java complains if this isn't initialised, but not from, so, both null. + + // Check if the extension is even loaded + if (!descriptions.containsKey(dependency.getKey())) { + if (dependency.getValue().isRequired()) { // Only disable the extension if this dependency is required + // The extension we are checking is missing 1 or more dependencies + logger.error( + GeyserLocale.getLocaleStringLog( + "geyser.extensions.load.failed_dependency_missing", + description.id(), + dependency.getKey() + ) + ); + + descriptions.remove(description.id()); // Prevents it from being loaded later + } + + continue; + } + + if ( + !(description.humanApiVersion() >= 2 && + description.majorApiVersion() >= 9 && + description.minorApiVersion() >= 0) + ) { + logger.error( + GeyserLocale.getLocaleStringLog( + "geyser.extensions.load.failed_cannot_use_dependencies", + description.id(), + description.apiVersion() + ) + ); + + descriptions.remove(description.id()); // Prevents it from being loaded later + + continue; + } + + // Determine which way they should go in the graph + switch (dependency.getValue().getLoad()) { + case BEFORE -> { + from = dependency.getKey(); + to = description.id(); + } + case AFTER -> { + from = description.id(); + to = dependency.getKey(); + } + } + + loadOrderGraph.get(from).add(to); + } + } + + Set visited = new HashSet<>(); + List visiting = new ArrayList<>(); + List loadOrder = new ArrayList<>(); + + AtomicReference> sortMethod = new AtomicReference<>(); // yay, lambdas. This doesn't feel to suited to be a method + sortMethod.set((node) -> { + if (visiting.contains(node)) { + logger.error( + GeyserLocale.getLocaleStringLog( + "geyser.extensions.load.failed_cyclical_dependencies", + node, + visiting.get(visiting.indexOf(node) - 1) + ) + ); + + visiting.remove(node); + return; + } + + if (visited.contains(node)) return; + + visiting.add(node); + for (String neighbor : loadOrderGraph.get(node)) { + sortMethod.get().accept(neighbor); + } + visiting.remove(node); + visited.add(node); + loadOrder.add(node); + }); + + for (String ext : descriptions.keySet()) { + if (!visited.contains(ext)) { + // Time to sort the graph to get a load order, this reveals any cycles we may have + sortMethod.get().accept(ext); + } + } + Collections.reverse(loadOrder); // This is inverted due to how the graph is created + + // Step 4: Load the extensions + for (String id : loadOrder) { + // Grab path and description found from before, since we want a custom load order now + Path path = extensionPaths.get(id); + GeyserExtensionDescription description = descriptions.get(id); + + String name = description.name(); if (extensions.containsKey(id) || extensionManager.extension(id) != null) { logger.warning(GeyserLocale.getLocaleStringLog("geyser.extensions.load.duplicate", name, path.toString())); return; @@ -222,20 +345,22 @@ public class GeyserExtensionLoader extends ExtensionLoader { } } - GeyserExtensionContainer container = this.loadExtension(path, description); - extensions.put(id, path); - loadedExtensions.put(id, container); - }, (path, e) -> { - logger.error(GeyserLocale.getLocaleStringLog("geyser.extensions.load.failed_with_name", path.getFileName(), path.toAbsolutePath()), e); - }); + try { + GeyserExtensionContainer container = this.loadExtension(path, description); + extensions.put(id, path); + loadedExtensions.put(id, container); + } catch (Throwable e) { + logger.error(GeyserLocale.getLocaleStringLog("geyser.extensions.load.failed_with_name", path.getFileName(), path.toAbsolutePath()), e); + } + } - // Step 4: Register the extensions + // Step 5: Register the extensions for (GeyserExtensionContainer container : loadedExtensions.values()) { this.extensionContainers.put(container.extension(), container); this.register(container.extension(), extensionManager); } } catch (IOException ex) { - ex.printStackTrace(); + logger.error("Unable to read extensions.", ex); } } diff --git a/core/src/main/resources/languages b/core/src/main/resources/languages index 40253e5f3..4ce8ad58e 160000 --- a/core/src/main/resources/languages +++ b/core/src/main/resources/languages @@ -1 +1 @@ -Subproject commit 40253e5f317ede6020aa563d0823c54c37380ebf +Subproject commit 4ce8ad58ea7ab779d613a64862956d6d0563a8e3