Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -94,3 +83,4 @@ jobs:
!**/build/libs/*-sources.jar
!**/build/libs/*-javadoc.jar
retention-days: 1

3 changes: 1 addition & 2 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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

18 changes: 0 additions & 18 deletions src/main/java/io/github/mcengine/mcextension/api/IMCExtension.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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.
* </p>
*
* @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();
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<String, String> 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<String, IMCExtension> loadedInstances = new HashMap<>();

/**
* Creates a new MCExtensionManager for the given plugin.
* <p>
* Extensions will be loaded from: {@code plugins/{PluginName}/extensions/}
* </p>
*
* @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;
Expand All @@ -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.
* <p>
* 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.
* </p>
*/
public void loadAllExtensions() {
File[] files = extensionFolder.listFiles((dir, name) -> name.endsWith(".jar"));
Expand All @@ -63,18 +82,52 @@ public void loadAllExtensions() {
return;
}

plugin.getLogger().info("Found " + files.length + " extension(s). Loading...");
List<File> 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<File> 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.
*
Expand All @@ -94,105 +147,122 @@ 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);
}
return true;
}

/**
* 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.
* <p>
* This involves reading the nested extension.yml, verifying base plugin dependencies,
* checking for other required extensions, and performing reflective instantiation.
* </p>
*
* @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<String> baseDepend = new ArrayList<>();
List<String> extDepend = new ArrayList<>();

// 1. Read extension.yml from the JAR
try (JarFile jar = new JarFile(jarFile)) {
Enumeration<JarEntry> 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<String, Object> 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<String, Object> baseSection = (Map<String, Object>) data.get("base");
if (baseSection.get("depend") instanceof List) {
@SuppressWarnings("unchecked")
List<String> castList = (List<String>) baseSection.get("depend");
baseDepend = castList;
}
}

// Navigate Nested Map for 'extension'
if (data.get("extension") instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> extSection = (Map<String, Object>) data.get("extension");
if (extSection.get("depend") instanceof List) {
@SuppressWarnings("unchecked")
List<String> castList = (List<String>) 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<String, String> getLoadedExtensions() {
return new HashMap<>(loadedExtensionsInfo);
Expand Down
12 changes: 12 additions & 0 deletions src/main/resources/extension.yml
Original file line number Diff line number Diff line change
@@ -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]