From aacbe8d68a6213eb8da83b4d884fbbd82db14e12 Mon Sep 17 00:00:00 2001 From: KingRainbow44 Date: Tue, 5 Dec 2023 21:40:00 -0500 Subject: [PATCH] (feat) Add a plugin manager --- src/main/java/emu/lunarcore/LunarCore.java | 15 ++ .../java/emu/lunarcore/plugin/Plugin.java | 100 ++++++++ .../emu/lunarcore/plugin/PluginManager.java | 221 ++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 src/main/java/emu/lunarcore/plugin/Plugin.java create mode 100644 src/main/java/emu/lunarcore/plugin/PluginManager.java diff --git a/src/main/java/emu/lunarcore/LunarCore.java b/src/main/java/emu/lunarcore/LunarCore.java index bff7d91..8d933bd 100644 --- a/src/main/java/emu/lunarcore/LunarCore.java +++ b/src/main/java/emu/lunarcore/LunarCore.java @@ -2,6 +2,7 @@ package emu.lunarcore; import java.io.*; +import emu.lunarcore.plugin.PluginManager; import org.jline.reader.EndOfFileException; import org.jline.reader.LineReaderBuilder; import org.jline.reader.UserInterruptException; @@ -35,6 +36,7 @@ public class LunarCore { @Getter private static GameServer gameServer; @Getter private static CommandManager commandManager; + @Getter private static PluginManager pluginManager; @Getter private static ServerType serverType = ServerType.BOTH; private static LineReaderImpl reader; @@ -65,6 +67,13 @@ public class LunarCore { // Load commands LunarCore.commandManager = new CommandManager(); + LunarCore.pluginManager = new PluginManager(); + + try { + LunarCore.getPluginManager().loadPlugins(); + } catch (Exception exception) { + LunarCore.getLogger().error("Unable to load plugins.", exception); + } // Parse arguments for (String arg : args) { @@ -123,6 +132,8 @@ public class LunarCore { LunarCore.getLogger().error("Unable to start the game server.", exception); } + LunarCore.getPluginManager().enablePlugins(); + // Hook into shutdown event Runtime.getRuntime().addShutdownHook(new Thread(LunarCore::onShutdown)); @@ -217,6 +228,10 @@ public class LunarCore { if (gameServer != null) { gameServer.onShutdown(); } + + if (pluginManager != null) { + pluginManager.disablePlugins(); + } } // Server enums diff --git a/src/main/java/emu/lunarcore/plugin/Plugin.java b/src/main/java/emu/lunarcore/plugin/Plugin.java new file mode 100644 index 0000000..fa0d296 --- /dev/null +++ b/src/main/java/emu/lunarcore/plugin/Plugin.java @@ -0,0 +1,100 @@ +package emu.lunarcore.plugin; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; + +import java.io.File; +import java.io.InputStream; +import java.net.URLClassLoader; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class Plugin { + private final Identifier identifier; + private final URLClassLoader classLoader; + private final File dataFolder; + private final Logger logger; + + /* + * Collection of plugin events which are called by the server. + */ + + public void onLoad() {} + public void onEnable() {} + public void onDisable() {} + + /** + * Fetches a resource from the plugin's JAR file. + * + * @param fileName The name of the file to fetch. + * @return An {@link InputStream} of the file. + */ + public final InputStream getResource(String fileName) { + return this.getClassLoader().getResourceAsStream(fileName); + } + + /** Get the plugin's name. */ + public final String getName() { + return this.getIdentifier().name; + } + + /** Get the plugin's description. */ + public final String getDescription() { + return this.getIdentifier().description; + } + + /** Get the plugin's version. */ + public final String getVersion() { + return this.getIdentifier().version; + } + + /** Deserialized plugin config data. */ + public record Config( + String name, + String description, + String version, + String mainClass, + Integer api, + String[] authors, + String[] loadAfter + ) { + /** + * Attempts to validate this config instance. + * + * @return True if the config is valid, false otherwise. + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public boolean validate() { + return name != null && description != null && mainClass != null && api != null; + } + } + + /** Loaded plugin data. */ + public record Identifier( + String name, + String description, + String version, + String[] authors + ) { + /** + * Converts a {@link Config} into a {@link Identifier}. + * + * @param config The config to convert. + * @return An instance of {@link Identifier}. + */ + public static Identifier from(Config config) { + if (!config.validate()) + throw new IllegalArgumentException("Invalid plugin config supplied."); + return new Identifier(config.name(), config.description(), config.version(), config.authors()); + } + } + + /** Unloaded plugin data. */ + public record Data( + Plugin instance, + URLClassLoader classLoader, + String[] dependencies + ) {} +} diff --git a/src/main/java/emu/lunarcore/plugin/PluginManager.java b/src/main/java/emu/lunarcore/plugin/PluginManager.java new file mode 100644 index 0000000..90bc02b --- /dev/null +++ b/src/main/java/emu/lunarcore/plugin/PluginManager.java @@ -0,0 +1,221 @@ +package emu.lunarcore.plugin; + +import emu.lunarcore.util.JsonUtils; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.lang.reflect.InvocationTargetException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.*; +import java.util.jar.JarFile; + +/** Manages the server's plugins and the event system. */ +@Getter +public final class PluginManager { + /* + * This should only be changed when a breaking change is made to the plugin API. + * A 'breaking change' is something which changes the existing logic of the API. + */ + public static final int API_VERSION = 1; + + /** + * The directory where plugins are stored. + */ + public static final File PLUGINS_DIR = new File("plugins"); + + /** Map of loaded plugins; Name -> Instance */ + private final Map plugins = new HashMap<>(); + private final Logger logger = LoggerFactory.getLogger("Plugin Manager"); + + private boolean pluginsLoaded = false; + + /** + * Loads all plugins from the plugins directory. + * This can only be called once. + */ + public void loadPlugins() throws IOException { + if (this.pluginsLoaded) + throw new IllegalStateException("Plugins have already been loaded."); + this.pluginsLoaded = true; + + if (!PLUGINS_DIR.exists() && !PLUGINS_DIR.mkdirs()) + throw new IOException("Failed to create plugins directory."); + + // Read files from the directory. + var files = PLUGINS_DIR.listFiles(); + if (files == null) return; + + var loadingExceptions = new ArrayList(); + + var pluginFiles = Arrays.stream(files) + .filter(file -> file.getName().endsWith(".jar")) + .toList(); + var pluginURLs = pluginFiles.stream() + .map(file -> { + try { + return file.toURI().toURL(); + } catch (IOException e) { + loadingExceptions.add(e); + return null; + } + }) + .filter(Objects::nonNull) + .toArray(URL[]::new); + + loadingExceptions.forEach(e -> this.getLogger().warn("Failed to load plugin: " + e.getMessage())); + + // Begin loading plugins. + var classLoader = new URLClassLoader(pluginURLs); + var dependencies = new ArrayList(); + + pluginFiles.forEach(pluginFile -> { + try { + var pluginUrl = pluginFile.toURI().toURL(); + + // Read the plugin's configuration file. + var jarReader = new URLClassLoader(new URL[] { pluginUrl }); + var pluginConfigFile = jarReader.getResourceAsStream("plugin.json"); + if (pluginConfigFile == null) { + this.getLogger().warn("Plugin {} did not specify a config file.", pluginFile.getName()); + return; + } + + // Deserialize the plugin's configuration file. + var configReader = new InputStreamReader(pluginConfigFile); + var pluginConfig = JsonUtils.loadToClass(configReader, Plugin.Config.class); + if (pluginConfig == null) { + this.getLogger().warn("Plugin {} has an invalid config file.", pluginFile.getName()); + return; + } + jarReader.close(); + + // Validate the plugin's configuration file. + if (pluginConfig.api() == null) { + this.getLogger().warn("Plugin {} did not specify an API version.", pluginFile.getName()); + return; + } else if (pluginConfig.api() != API_VERSION) { + this.getLogger().warn("Plugin {} requires API version {}, but this server is running version {}.", + pluginFile.getName(), pluginConfig.api(), API_VERSION); + return; + } else if (!pluginConfig.validate()) { + this.getLogger().warn("Plugin {} has an invalid config file.", pluginFile.getName()); + return; + } + + // Load all classes in the plugin's JAR file. + var pluginJar = new JarFile(pluginFile); + var entries = pluginJar.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + if (entry.isDirectory() || !entry.getName().endsWith(".class")) continue; + + var className = entry.getName().substring(0, entry.getName().length() - 6); + className = className.replace('/', '.'); + classLoader.loadClass(className); + } + + // Instantiate the plugin. + var pluginClass = classLoader.loadClass(pluginConfig.mainClass()); + var pluginInstance = (Plugin) pluginClass.getDeclaredConstructor( + Plugin.Identifier.class, + URLClassLoader.class, + File.class, + Logger.class + ).newInstance( + Plugin.Identifier.from(pluginConfig), + classLoader, + new File(PLUGINS_DIR, pluginConfig.name()), + LoggerFactory.getLogger(pluginConfig.name()) + ); + + // Check for plugin dependencies. + var loadAfter = pluginConfig.loadAfter(); + if (loadAfter != null && loadAfter.length > 0) { + dependencies.add(new Plugin.Data( + pluginInstance, + classLoader, + loadAfter + )); + } else try { + pluginInstance.onLoad(); + } catch (Throwable exception) { + this.getLogger().warn("Failed to load plugin {}.", pluginFile.getName()); + } + } catch (IOException | ClassNotFoundException e) { + this.getLogger().warn("Failed to load plugin {}.", pluginFile.getName()); + } catch (InvocationTargetException | InstantiationException | IllegalAccessException | + NoSuchMethodException e) { + throw new RuntimeException(e); + } + }); + + // Load all plugins with dependencies. + var depth = 0; + final var maxDepth = 30; + while (!dependencies.isEmpty()) { + // Check if the depth is too high. + if (depth >= maxDepth) { + this.getLogger().warn("Failed to load plugins due to circular dependencies."); + break; + } + + try { + // Get the next plugin to load. + var pluginData = dependencies.get(0); + + // Check if the plugin's dependencies are loaded. + if (!this.plugins.keySet().containsAll(List.of(pluginData.dependencies()))) { + depth++; // Increase depth counter. + continue; // Continue to next plugin. + } + + // Remove the plugin from the list of dependencies. + dependencies.remove(pluginData); + + // Load the plugin. + pluginData.instance().onLoad(); + } catch (Throwable exception) { + this.getLogger().warn("Failed to load plugin {}.", exception.getMessage()); + depth++; + } + } + } + + /** + * Enables all plugins. + */ + public void enablePlugins() { + this.getPlugins().forEach((name, plugin) -> { + try { + this.getLogger().info("Enabling plugin {}.", name); + plugin.onEnable(); + return; + } catch (NoSuchMethodError | NoSuchFieldError ignored) { + this.getLogger().warn("Plugin {} is not compatible with this server version.", name); + } catch (Throwable exception) { + this.getLogger().warn("Failed to enable plugin {}.", name); + } + + plugin.onDisable(); + }); + } + + /** + * Disables all plugins. + */ + public void disablePlugins() { + this.getPlugins().forEach((name, plugin) -> { + try { + this.getLogger().info("Disabling plugin {}.", name); + plugin.onDisable(); + } catch (Throwable exception) { + this.getLogger().warn("Failed to disable plugin {}.", name); + } + }); + } +}