PluginManager.java 11.8 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
75
        // Initialize all plugins.
        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.
KingRainbow44's avatar
KingRainbow44 committed
85
                    PluginConfig pluginConfig = Grasscutter.getGsonFactory().fromJson(fileReader, PluginConfig.class);
86
87
                    // Check if the plugin config is valid.
                    if (!pluginConfig.validate()) {
KingRainbow44's avatar
KingRainbow44 committed
88
89
90
91
                        Utils.logObject(pluginConfig);
                        Grasscutter.getLogger().warn("Plugin " + plugin.getName() + " has an invalid config file.");
                        return;
                    }
muhammadeko's avatar
muhammadeko committed
92

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

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

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

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

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

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

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

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

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

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

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

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

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

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

KingRainbow44's avatar
KingRainbow44 committed
208
209
210
211
    /**
     * Disables all registered plugins.
     */
    public void disablePlugins() {
KingRainbow44's avatar
KingRainbow44 committed
212
213
        this.plugins.forEach((name, plugin) -> {
            Grasscutter.getLogger().info("Disabling plugin: " + name);
214
215
            try {
                plugin.onDisable();
216
            } catch (Throwable exception) {
217
218
                Grasscutter.getLogger().error("Failed to disable plugin: " + name, exception);
            }
KingRainbow44's avatar
KingRainbow44 committed
219
        });
KingRainbow44's avatar
KingRainbow44 committed
220
    }
KingRainbow44's avatar
KingRainbow44 committed
221
222
223

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

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

    /**
     * Check an event to handlers for the priority.
244
245
     *
     * @param event    The event being called.
KingRainbow44's avatar
KingRainbow44 committed
246
247
248
     * @param priority The priority to call for.
     */
    private void checkAndFilter(Event event, HandlerPriority priority) {
249
250
251
252
253
254
255
256
257
258
259
260
        // 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
261
    }
262

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

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