PluginManager.java 11.6 KB
Newer Older
KingRainbow44's avatar
KingRainbow44 committed
1
2
3
package emu.grasscutter.plugin;

import emu.grasscutter.Grasscutter;
4
import emu.grasscutter.server.event.*;
KingRainbow44's avatar
KingRainbow44 committed
5
import emu.grasscutter.utils.Utils;
6
import lombok.*;
KingRainbow44's avatar
KingRainbow44 committed
7

8
import javax.annotation.Nullable;
9
10
11

import static emu.grasscutter.config.Configuration.PLUGIN;

12
import java.io.*;
KingRainbow44's avatar
KingRainbow44 committed
13
import java.lang.reflect.Method;
14
import java.net.*;
KingRainbow44's avatar
KingRainbow44 committed
15
import java.util.*;
16
import java.util.jar.*;
KingRainbow44's avatar
KingRainbow44 committed
17
18

/**
KingRainbow44's avatar
KingRainbow44 committed
19
 * Manages the server's plugins and the event system.
KingRainbow44's avatar
KingRainbow44 committed
20
21
 */
public final class PluginManager {
22
23
24
25
26
    /* All loaded plugins. */
    private final Map<String, Plugin> plugins = new LinkedHashMap<>();
    /* All currently registered listeners per plugin. */
    private final Map<Plugin, List<EventHandler<? extends Event>>> listeners = new LinkedHashMap<>();

KingRainbow44's avatar
KingRainbow44 committed
27
28
29
30
    public PluginManager() {
        this.loadPlugins(); // Load all plugins from the plugins directory.
    }

31
32
33
34
35
36
37
38
39
    /* Data about an unloaded plugin. */
    @AllArgsConstructor @Getter
    static class PluginData {
        private Plugin plugin;
        private PluginIdentifier identifier;
        private URLClassLoader classLoader;
        private String[] dependencies;
    }

KingRainbow44's avatar
KingRainbow44 committed
40
41
42
43
    /**
     * Loads plugins from the config-specified directory.
     */
    private void loadPlugins() {
44
        File pluginsDir = new File(Utils.toFilePath(PLUGIN()));
45
        if (!pluginsDir.exists() && !pluginsDir.mkdirs()) {
KingRainbow44's avatar
KingRainbow44 committed
46
47
48
            Grasscutter.getLogger().error("Failed to create plugins directory: " + pluginsDir.getAbsolutePath());
            return;
        }
49

KingRainbow44's avatar
KingRainbow44 committed
50
        File[] files = pluginsDir.listFiles();
51
        if (files == null) {
KingRainbow44's avatar
KingRainbow44 committed
52
53
54
            // The directory is empty, there aren't any plugins to load.
            return;
        }
55

KingRainbow44's avatar
KingRainbow44 committed
56
        List<File> plugins = Arrays.stream(files)
57
58
            .filter(file -> file.getName().endsWith(".jar"))
            .toList();
59
60
61
62
63

        URL[] pluginNames = new URL[plugins.size()];
        plugins.forEach(plugin -> {
            try {
                pluginNames[plugins.indexOf(plugin)] = plugin.toURI().toURL();
Magix's avatar
Magix committed
64
65
            } catch (MalformedURLException exception) {
                Grasscutter.getLogger().warn("Unable to load plugin.", exception);
66
67
68
            }
        });

69
        // Create a class loader for the plugins.
70
        URLClassLoader classLoader = new URLClassLoader(pluginNames);
71
72
        // Create a list of plugins that require dependencies.
        List<PluginData> dependencies = new ArrayList<>();
73

74
        // Initialize all plugins.
github-actions's avatar
github-actions committed
75
        for (var plugin : plugins) {
KingRainbow44's avatar
KingRainbow44 committed
76
77
78
            try {
                URL url = plugin.toURI().toURL();
                try (URLClassLoader loader = new URLClassLoader(new URL[]{url})) {
79
80
81
                    // Find the plugin.json file for each plugin.
                    URL configFile = loader.findResource("plugin.json");
                    // Open the config file for reading.
KingRainbow44's avatar
KingRainbow44 committed
82
                    InputStreamReader fileReader = new InputStreamReader(configFile.openStream());
muhammadeko's avatar
muhammadeko committed
83

84
                    // Create a plugin config instance from the config file.
85
                    PluginConfig pluginConfig = Utils.loadJsonToClass(fileReader, PluginConfig.class);
86
87
                    // Check if the plugin config is valid.
                    if (!pluginConfig.validate()) {
KingRainbow44's avatar
KingRainbow44 committed
88
89
90
                        Grasscutter.getLogger().warn("Plugin " + plugin.getName() + " has an invalid config file.");
                        return;
                    }
muhammadeko's avatar
muhammadeko committed
91

92
                    // Create a JAR file instance from the plugin's URL.
muhammadeko's avatar
muhammadeko committed
93
                    JarFile jarFile = new JarFile(plugin);
94
                    // Load all class files from the JAR file.
muhammadeko's avatar
muhammadeko committed
95
                    Enumeration<JarEntry> entries = jarFile.entries();
96
                    while (entries.hasMoreElements()) {
muhammadeko's avatar
muhammadeko committed
97
                        JarEntry entry = entries.nextElement();
98
99
                        if (entry.isDirectory() || !entry.getName().endsWith(".class") || entry.getName().contains("module-info"))
                            continue;
muhammadeko's avatar
muhammadeko committed
100
                        String className = entry.getName().replace(".class", "").replace("/", ".");
Magix's avatar
Magix committed
101
                        classLoader.loadClass(className); // Use the same class loader for ALL plugins.
muhammadeko's avatar
muhammadeko committed
102
                    }
103
104

                    // Create a plugin instance.
105
                    Class<?> pluginClass = classLoader.loadClass(pluginConfig.mainClass);
KingRainbow44's avatar
KingRainbow44 committed
106
                    Plugin pluginInstance = (Plugin) pluginClass.getDeclaredConstructor().newInstance();
107
108
109
110
                    // Close the file reader.
                    fileReader.close();

                    // Check if the plugin has alternate dependencies.
github-actions's avatar
github-actions committed
111
                    if (pluginConfig.loadAfter != null && pluginConfig.loadAfter.length > 0) {
112
113
114
115
116
117
118
119
                        // Add the plugin to a "load later" list.
                        dependencies.add(new PluginData(
                            pluginInstance, PluginIdentifier.fromPluginConfig(pluginConfig),
                            loader, pluginConfig.loadAfter));
                        continue;
                    }

                    // Load the plugin.
120
                    this.loadPlugin(pluginInstance, PluginIdentifier.fromPluginConfig(pluginConfig), loader);
KingRainbow44's avatar
KingRainbow44 committed
121
122
                } catch (ClassNotFoundException ignored) {
                    Grasscutter.getLogger().warn("Plugin " + plugin.getName() + " has an invalid main class.");
123
124
                } catch (FileNotFoundException ignored) {
                    Grasscutter.getLogger().warn("Plugin " + plugin.getName() + " lacks a valid config file.");
KingRainbow44's avatar
KingRainbow44 committed
125
126
127
128
                }
            } catch (Exception exception) {
                Grasscutter.getLogger().error("Failed to load plugin: " + plugin.getName(), exception);
            }
129
130
131
132
        }

        // Load plugins with dependencies.
        int depth = 0; final int maxDepth = 30;
github-actions's avatar
github-actions committed
133
        while (!dependencies.isEmpty()) {
134
            // Check if the depth is too high.
github-actions's avatar
github-actions committed
135
            if (depth >= maxDepth) {
136
137
138
139
                Grasscutter.getLogger().error("Failed to load plugins with dependencies.");
                break;
            }

140
141
142
            try {
                // Get the next plugin to load.
                var pluginData = dependencies.get(0);
143

144
                // Check if the plugin's dependencies are loaded.
github-actions's avatar
github-actions committed
145
                if (!this.plugins.keySet().containsAll(List.of(pluginData.getDependencies()))) {
146
147
148
149
                    depth++; // Increase depth counter.
                    continue; // Continue to next plugin.
                }

150
151
152
                // Remove the plugin from the list of dependencies.
                dependencies.remove(pluginData);

153
154
155
                // Load the plugin.
                this.loadPlugin(pluginData.getPlugin(), pluginData.getIdentifier(), pluginData.getClassLoader());
            } catch (Exception exception) {
156
                Grasscutter.getLogger().error("Failed to load a plugin.", exception); depth++;
157
158
            }
        }
KingRainbow44's avatar
KingRainbow44 committed
159
160
161
162
    }

