From f15f1d83379cd563bad9f58589bfabfd0d61476b Mon Sep 17 00:00:00 2001 From: JetsadaWijit Date: Sat, 24 Jan 2026 18:43:36 +0700 Subject: [PATCH 1/3] chore: Increment project iteration to 5 Increment project iteration from 4 to 5. --- gradle.properties | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 084771c..2500c0f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,10 +4,9 @@ git-org-repository=mcextension # --- Artifact Identity --- project-version=2026.0.2 -project-iteration=4 +project-iteration=5 project-group=io.github.mcengine project-artifact-id=mcextension project-artifact-name=MCExtension project-artifact-description=This project is a library designed to allow Minecraft plugins to load their own extensions. project-artifact-url=https://mcengine.github.io/mcextension-website - From 9ecdf204e95c0a0ef9dba61f8a1032114e1fffb7 Mon Sep 17 00:00:00 2001 From: JetsadaWijit Date: Sat, 24 Jan 2026 12:08:02 +0000 Subject: [PATCH 2/3] refactor: implement YAML-based manifest and dependency resolution - Migrate extension identity (ID and Version) from code (IMCExtension) to extension.yml manifest. - Implement a multi-pass loading system in MCExtensionManager to support 'base' and 'extension' dependencies. - Switch from brute-force JAR class scanning to targeted loading via the 'main' key in YAML. - Add comprehensive Javadocs and internal metadata tracking for better maintainability. - Simplify IMCExtension interface by removing getId() and getVersion() requirements. --- .../mcextension/api/IMCExtension.java | 18 -- .../common/MCExtensionManager.java | 222 ++++++++++++------ src/main/resources/extension.yml | 12 + 3 files changed, 158 insertions(+), 94 deletions(-) create mode 100644 src/main/resources/extension.yml diff --git a/src/main/java/io/github/mcengine/mcextension/api/IMCExtension.java b/src/main/java/io/github/mcengine/mcextension/api/IMCExtension.java index 2316629..f3e80f3 100644 --- a/src/main/java/io/github/mcengine/mcextension/api/IMCExtension.java +++ b/src/main/java/io/github/mcengine/mcextension/api/IMCExtension.java @@ -50,22 +50,4 @@ default void onDisable(JavaPlugin plugin, Executor executor) {} default boolean checkUpdate(String url, String token) { return false; } - - /** - * Gets the unique identifier for this extension. - *

- * This ID must be unique within the context of the host plugin. - * If another extension with the same ID is already loaded, this one will be rejected. - *

