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.*;
5
import emu.grasscutter.utils.JsonUtils;
KingRainbow44's avatar
KingRainbow44 committed
6
import emu.grasscutter.utils.Utils;
7
import lombok.*;
KingRainbow44's avatar
KingRainbow44 committed
8

9
import javax.annotation.Nullable;
10
11
12

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

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

/**
KingRainbow44's avatar
KingRainbow44 committed
20
 * Manages the server's plugins and the event system.
KingRainbow44's avatar
KingRainbow44 committed
21
22
 */
public final class PluginManager {
23
24
25
26
27
    /* 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
28
29
30
31
    public PluginManager() {
        this.loadPlugins(); // Load all plugins from the plugins directory.
    }

32
33
34
35
36
37
38
39
40
    /* 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
41
42
43
44
    /**
     * Loads plugins from the config-specified directory.
     */
    private void loadPlugins() {
45
        File pluginsDir = new File(Utils.toFilePath(PLUGIN()));
46
        if (!pluginsDir.exists() && !pluginsDir.mkdirs()) {
KingRainbow44's avatar
KingRainbow44 committed
47
48
49
            Grasscutter.getLogger().error("Failed to create plugins directory: " + pluginsDir.getAbsolutePath());
            return;
        }
50

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

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

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

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

75
        // Initialize all plugins.
github-actions's avatar
github-actions committed
76
        for (var plugin : plugins) {
KingRainbow44's avatar
KingRainbow44 committed
77
78
79
            try {
                URL url = plugin.toURI().toURL();
                try (URLClassLoader loader = new URLClassLoader(new URL[]{url})) {
80
81
82
                    // 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
83
                    InputStreamReader fileReader = new InputStreamReader(configFile.openStream());
muhammadeko's avatar
muhammadeko committed
84

85
                    // Create a plugin config instance from the config file.
86
                    PluginConfig pluginConfig = JsonUtils.loadToClass(fileReader, PluginConfig.class);
87
88
                    // Check if the plugin config is valid.
                    if (!pluginConfig.validate()) {
KingRainbow44's avatar
KingRainbow44 committed
89
90
91
                        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.
github-actions's avatar
github-actions committed
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;
github-actions's avatar
github-actions committed
134
        while (!dependencies.isEmpty()) {
135
            // Check if the depth is too high.
github-actions's avatar
github-actions committed
136
            if (depth >= maxDepth) {
137
138
139
140
                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
                // Check if the plugin's dependencies are loaded.
github-actions's avatar
github-actions committed
146
                if (!this.plugins.keySet().containsAll(List.of(pluginData.getDependencies()))) {
147
148
149
150
                    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
            this.disablePlugin(plugin);
KingRainbow44's avatar
KingRainbow44 committed
215
        });
KingRainbow44's avatar
KingRainbow44 committed
216
    }
KingRainbow44's avatar
KingRainbow44 committed
217
218
219

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

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

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

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

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