    /**
     * Load the specified plugin.
163
     *
KingRainbow44's avatar
KingRainbow44 committed
164
165
     * @param plugin The plugin instance.
     */
166
    private void loadPlugin(Plugin plugin, PluginIdentifier identifier, URLClassLoader classLoader) {
KingRainbow44's avatar
KingRainbow44 committed
167
        Grasscutter.getLogger().info("Loading plugin: " + identifier.name);
168

KingRainbow44's avatar
KingRainbow44 committed
169
170
171
        // Add the plugin's identifier.
        try {
            Class<Plugin> pluginClass = Plugin.class;
172
            Method method = pluginClass.getDeclaredMethod("initializePlugin", PluginIdentifier.class, URLClassLoader.class);
173
174
175
            method.setAccessible(true);
            method.invoke(plugin, identifier, classLoader);
            method.setAccessible(false);
KingRainbow44's avatar
KingRainbow44 committed
176
177
178
        } catch (Exception ignored) {
            Grasscutter.getLogger().warn("Failed to add plugin identifier: " + identifier.name);
        }
179

KingRainbow44's avatar
KingRainbow44 committed
180
        // Add the plugin to the list of loaded plugins.
KingRainbow44's avatar
KingRainbow44 committed
181
        this.plugins.put(identifier.name, plugin);
182
183
184
        // Create a collection for the plugin's listeners.
        this.listeners.put(plugin, new LinkedList<>());

KingRainbow44's avatar
KingRainbow44 committed
185
        // Call the plugin's onLoad method.
186
187
        try {
            plugin.onLoad();
188
        } catch (Throwable exception) {
189
190
            Grasscutter.getLogger().error("Failed to load plugin: " + identifier.name, exception);
        }
KingRainbow44's avatar
KingRainbow44 committed
191
192
193
194
195
196
    }