- * - * @return A unique string ID (e.g., "DailyRewards", "CustomItems"). - */ - String getId(); - - /** - * Gets the version string of this extension. - * - * @return The version string (e.g., "1.0.0"). - */ - String getVersion(); } diff --git a/src/main/java/io/github/mcengine/mcextension/common/MCExtensionManager.java b/src/main/java/io/github/mcengine/mcextension/common/MCExtensionManager.java index a86d00b..c203cc0 100644 --- a/src/main/java/io/github/mcengine/mcextension/common/MCExtensionManager.java +++ b/src/main/java/io/github/mcengine/mcextension/common/MCExtensionManager.java @@ -1,15 +1,16 @@ package io.github.mcengine.mcextension.common; import io.github.mcengine.mcextension.api.IMCExtension; +import org.bukkit.Bukkit; import org.bukkit.plugin.java.JavaPlugin; +import org.yaml.snakeyaml.Yaml; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.net.URL; import java.net.URLClassLoader; -import java.util.Enumeration; -import java.util.HashMap; -import java.util.Map; +import java.util.*; import java.util.concurrent.Executor; import java.util.jar.JarEntry; import java.util.jar.JarFile; @@ -23,24 +24,38 @@ */ public class MCExtensionManager { + /** + * The host plugin instance that owns this manager. + */ private final JavaPlugin plugin; + + /** + * The directory where extension JAR files are stored. + */ private final File extensionFolder; + + /** + * The executor used for running extension-related tasks. + */ private final Executor executor; - // Tracks loaded extension metadata: {ID : Version} + /** + * A map tracking loaded extension metadata, where the key is the extension ID + * and the value is the version string. + */ private final Map loadedExtensionsInfo = new HashMap<>(); - // Tracks loaded extension instances: {ID : Instance} + /** + * A map tracking active extension instances, where the key is the extension ID + * and the value is the instantiated {@link IMCExtension}. + */ private final Map loadedInstances = new HashMap<>(); /** * Creates a new MCExtensionManager for the given plugin. - *

- * Extensions will be loaded from: {@code plugins/{PluginName}/extensions/} - *

* * @param plugin The host plugin instance. - * @param executor The executor responsible for handling extension tasks (e.g., Async/Folia scheduler). + * @param executor The executor responsible for handling extension tasks. */ public MCExtensionManager(JavaPlugin plugin, Executor executor) { this.plugin = plugin; @@ -53,7 +68,11 @@ public MCExtensionManager(JavaPlugin plugin, Executor executor) { } /** - * Scans the extension folder and loads all valid .jar files. + * Scans the extension folder and loads all valid .jar files with dependency resolution. + *

+ * This method uses a multi-pass approach to ensure that extensions are loaded only + * after their required dependencies (both base plugins and other extensions) are ready. + *

*/ public void loadAllExtensions() { File[] files = extensionFolder.listFiles((dir, name) -> name.endsWith(".jar")); @@ -63,18 +82,52 @@ public void loadAllExtensions() { return; } - plugin.getLogger().info("Found " + files.length + " extension(s). Loading..."); + List pendingFiles = new ArrayList<>(Arrays.asList(files)); + boolean changed = true; + + plugin.getLogger().info("Found " + files.length + " extension(s). Resolving dependencies..."); - for (File file : files) { - try { - loadExtension(file); - } catch (Exception e) { - plugin.getLogger().severe("Failed to load extension: " + file.getName()); - e.printStackTrace(); + while (changed && !pendingFiles.isEmpty()) { + changed = false; + Iterator iterator = pendingFiles.iterator(); + + while (iterator.hasNext()) { + File file = iterator.next(); + try { + LoadResult result = loadExtension(file); + if (result == LoadResult.SUCCESS) { + iterator.remove(); + changed = true; + } else if (result == LoadResult.FAILED) { + iterator.remove(); + } + } catch (Exception e) { + plugin.getLogger().severe("Failed to load extension: " + file.getName()); + e.printStackTrace(); + iterator.remove(); + } + } + } + + if (!pendingFiles.isEmpty()) { + for (File file : pendingFiles) { + plugin.getLogger().severe("Could not load " + file.getName() + ": Dependency requirements not met."); } } } + /** + * Represents the result of an individual extension load attempt. + */ + private enum LoadResult { + /** Extension was loaded successfully. */ + SUCCESS, + /** Extension failed to load due to missing data or errors. */ + FAILED, + /** Extension is waiting for its dependencies to be loaded. */ + WAITING + } + /** * Disables a specific extension by its ID. * @@ -94,7 +147,6 @@ public boolean disableExtension(String id) { plugin.getLogger().severe("Error disabling extension " + id); e.printStackTrace(); } finally { - // Remove from registry regardless of success/failure to ensure clean state loadedInstances.remove(id); loadedExtensionsInfo.remove(id); } @@ -102,97 +154,115 @@ public boolean disableExtension(String id) { } /** - * Disables all loaded extensions and clears the registry. + * Disables all currently loaded extensions and clears the internal registries. */ public void disableAllExtensions() { - // Create a copy of values to avoid ConcurrentModificationException during iteration - for (IMCExtension extension : new HashMap<>(loadedInstances).values()) { - try { - extension.onDisable(plugin, executor); - } catch (Exception e) { - plugin.getLogger().severe("Error disabling extension " + extension.getId()); - e.printStackTrace(); - } + for (String id : new HashMap<>(loadedInstances).keySet()) { + disableExtension(id); } loadedExtensionsInfo.clear(); loadedInstances.clear(); } /** - * Loads a specific extension JAR file. + * Logic for loading a single extension JAR. + *

+ * This involves reading the nested extension.yml, verifying base plugin dependencies, + * checking for other required extensions, and performing reflective instantiation. + *

* - * @param jarFile The JAR file to load. - * @throws IOException If file access fails. - * @throws ReflectiveOperationException If any reflection error occurs (ClassNotFound, NoSuchMethod, etc.). + * @param jarFile The JAR file to attempt to load. + * @return The {@link LoadResult} indicating success, failure, or a dependency-induced wait. + * @throws IOException If the file cannot be read. + * @throws ReflectiveOperationException If the main class cannot be found or instantiated. */ - private void loadExtension(File jarFile) throws IOException, ReflectiveOperationException { - // 1. Setup ClassLoader (Parent is the plugin's loader so extension can see Bukkit/Plugin API) - URL[] urls = {jarFile.toURI().toURL()}; - URLClassLoader loader = new URLClassLoader(urls, plugin.getClass().getClassLoader()); - - // 2. Scan JAR to find the class implementing IMCExtension - Class mainClass = null; + private LoadResult loadExtension(File jarFile) throws IOException, ReflectiveOperationException { + String mainClassName = null; + String id = null; + String version = "1.0.0"; + + List baseDepend = new ArrayList<>(); + List extDepend = new ArrayList<>(); + // 1. Read extension.yml from the JAR try (JarFile jar = new JarFile(jarFile)) { - Enumeration entries = jar.entries(); - - while (entries.hasMoreElements()) { - JarEntry entry = entries.nextElement(); - if (entry.isDirectory() || !entry.getName().endsWith(".class")) continue; - - // Convert path to class name (e.g., com/example/Main.class -> com.example.Main) - String className = entry.getName().replace('/', '.').replace(".class", ""); + JarEntry entry = jar.getJarEntry("extension.yml"); + if (entry == null) return LoadResult.FAILED; - try { - // Load the class strictly to check compatibility - Class clazz = loader.loadClass(className); + try (InputStream input = jar.getInputStream(entry)) { + Yaml yaml = new Yaml(); + Map data = yaml.load(input); + if (data != null) { + id = (String) data.get("name"); + mainClassName = (String) data.get("main"); + version = String.valueOf(data.getOrDefault("version", "1.0.0")); - // Enforce usage of IMCExtension interface - if (IMCExtension.class.isAssignableFrom(clazz) && !clazz.isInterface()) { - mainClass = clazz; - break; // Found the entry point + // Navigate Nested Map for 'base' + if (data.get("base") instanceof Map) { + @SuppressWarnings("unchecked") + Map baseSection = (Map) data.get("base"); + if (baseSection.get("depend") instanceof List) { + @SuppressWarnings("unchecked") + List castList = (List) baseSection.get("depend"); + baseDepend = castList; + } + } + + // Navigate Nested Map for 'extension' + if (data.get("extension") instanceof Map) { + @SuppressWarnings("unchecked") + Map extSection = (Map) data.get("extension"); + if (extSection.get("depend") instanceof List) { + @SuppressWarnings("unchecked") + List castList = (List) extSection.get("depend"); + extDepend = castList; + } } - } catch (ClassNotFoundException | NoClassDefFoundError ignored) { - // Skip classes that fail to load (e.g., optional dependencies missing) } } } - if (mainClass == null) { - plugin.getLogger().warning("Skipping " + jarFile.getName() + ": No class implementing IMCExtension found."); - return; - } + if (id == null || mainClassName == null) return LoadResult.FAILED; - // 3. Instantiate the Extension - // Updated to use Constructor.newInstance() instead of deprecated Class.newInstance() - IMCExtension extension = (IMCExtension) mainClass.getDeclaredConstructor().newInstance(); - String id = extension.getId(); - String version = extension.getVersion(); + // 2. Check Base Plugin Dependencies + for (String dep : baseDepend) { + if (!Bukkit.getPluginManager().isPluginEnabled(dep)) { + plugin.getLogger().warning("Skipping " + id + ": Missing base plugin '" + dep + "'"); + return LoadResult.FAILED; + } + } - // 4. Duplicate ID Check - if (loadedExtensionsInfo.containsKey(id)) { - plugin.getLogger().warning("Skipping " + jarFile.getName() + ": Duplicate Extension ID '" + id + "' already loaded."); - return; + // 3. Check Extension Dependencies + for (String dep : extDepend) { + if (!loadedInstances.containsKey(dep)) return LoadResult.WAITING; } - // 5. Lifecycle: Load + // 4. Load Classes and Instantiate + URL[] urls = {jarFile.toURI().toURL()}; + URLClassLoader loader = new URLClassLoader(urls, plugin.getClass().getClassLoader()); + Class clazz = loader.loadClass(mainClassName); + + if (!IMCExtension.class.isAssignableFrom(clazz)) return LoadResult.FAILED; + if (loadedExtensionsInfo.containsKey(id)) return LoadResult.FAILED; + + IMCExtension extension = (IMCExtension) clazz.getDeclaredConstructor().newInstance(); + try { extension.onLoad(plugin, executor); - - // 6. Register loadedExtensionsInfo.put(id, version); loadedInstances.put(id, extension); - plugin.getLogger().info("Loaded Extension: " + id + " (v" + version + ")"); + return LoadResult.SUCCESS; } catch (Exception e) { - plugin.getLogger().severe("Error occurred while initializing extension: " + id); e.printStackTrace(); + return LoadResult.FAILED; } } - + /** - * Gets a map of loaded extension IDs and their versions. - * * @return Map of {ID : Version} + * Gets a map of all currently loaded extension IDs and their versions. + * + * @return An unmodifiable-style copy of the loaded extensions info map. */ public Map getLoadedExtensions() { return new HashMap<>(loadedExtensionsInfo); diff --git a/src/main/resources/extension.yml b/src/main/resources/extension.yml new file mode 100644 index 0000000..ac57855 --- /dev/null +++ b/src/main/resources/extension.yml @@ -0,0 +1,12 @@ +# Example +name: MyEpicExtension +version: 1.0.0 +main: io.github.example.EpicExtension + +base: + depend: [Vault, PlaceholderAPI] + softdepend: [Essentials] + +extension: + depend: [CoreExtension] + softdepend: [OptionalModule] From 1cfa4ebfa84638e3a45bbd62be01b64d6a5a9631 Mon Sep 17 00:00:00 2001 From: JetsadaWijit Date: Sat, 24 Jan 2026 20:34:51 +0700 Subject: [PATCH 3/3] chore: Delete create tag after Publish Release --- .github/workflows/build.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a29d18e..ddde6af 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -73,17 +73,6 @@ jobs: USER_GITHUB_NAME: ${{ github.actor }} USER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # 4d. Auto Create Tag - # Automatically creates a git tag after successful 4c publish. - - name: Create Git Tag - if: ${{ steps.publish_release.outcome == 'success' }} - run: | - TAG_NAME="v${{ steps.check_iteration.outputs.version }}" - git tag $TAG_NAME - git push origin $TAG_NAME - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: @@ -94,3 +83,4 @@ jobs: !**/build/libs/*-sources.jar !**/build/libs/*-javadoc.jar retention-days: 1 +