package world.bentobox.bentobox.managers; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.logging.Level; import java.util.stream.Collectors; import org.bukkit.Bukkit; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.event.HandlerList; import org.bukkit.event.Listener; import org.bukkit.generator.ChunkGenerator; import org.bukkit.permissions.PermissionDefault; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.PluginLoader; import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.util.permissions.DefaultPermissions; import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.Nullable; import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.addons.Addon; import world.bentobox.bentobox.api.addons.Addon.State; import world.bentobox.bentobox.api.addons.AddonClassLoader; import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.addons.Pladdon; import world.bentobox.bentobox.api.addons.exceptions.InvalidAddonDescriptionException; import world.bentobox.bentobox.api.addons.exceptions.InvalidAddonFormatException; import world.bentobox.bentobox.api.configuration.ConfigObject; import world.bentobox.bentobox.api.events.addon.AddonEvent; import world.bentobox.bentobox.commands.BentoBoxCommand; import world.bentobox.bentobox.database.objects.DataObject; import world.bentobox.bentobox.util.Util; /** * @author tastybento, ComminQ */ public class AddonsManager { private static final String DEFAULT = ".default"; private static final String GAMEMODE = "[gamemode]."; @NonNull private final List addons; @NonNull private final Map<@NonNull Addon, @Nullable AddonClassLoader> loaders; @NonNull private final Map<@NonNull Addon, @Nullable Plugin> pladdons; @NonNull private final Map> classes; private final BentoBox plugin; private @NonNull final Map<@NonNull String, @Nullable GameModeAddon> worldNames; private @NonNull final Map<@NonNull Addon, @NonNull List> listeners; private final PluginLoader pluginLoader; public AddonsManager(@NonNull BentoBox plugin) { this.plugin = plugin; addons = new ArrayList<>(); loaders = new HashMap<>(); pladdons = new HashMap<>(); classes = new HashMap<>(); listeners = new HashMap<>(); worldNames = new HashMap<>(); pluginLoader = plugin.getPluginLoader(); } /** * Register a plugin as an addon * @param parent - parent plugin * @param addon - addon class */ public void registerAddon(Plugin parent, Addon addon) { plugin.log("Registering " + parent.getDescription().getName()); // Get description in the addon.yml file InputStream resource = parent.getResource("addon.yml"); if (resource == null) { plugin.logError("Failed to register addon: no addon.yml found"); return; } // Open a reader to the jar try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource))) { setAddonFile(parent, addon); // Grab the description in the addon.yml file YamlConfiguration data = new YamlConfiguration(); data.load(reader); // Description addon.setDescription(AddonClassLoader.asDescription(data)); // Set various files addon.setDataFolder(parent.getDataFolder()); // Initialize initializeAddon(addon); sortAddons(); } catch (Exception e) { plugin.logError("Failed to register addon: " + e); } } private void setAddonFile(Plugin parent, Addon addon) throws NoSuchMethodException, SecurityException, IllegalAccessException, IllegalArgumentException, InvocationTargetException { Method getFileMethod = JavaPlugin.class.getDeclaredMethod("getFile"); getFileMethod.setAccessible(true); addon.setFile((File) getFileMethod.invoke(parent)); } /** * Loads all the addons from the addons folder */ public void loadAddons() { plugin.log("Loading addons..."); File f = new File(plugin.getDataFolder(), "addons"); if (!f.exists() && !f.mkdirs()) { plugin.logError("Cannot create addons folder!"); return; } Arrays.stream(Objects.requireNonNull(f.listFiles())).filter(x -> !x.isDirectory() && x.getName().endsWith(".jar")).forEach(this::loadAddon); plugin.log("Loaded " + getLoadedAddons().size() + " addons."); if (!getLoadedAddons().isEmpty()) { sortAddons(); } } private void loadAddon(@NonNull File f) { Addon addon; AddonClassLoader addonClassLoader; try (JarFile jar = new JarFile(f)) { // try loading the addon // Get description in the addon.yml file YamlConfiguration data = addonDescription(jar); // Check if the addon is already loaded (duplicate version?) String main = data.getString("main"); if (main != null && this.getAddonByMainClassName(main).isPresent()) { getAddonByMainClassName(main).ifPresent(a -> { plugin.logError("Duplicate addon! Addon " + a.getDescription().getName() + " " + a.getDescription().getVersion() + " has already been loaded!"); plugin.logError("Remove the duplicate and restart!"); }); return; } // Load the addon try { Plugin pladdon = pluginLoader.loadPlugin(f); if (pladdon instanceof Pladdon) { addon = ((Pladdon) pladdon).getAddon(); addon.setDescription(AddonClassLoader.asDescription(data)); // Mark pladdon as enabled. ((Pladdon) pladdon).setEnabled(); pladdons.put(addon, pladdon); } else { plugin.logError("Could not load pladdon!"); return; } } catch (Exception ex) { // Addon not pladdon addonClassLoader = new AddonClassLoader(this, data, f, this.getClass().getClassLoader()); // Get the addon itself addon = addonClassLoader.getAddon(); // Add to the list of loaders loaders.put(addon, addonClassLoader); } } catch (Exception e) { // We couldn't load the addon, aborting. plugin.logError("Could not load addon '" + f.getName() + "'. Error is: " + e.getMessage()); plugin.logStacktrace(e); return; } // Initialize some settings addon.setDataFolder(new File(f.getParent(), addon.getDescription().getName())); addon.setFile(f); // Initialize addon initializeAddon(addon); } private void initializeAddon(Addon addon) { // Locales plugin.getLocalesManager().copyLocalesFromAddonJar(addon); plugin.getLocalesManager().loadLocalesFromFile(addon.getDescription().getName()); // Fire the load event new AddonEvent().builder().addon(addon).reason(AddonEvent.Reason.LOAD).build(); // Add it to the list of addons addons.remove(addon); addons.add(addon); // Checks if this addon is compatible with the current BentoBox version. if (!isAddonCompatibleWithBentoBox(addon)) { // It is not, abort. plugin.logError("Cannot load " + addon.getDescription().getName() + " because it requires BentoBox version " + addon.getDescription().getApiVersion() + " or greater."); plugin.logError("NOTE: Please update BentoBox."); addon.setState(State.INCOMPATIBLE); return; } try { addon.setState(Addon.State.LOADED); // Run the onLoad. addon.onLoad(); // if game mode, get the world name and generate if (addon instanceof GameModeAddon gameMode && !addon.getState().equals(State.DISABLED)) { if (!gameMode.getWorldSettings().getWorldName().isEmpty()) { worldNames.put(gameMode.getWorldSettings().getWorldName().toLowerCase(Locale.ENGLISH), gameMode); } } } catch (NoClassDefFoundError | NoSuchMethodError | NoSuchFieldError e) { // Looks like the addon is incompatible, because it tries to refer to missing classes... handleAddonIncompatibility(addon, e); } catch (Exception e) { // Unhandled exception. We'll give a bit of debug here. handleAddonError(addon, e); } } /** * Enables all the addons */ public void enableAddons() { if (getLoadedAddons().isEmpty()) return; plugin.log("Enabling game mode addons..."); // Enable GameModes first, then other addons getLoadedAddons().stream().filter(a -> !a.getState().equals(State.DISABLED)).filter(GameModeAddon.class::isInstance).forEach(this::enableAddon); plugin.log("Enabling other addons..."); getLoadedAddons().stream().filter(a -> !a.getState().equals(State.DISABLED)).filter(g -> !(g instanceof GameModeAddon)).forEach(this::enableAddon); // Set perms for enabled addons this.getEnabledAddons().forEach(this::setPerms); plugin.log("Addons successfully enabled."); } boolean setPerms(Addon addon) { ConfigurationSection perms = addon.getDescription().getPermissions(); if (perms == null) return false; for (String perm : perms.getKeys(true)) { // Only try to register perms for end nodes if (perms.contains(perm + DEFAULT) && perms.contains(perm + ".description")) { try { registerPermission(perms, perm); } catch (InvalidAddonDescriptionException e) { plugin.logError("Addon " + addon.getDescription().getName() + ": " + e.getMessage()); } } } return true; } void registerPermission(ConfigurationSection perms, String perm) throws InvalidAddonDescriptionException { String name = perms.getString(perm + DEFAULT); if (name == null) { throw new InvalidAddonDescriptionException("Permission default is invalid in addon.yml: " + perm + DEFAULT); } PermissionDefault pd = PermissionDefault.getByName(name); if (pd == null) { throw new InvalidAddonDescriptionException("Permission default is invalid in addon.yml: " + perm + DEFAULT); } String desc = perms.getString(perm + ".description"); // Replace placeholders for Game Mode Addon names if (perm.contains(GAMEMODE)) { getGameModeAddons().stream().map(Addon::getPermissionPrefix) .forEach(p -> DefaultPermissions.registerPermission(perm.replace(GAMEMODE, p), desc, pd)); } else { // Single perm DefaultPermissions.registerPermission(perm, desc, pd); } } /** * Enables an addon * @param addon addon */ private void enableAddon(Addon addon) { plugin.log("Enabling " + addon.getDescription().getName() + " (" + addon.getDescription().getVersion() + ")..."); try { // If this is a GameModeAddon create the worlds, register it and load the blueprints if (addon instanceof GameModeAddon gameMode) { // Create the gameWorlds gameMode.createWorlds(); plugin.getIWM().addGameMode(gameMode); // Save and load blueprints plugin.getBlueprintsManager().extractDefaultBlueprints(gameMode); plugin.getBlueprintsManager().loadBlueprintBundles(gameMode); } addon.onEnable(); if (addon.getState().equals(State.DISABLED)) { plugin.log(addon.getDescription().getName() + " is disabled."); return; } if (addon instanceof GameModeAddon gameMode) { // Set the worlds for the commands gameMode.getPlayerCommand().ifPresent(c -> c.setWorld(gameMode.getOverWorld())); gameMode.getAdminCommand().ifPresent(c -> c.setWorld(gameMode.getOverWorld())); } new AddonEvent().builder().addon(addon).reason(AddonEvent.Reason.ENABLE).build(); addon.setState(Addon.State.ENABLED); } catch (NoClassDefFoundError | NoSuchMethodError | NoSuchFieldError e) { // Looks like the addon is incompatible, because it tries to refer to missing classes... handleAddonIncompatibility(addon, e); } catch (Exception e) { // Unhandled exception. We'll give a bit of debug here. handleAddonError(addon, e); } } /** * Handles an addon which failed to load due to an incompatibility (missing class, missing method). * @param addon instance of the Addon. * @param e - linkage exception * @since 1.1 */ private void handleAddonIncompatibility(@NonNull Addon addon, LinkageError e) { // Set the AddonState as "INCOMPATIBLE". addon.setState(Addon.State.INCOMPATIBLE); plugin.logWarning("Skipping " + addon.getDescription().getName() + " as it is incompatible with the current version of BentoBox or of server software..."); plugin.logWarning("NOTE: The addon is referring to no longer existing classes."); plugin.logWarning("NOTE: DO NOT report this as a bug from BentoBox."); StringBuilder a = new StringBuilder(); addon.getDescription().getAuthors().forEach(author -> a.append(author).append(" ")); plugin.getLogger().log(Level.SEVERE, "Please report this stack trace to the addon's author(s): " + a, e); } private boolean isAddonCompatibleWithBentoBox(@NonNull Addon addon) { return isAddonCompatibleWithBentoBox(addon, plugin.getDescription().getVersion()); } /** * Checks if the addon does not explicitly rely on API from a more recent BentoBox version. * @param addon instance of the Addon. * @param pluginVersion plugin version string. * @return {@code true} if the addon relies on available BentoBox API, {@code false} otherwise. * @since 1.11.0 */ boolean isAddonCompatibleWithBentoBox(@NonNull Addon addon, String pluginVersion) { String[] apiVersion = addon.getDescription().getApiVersion().split("\\D"); String[] bentoboxVersion = pluginVersion.split("\\D"); for (int i = 0; i < apiVersion.length; i++) { int bentoboxNumber = 0; if (i < bentoboxVersion.length && Util.isInteger(bentoboxVersion[i], false)) { bentoboxNumber = Integer.parseInt(bentoboxVersion[i]); } int apiNumber = Util.isInteger(apiVersion[i], false) ? Integer.parseInt(apiVersion[i]) : -1; if (bentoboxNumber > apiNumber) { return true; // BentoBox version is greater than the required version -> compatible } if (bentoboxNumber < apiNumber) { return false; // BentoBox is definitely outdated } // If it is equal, go to the next number } return true; // Everything is equal, so return true } /** * Handles an addon which failed to load due to an error. * @param addon instance of the Addon. * @param throwable Throwable that was thrown and which led to the error. * @since 1.1 */ private void handleAddonError(@NonNull Addon addon, @NonNull Throwable throwable) { // Set the AddonState as "ERROR". addon.setState(Addon.State.ERROR); plugin.logError("Skipping " + addon.getDescription().getName() + " due to an unhandled exception..."); // Send stacktrace, required for addon development plugin.logStacktrace(throwable); } /** * Reloads all the enabled addons */ public void reloadAddons() { disableAddons(); // Reload BentoBox commands new BentoBoxCommand(); loadAddons(); enableAddons(); } /** * Disable all the enabled addons */ public void disableAddons() { if (!getEnabledAddons().isEmpty()) { plugin.log("Disabling addons..."); // Disable addons getEnabledAddons().forEach(this::disable); plugin.log("Addons successfully disabled."); } // Unregister all commands plugin.getCommandsManager().unregisterCommands(); // Clear all maps listeners.clear(); pladdons.clear(); addons.clear(); loaders.clear(); classes.clear(); } /** * Gets the addon by name * @param name addon name, not null * @return Optional addon object */ @NonNull @SuppressWarnings("unchecked") public Optional getAddonByName(@NonNull String name){ return addons.stream().filter(a -> a.getDescription().getName().equalsIgnoreCase(name)).map(a -> (T) a).findFirst(); } /** * Gets the addon by main class name * @param name - main class name * @return Optional addon object */ @NonNull @SuppressWarnings("unchecked") public Optional getAddonByMainClassName(@NonNull String name){ return addons.stream().filter(a -> a.getDescription().getMain().equalsIgnoreCase(name)).map(a -> (T) a).findFirst(); } @NonNull private YamlConfiguration addonDescription(@NonNull JarFile jar) throws InvalidAddonFormatException, IOException, InvalidConfigurationException { // Obtain the addon.yml file JarEntry entry = jar.getJarEntry("addon.yml"); if (entry == null) { throw new InvalidAddonFormatException("Addon '" + jar.getName() + "' doesn't contains addon.yml file"); } // Open a reader to the jar BufferedReader reader = new BufferedReader(new InputStreamReader(jar.getInputStream(entry))); // Grab the description in the addon.yml file YamlConfiguration data = new YamlConfiguration(); data.load(reader); reader.close(); return data; } @NonNull public List getAddons() { return addons; } /** * @return List of enabled game mode addons * @since 1.1 */ @NonNull public List getGameModeAddons() { return getEnabledAddons().stream() .filter(GameModeAddon.class::isInstance) .map(GameModeAddon.class::cast) .collect(Collectors.toList()); } /** * Gets the list of Addons that are loaded. * @return list of loaded Addons. * @since 1.1 */ @NonNull public List getLoadedAddons() { return addons.stream().filter(addon -> addon.getState().equals(Addon.State.LOADED)).collect(Collectors.toList()); } /** * Gets the list of Addons that are enabled. * @return list of enabled Addons. * @since 1.1 */ @NonNull public List getEnabledAddons() { return addons.stream().filter(addon -> addon.getState().equals(Addon.State.ENABLED)).collect(Collectors.toList()); } @Nullable public AddonClassLoader getLoader(@NonNull final Addon addon) { return loaders.get(addon); } /** * Finds a class by name that has been loaded by this loader * @param name name of the class, not null * @return Class the class or null if not found */ @Nullable public Class getClassByName(@NonNull final String name) { try { return classes.getOrDefault(name, loaders.values().stream().filter(Objects::nonNull).map(l -> l.findClass(name, false)).filter(Objects::nonNull).findFirst().orElse(null)); } catch (Exception ignored) { // Ignored. } return null; } /** * Sets a class that this loader should know about * * @param name name of the class, not null * @param clazz the class, not null */ public void setClass(@NonNull final String name, @NonNull final Class clazz) { classes.putIfAbsent(name, clazz); } /** * Sorts the addons into loading order taking into account dependencies */ private void sortAddons() { // Lists all available addons as names. List names = addons.stream().map(a -> a.getDescription().getName()).collect(Collectors.toList()); // Check that any dependencies exist Iterator addonsIterator = addons.iterator(); while (addonsIterator.hasNext()) { Addon a = addonsIterator.next(); for (String dependency : a.getDescription().getDependencies()) { if (!names.contains(dependency)) { plugin.logError(a.getDescription().getName() + " has dependency on " + dependency + " that does not exist. Addon will not load!"); addonsIterator.remove(); break; } } } // Load dependencies or soft dependencies Map sortedAddons = new LinkedHashMap<>(); // Start with nodes with no dependencies addons.stream().filter(a -> a.getDescription().getDependencies().isEmpty() && a.getDescription().getSoftDependencies().isEmpty()) .forEach(a -> sortedAddons.put(a.getDescription().getName(), a)); // Fill remaining List remaining = addons.stream().filter(a -> !sortedAddons.containsKey(a.getDescription().getName())).collect(Collectors.toList()); // Run through remaining addons remaining.forEach(addon -> { // Get the addon's dependencies. List dependencies = new ArrayList<>(addon.getDescription().getDependencies()); dependencies.addAll(addon.getDescription().getSoftDependencies()); // Remove already sorted addons (dependencies) from the list dependencies.removeIf(sortedAddons::containsKey); if (dependencies.stream().noneMatch(dependency -> addon.getDescription().getDependencies().contains(dependency))) { sortedAddons.put(addon.getDescription().getName(), addon); } }); addons.clear(); addons.addAll(sortedAddons.values()); } /** * Get the world generator if it exists * @param worldName - name of world - case insensitive * @param id - specific generator id * @return ChunkGenerator or null if none found * @since 1.2.0 */ @Nullable public ChunkGenerator getDefaultWorldGenerator(String worldName, String id) { // Clean up world name String w = worldName.replace("_nether", "").replace("_the_end", "").toLowerCase(Locale.ENGLISH); if (worldNames.containsKey(w)) { return worldNames.get(w).getDefaultWorldGenerator(worldName, id); } return null; } /** * Register a listener * @param addon - the addon registering * @param listener - listener */ public void registerListener(@NonNull Addon addon, @NonNull Listener listener) { Bukkit.getPluginManager().registerEvents(listener, BentoBox.getInstance()); listeners.computeIfAbsent(addon, k -> new ArrayList<>()).add(listener); } /** * Disables an addon * @param addon - addon */ private void disable(@NonNull Addon addon) { // Clear listeners if (listeners.containsKey(addon)) { listeners.get(addon).forEach(HandlerList::unregisterAll); listeners.remove(addon); } // Unregister flags plugin.getFlagsManager().unregister(addon); // Disable if (addon.isEnabled()) { plugin.log("Disabling " + addon.getDescription().getName() + "..."); try { addon.onDisable(); } catch (Exception e) { plugin.logError("Error occurred when disabling addon " + addon.getDescription().getName()); plugin.logError("Report this to the addon's author(s)"); addon.getDescription().getAuthors().forEach(plugin::logError); plugin.logStacktrace(e); } new AddonEvent().builder().addon(addon).reason(AddonEvent.Reason.DISABLE).build(); } // Clear loaders if (loaders.containsKey(addon)) { Set unmodifiableSet = Collections.unmodifiableSet(loaders.get(addon).getClasses()); for (String className : unmodifiableSet) { classes.remove(className); } addon.setState(State.DISABLED); loaders.remove(addon); } // Disable pladdons if (pladdons.containsKey(addon)) { this.pluginLoader.disablePlugin(Objects.requireNonNull(this.pladdons.get(addon))); pladdons.remove(addon); } // Remove it from the addons list addons.remove(addon); } /* * Get a list of addon classes that are of type {@link DataObject} * but not {@link ConfigObject}. Configs are not transitioned to database. * Used in database transition. * @return list of DataObjects * @since 1.5.0 */ public List> getDataObjects() { return classes.values().stream() .filter(DataObject.class::isAssignableFrom) // Do not include config files .filter(c -> !ConfigObject.class.isAssignableFrom(c)) .collect(Collectors.toList()); } /** * Notifies all addons that BentoBox has loaded all addons * @since 1.8.0 */ public void allLoaded() { addons.forEach(Addon::allLoaded); } }