    /**
     * Enables all registered plugins.
     */
    public void enablePlugins() {
KingRainbow44's avatar
KingRainbow44 committed
197
198
        this.plugins.forEach((name, plugin) -> {
            Grasscutter.getLogger().info("Enabling plugin: " + name);
199
200
            try {
                plugin.onEnable();
201
            } catch (Throwable exception) {
202
203
                Grasscutter.getLogger().error("Failed to enable plugin: " + name, exception);
            }
KingRainbow44's avatar
KingRainbow44 committed
204
        });
KingRainbow44's avatar
KingRainbow44 committed
205
    }
206

KingRainbow44's avatar
KingRainbow44 committed
207
208
209
210
    /**
     * Disables all registered plugins.
     */
    public void disablePlugins() {
KingRainbow44's avatar
KingRainbow44 committed
211
212
        this.plugins.forEach((name, plugin) -> {
            Grasscutter.getLogger().info("Disabling plugin: " + name);
213
            this.disablePlugin(plugin);
KingRainbow44's avatar
KingRainbow44 committed
214
        });
KingRainbow44's avatar
KingRainbow44 committed
215
    }
KingRainbow44's avatar
KingRainbow44 committed
216
217
218

    /**
     * Registers a plugin's event listener.
219
220
     *
     * @param plugin The plugin registering the listener.
KingRainbow44's avatar
KingRainbow44 committed
221
222
     * @param listener The event listener.
     */
223
224
    public void registerListener(Plugin plugin, EventHandler<? extends Event> listener) {
        this.listeners.get(plugin).add(listener);
KingRainbow44's avatar
KingRainbow44 committed
225
    }
226

KingRainbow44's avatar
KingRainbow44 committed
227
228
    /**
     * Invoke the provided event on all registered event listeners.
229
     *
KingRainbow44's avatar
KingRainbow44 committed
230
231
232
     * @param event The event to invoke.
     */
    public void invokeEvent(Event event) {
KingRainbow44's avatar
KingRainbow44 committed
233
        EnumSet.allOf(HandlerPriority.class)
234
            .forEach(priority -> this.checkAndFilter(event, priority));
KingRainbow44's avatar
KingRainbow44 committed
235
236
237
238
    }

