PluginManager.java 11.5 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
111
112
113
114
115
116
117
118
119
                    // Close the file reader.
                    fileReader.close();

                    // Check if the plugin has alternate dependencies.
                    if(pluginConfig.loadAfter.length > 0) {
                        // 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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
        }

        // Load plugins with dependencies.
        int depth = 0; final int maxDepth = 30;
        while(!dependencies.isEmpty() || depth < maxDepth) {
            try {
                // Get the next plugin to load.
                var pluginData = dependencies.get(0);
                // 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.
                }

                // Load the plugin.
                this.loadPlugin(pluginData.getPlugin(), pluginData.getIdentifier(), pluginData.getClassLoader());
            } catch (Exception exception) {
                Grasscutter.getLogger().error("Failed to load a plugin.", exception);
            }
        }
KingRainbow44's avatar
KingRainbow44 committed
149
150
151
152
    }

    /**
     * Load the specified plugin.
153
     *
KingRainbow44's avatar
KingRainbow44 committed
154
155
     * @param plugin The plugin instance.
     */
156
    private void loadPlugin(Plugin plugin, PluginIdentifier identifier, URLClassLoader classLoader) {
KingRainbow44's avatar
KingRainbow44 committed
157
        Grasscutter.getLogger().info("Loading plugin: " + identifier.name);
158

KingRainbow44's avatar
KingRainbow44 committed
159
160
161
        // Add the plugin's identifier.
        try {
            Class<Plugin> pluginClass = Plugin.class;
162
            Method method = pluginClass.getDeclaredMethod("initializePlugin", PluginIdentifier.class, URLClassLoader.class);
163
164
165
            method.setAccessible(true);
            method.invoke(plugin, identifier, classLoader);
            method.setAccessible(false);
KingRainbow44's avatar
KingRainbow44 committed
166
167
168
        } catch (Exception ignored) {
            Grasscutter.getLogger().warn("Failed to add plugin identifier: " + identifier.name);
        }
169

KingRainbow44's avatar
KingRainbow44 committed
170
        // Add the plugin to the list of loaded plugins.
KingRainbow44's avatar
KingRainbow44 committed
171
        this.plugins.put(identifier.name, plugin);
172
173
174
        // Create a collection for the plugin's listeners.
        this.listeners.put(plugin, new LinkedList<>());

KingRainbow44's avatar
KingRainbow44 committed
175
        // Call the plugin's onLoad method.
176
177
178
179
180
        try {
            plugin.onLoad();
        } catch (Exception exception) {
            Grasscutter.getLogger().error("Failed to load plugin: " + identifier.name, exception);
        }
KingRainbow44's avatar
KingRainbow44 committed
181
182
183
184
185
186
    }

    /**
     * Enables all registered plugins.
     */
    public void enablePlugins() {
KingRainbow44's avatar
KingRainbow44 committed
187
188
        this.plugins.forEach((name, plugin) -> {
            Grasscutter.getLogger().info("Enabling plugin: " + name);
189
190
191
192
193
            try {
                plugin.onEnable();
            } catch (Exception exception) {
                Grasscutter.getLogger().error("Failed to enable plugin: " + name, exception);
            }
KingRainbow44's avatar
KingRainbow44 committed
194
        });
KingRainbow44's avatar
KingRainbow44 committed
195
    }
196

KingRainbow44's avatar
KingRainbow44 committed
197
198
199
200
    /**
     * Disables all registered plugins.
     */
    public void disablePlugins() {
KingRainbow44's avatar
KingRainbow44 committed
201
202
        this.plugins.forEach((name, plugin) -> {
            Grasscutter.getLogger().info("Disabling plugin: " + name);
203
204
205
206
207
            try {
                plugin.onDisable();
            } catch (Exception exception) {
                Grasscutter.getLogger().error("Failed to disable plugin: " + name, exception);
            }
KingRainbow44's avatar
KingRainbow44 committed
208
        });
KingRainbow44's avatar
KingRainbow44 committed
209
    }
KingRainbow44's avatar
KingRainbow44 committed
210
211
212

    /**
     * Registers a plugin's event listener.
213
214
     *
     * @param plugin The plugin registering the listener.
KingRainbow44's avatar
KingRainbow44 committed
215
216
     * @param listener The event listener.
     */
217
218
    public void registerListener(Plugin plugin, EventHandler<? extends Event> listener) {
        this.listeners.get(plugin).add(listener);
KingRainbow44's avatar
KingRainbow44 committed
219
    }
220

KingRainbow44's avatar
KingRainbow44 committed
221
222
    /**
     * Invoke the provided event on all registered event listeners.
223
     *
KingRainbow44's avatar
KingRainbow44 committed
224
225
226
     * @param event The event to invoke.
     */
    public void invokeEvent(Event event) {
KingRainbow44's avatar
KingRainbow44 committed
227
        EnumSet.allOf(HandlerPriority.class)
228
            .forEach(priority -> this.checkAndFilter(event, priority));
KingRainbow44's avatar
KingRainbow44 committed
229
230
231
232
    }

    /**
     * Check an event to handlers for the priority.
233
234
     *
     * @param event    The event being called.
KingRainbow44's avatar
KingRainbow44 committed
235
236
237
     * @param priority The priority to call for.
     */
    private void checkAndFilter(Event event, HandlerPriority priority) {
238
239
240
241
242
243
244
245
246
247
248
249
        // 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
250
    }
251

252
253
254
255
256
257
258
    /**
     * Gets a plugin's instance by its name.
     *
     * @param name The name of the plugin.
     * @return Either null, or the plugin's instance.
     */
    @Nullable
259
260
261
262
    public Plugin getPlugin(String name) {
        return this.plugins.get(name);
    }

263
264
265
266
267
268
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
    /**
     * 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
294
    /**
295
     * Performs logic checks then invokes the provided event handler.
296
297
     *
     * @param event   The event passed through to the handler.
298
     * @param handler The handler to invoke.
KingRainbow44's avatar
KingRainbow44 committed
299
     */
KingRainbow44's avatar
KingRainbow44 committed
300
301
    @SuppressWarnings("unchecked")
    private <T extends Event> void invokeHandler(Event event, EventHandler<T> handler) {
302
303
        if (!event.isCanceled() ||
            (event.isCanceled() && handler.ignoresCanceled())
KingRainbow44's avatar
KingRainbow44 committed
304
        ) handler.getCallback().consume((T) event);
KingRainbow44's avatar
KingRainbow44 committed
305
    }
Magix's avatar
Magix committed
306
}