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
9
import javax.annotation.Nullable;
import java.io.*;
KingRainbow44's avatar
KingRainbow44 committed
10
import java.lang.reflect.Method;
11
import java.net.*;
KingRainbow44's avatar
KingRainbow44 committed
12
import java.util.*;
13
import java.util.jar.*;
KingRainbow44's avatar
KingRainbow44 committed
14

15
import static emu.grasscutter.Configuration.PLUGIN;
16

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

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

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

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

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

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

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

83
                    // Create a plugin config instance from the config file.
KingRainbow44's avatar
KingRainbow44 committed
84
                    PluginConfig pluginConfig = Grasscutter.getGsonFactory().fromJson(fileReader, PluginConfig.class);
85
86
                    // Check if the plugin config is valid.
                    if (!pluginConfig.validate()) {
KingRainbow44's avatar
KingRainbow44 committed
87
88
89
90
                        Utils.logObject(pluginConfig);
                        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.
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;
133
134
135
136
137
138
139
        while(!dependencies.isEmpty()) {
            // Check if the depth is too high.
            if(depth >= maxDepth) {
                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
145
146
147
148
149
                // 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.
                }

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
214
            try {
                plugin.onDisable();
215
            } catch (Throwable exception) {
216
217
                Grasscutter.getLogger().error("Failed to disable plugin: " + name, exception);
            }
KingRainbow44's avatar
KingRainbow44 committed
218
        });
KingRainbow44's avatar
KingRainbow44 committed
219
    }
KingRainbow44's avatar
KingRainbow44 committed
220
221
222

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

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

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

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

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