    /**
     * Check an event to handlers for the priority.
239
240
     *
     * @param event    The event being called.
KingRainbow44's avatar
KingRainbow44 committed
241
242
243
     * @param priority The priority to call for.
     */
    private void checkAndFilter(Event event, HandlerPriority priority) {
244
245
246
247
248
249
250
251
252
253
254
255
        // Create a collection of listeners.
        List<EventHandler<? extends Event>> listeners = new LinkedList<>();

        // Add all listeners from every plugin.
        this.listeners.values().forEach(listeners::addAll);

        listeners.stream()
            // Filter the listeners by priority.
            .filter(handler -> handler.handles().isInstance(event))
            .filter(handler -> handler.getPriority() == priority)
            // Invoke the event.
            .toList().forEach(handler -> this.invokeHandler(event, handler));
KingRainbow44's avatar
KingRainbow44 committed
256
    }
257

258
259
260
261
262
263
264
    /**
     * Gets a plugin's instance by its name.
     *
     * @param name The name of the plugin.
     * @return Either null, or the plugin's instance.
     */
    @Nullable
265
266
267
268
    public Plugin getPlugin(String name) {
        return this.plugins.get(name);
    }

269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
    /**
     * Enables a plugin.
     *
     * @param plugin The plugin to enable.
     */
    public void enablePlugin(Plugin plugin) {
        try {
            // Call the plugin's onEnable method.
            plugin.onEnable();
        } catch (Exception exception) {
            Grasscutter.getLogger().error("Failed to enable plugin: " + plugin.getName(), exception);
        }
    }

    /**
     * Disables a plugin.
     *
     * @param plugin The plugin to disable.
     */
    public void disablePlugin(Plugin plugin) {
        try {
            // Call the plugin's onDisable method.
            plugin.onDisable();
        } catch (Exception exception) {
            Grasscutter.getLogger().error("Failed to disable plugin: " + plugin.getName(), exception);
        }

        // Un-register all listeners.
        this.listeners.remove(plugin);
    }

KingRainbow44's avatar
KingRainbow44 committed
300
    /**
301
     * Performs logic checks then invokes the provided event handler.
302
303
     *
     * @param event   The event passed through to the handler.
304
     * @param handler The handler to invoke.
KingRainbow44's avatar
KingRainbow44 committed
305
     */
KingRainbow44's avatar
KingRainbow44 committed
306
307
    @SuppressWarnings("unchecked")
    private <T extends Event> void invokeHandler(Event event, EventHandler<T> handler) {
308
309
        if (!event.isCanceled() ||
            (event.isCanceled() && handler.ignoresCanceled())
KingRainbow44's avatar
KingRainbow44 committed
310
        ) handler.getCallback().consume((T) event);
KingRainbow44's avatar
KingRainbow44 committed
311
    }
Magix's avatar
Magix committed
312
}