PocketMine-MP 5.15.1 git-5ef247620a7c6301a849b54e5ef1009217729fc8
PluginManager.php
1<?php
2
3/*
4 *
5 * ____ _ _ __ __ _ __ __ ____
6 * | _ \ ___ ___| | _____| |_| \/ (_)_ __ ___ | \/ | _ \
7 * | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
8 * | __/ (_) | (__| < __/ |_| | | | | | | | __/_____| | | | __/
9 * |_| \___/ \___|_|\_\___|\__|_| |_|_|_| |_|\___| |_| |_|_|
10 *
11 * This program is free software: you can redistribute it and/or modify
12 * it under the terms of the GNU Lesser General Public License as published by
13 * the Free Software Foundation, either version 3 of the License, or
14 * (at your option) any later version.
15 *
16 * @author PocketMine Team
17 * @link http://www.pocketmine.net/
18 *
19 *
20 */
21
22declare(strict_types=1);
23
24namespace pocketmine\plugin;
25
43use Symfony\Component\Filesystem\Path;
44use function array_diff_key;
45use function array_key_exists;
46use function array_keys;
47use function array_merge;
48use function class_exists;
49use function count;
50use function dirname;
51use function file_exists;
52use function get_class;
53use function implode;
54use function is_a;
55use function is_array;
56use function is_dir;
57use function is_file;
58use function is_string;
59use function is_subclass_of;
60use function iterator_to_array;
61use function mkdir;
62use function realpath;
63use function shuffle;
64use function sprintf;
65use function str_contains;
66use function strtolower;
67
73 protected array $plugins = [];
74
76 protected array $enabledPlugins = [];
77
79 private array $pluginDependents = [];
80
81 private bool $loadPluginsGuard = false;
82
87 protected array $fileAssociations = [];
88
89 public function __construct(
90 private Server $server,
91 private ?string $pluginDataDirectory,
92 private ?PluginGraylist $graylist = null
93 ){
94 if($this->pluginDataDirectory !== null){
95 if(!file_exists($this->pluginDataDirectory)){
96 @mkdir($this->pluginDataDirectory, 0777, true);
97 }elseif(!is_dir($this->pluginDataDirectory)){
98 throw new \RuntimeException("Plugin data path $this->pluginDataDirectory exists and is not a directory");
99 }
100 }
101 }
102
103 public function getPlugin(string $name) : ?Plugin{
104 if(isset($this->plugins[$name])){
105 return $this->plugins[$name];
106 }
107
108 return null;
109 }
110
111 public function registerInterface(PluginLoader $loader) : void{
112 $this->fileAssociations[get_class($loader)] = $loader;
113 }
114
118 public function getPlugins() : array{
119 return $this->plugins;
120 }
121
122 private function getDataDirectory(string $pluginPath, string $pluginName) : string{
123 if($this->pluginDataDirectory !== null){
124 return Path::join($this->pluginDataDirectory, $pluginName);
125 }
126 return Path::join(dirname($pluginPath), $pluginName);
127 }
128
129 private function internalLoadPlugin(string $path, PluginLoader $loader, PluginDescription $description) : ?Plugin{
130 $language = $this->server->getLanguage();
131 $this->server->getLogger()->info($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_plugin_load($description->getFullName())));
132
133 $dataFolder = $this->getDataDirectory($path, $description->getName());
134 if(file_exists($dataFolder) && !is_dir($dataFolder)){
135 $this->server->getLogger()->critical($language->translate(KnownTranslationFactory::pocketmine_plugin_loadError(
136 $description->getName(),
137 KnownTranslationFactory::pocketmine_plugin_badDataFolder($dataFolder)
138 )));
139 return null;
140 }
141 if(!file_exists($dataFolder)){
142 mkdir($dataFolder, 0777, true);
143 }
144
145 $prefixed = $loader->getAccessProtocol() . $path;
146 $loader->loadPlugin($prefixed);
147
148 $mainClass = $description->getMain();
149 if(!class_exists($mainClass, true)){
150 $this->server->getLogger()->critical($language->translate(KnownTranslationFactory::pocketmine_plugin_loadError(
151 $description->getName(),
152 KnownTranslationFactory::pocketmine_plugin_mainClassNotFound()
153 )));
154 return null;
155 }
156 if(!is_a($mainClass, Plugin::class, true)){
157 $this->server->getLogger()->critical($language->translate(KnownTranslationFactory::pocketmine_plugin_loadError(
158 $description->getName(),
159 KnownTranslationFactory::pocketmine_plugin_mainClassWrongType(Plugin::class)
160 )));
161 return null;
162 }
163 $reflect = new \ReflectionClass($mainClass); //this shouldn't throw; we already checked that it exists
164 if(!$reflect->isInstantiable()){
165 $this->server->getLogger()->critical($language->translate(KnownTranslationFactory::pocketmine_plugin_loadError(
166 $description->getName(),
167 KnownTranslationFactory::pocketmine_plugin_mainClassAbstract()
168 )));
169 return null;
170 }
171
172 $permManager = PermissionManager::getInstance();
173 foreach($description->getPermissions() as $permsGroup){
174 foreach($permsGroup as $perm){
175 if($permManager->getPermission($perm->getName()) !== null){
176 $this->server->getLogger()->critical($language->translate(KnownTranslationFactory::pocketmine_plugin_loadError(
177 $description->getName(),
178 KnownTranslationFactory::pocketmine_plugin_duplicatePermissionError($perm->getName())
179 )));
180 return null;
181 }
182 }
183 }
184 $opRoot = $permManager->getPermission(DefaultPermissions::ROOT_OPERATOR);
185 $everyoneRoot = $permManager->getPermission(DefaultPermissions::ROOT_USER);
186 foreach(Utils::stringifyKeys($description->getPermissions()) as $default => $perms){
187 foreach($perms as $perm){
188 $permManager->addPermission($perm);
189 switch($default){
190 case PermissionParser::DEFAULT_TRUE:
191 $everyoneRoot->addChild($perm->getName(), true);
192 break;
193 case PermissionParser::DEFAULT_OP:
194 $opRoot->addChild($perm->getName(), true);
195 break;
196 case PermissionParser::DEFAULT_NOT_OP:
197 //TODO: I don't think anyone uses this, and it currently relies on some magic inside PermissibleBase
198 //to ensure that the operator override actually applies.
199 //Explore getting rid of this.
200 //The following grants this permission to anyone who has the "everyone" root permission.
201 //However, if the operator root node (which has higher priority) is present, the
202 //permission will be denied instead.
203 $everyoneRoot->addChild($perm->getName(), true);
204 $opRoot->addChild($perm->getName(), false);
205 break;
206 default:
207 break;
208 }
209 }
210 }
211
216 $plugin = new $mainClass($loader, $this->server, $description, $dataFolder, $prefixed, new DiskResourceProvider($prefixed . "/resources/"));
217 $this->plugins[$plugin->getDescription()->getName()] = $plugin;
218
219 return $plugin;
220 }
221
226 private function triagePlugins(string $path, PluginLoadTriage $triage, int &$loadErrorCount, ?array $newLoaders = null) : void{
227 if(is_array($newLoaders)){
228 $loaders = [];
229 foreach($newLoaders as $key){
230 if(isset($this->fileAssociations[$key])){
231 $loaders[$key] = $this->fileAssociations[$key];
232 }
233 }
234 }else{
235 $loaders = $this->fileAssociations;
236 }
237
238 if(is_dir($path)){
239 $files = iterator_to_array(new \FilesystemIterator($path, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS));
240 shuffle($files); //this prevents plugins implicitly relying on the filesystem name order when they should be using dependency properties
241 }elseif(is_file($path)){
242 $realPath = Utils::assumeNotFalse(realpath($path), "realpath() should not return false on an accessible, existing file");
243 $files = [$realPath];
244 }else{
245 return;
246 }
247
248 $loadabilityChecker = new PluginLoadabilityChecker($this->server->getApiVersion());
249 foreach($loaders as $loader){
250 foreach($files as $file){
251 if(!is_string($file)) throw new AssumptionFailedError("FilesystemIterator current should be string when using CURRENT_AS_PATHNAME");
252 if(!$loader->canLoadPlugin($file)){
253 continue;
254 }
255 try{
256 $description = $loader->getPluginDescription($file);
257 }catch(PluginDescriptionParseException $e){
258 $this->server->getLogger()->critical($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_plugin_loadError(
259 $file,
260 KnownTranslationFactory::pocketmine_plugin_invalidManifest($e->getMessage())
261 )));
262 $loadErrorCount++;
263 continue;
264 }catch(\RuntimeException $e){ //TODO: more specific exception handling
265 $this->server->getLogger()->critical($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_plugin_loadError($file, $e->getMessage())));
266 $this->server->getLogger()->logException($e);
267 $loadErrorCount++;
268 continue;
269 }
270 if($description === null){
271 continue;
272 }
273
274 $name = $description->getName();
275
276 if($this->graylist !== null && !$this->graylist->isAllowed($name)){
277 $this->server->getLogger()->notice($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_plugin_loadError(
278 $name,
279 $this->graylist->isWhitelist() ? KnownTranslationFactory::pocketmine_plugin_disallowedByWhitelist() : KnownTranslationFactory::pocketmine_plugin_disallowedByBlacklist()
280 )));
281 //this does NOT increment loadErrorCount, because using the graylist to prevent a plugin from
282 //loading is not considered accidental; this is the same as if the plugin were manually removed
283 //this means that the server will continue to boot even if some plugins were blocked by graylist
284 continue;
285 }
286
287 if(($loadabilityError = $loadabilityChecker->check($description)) !== null){
288 $this->server->getLogger()->critical($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_plugin_loadError($name, $loadabilityError)));
289 $loadErrorCount++;
290 continue;
291 }
292
293 if(isset($triage->plugins[$name]) || $this->getPlugin($name) instanceof Plugin){
294 $this->server->getLogger()->critical($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_plugin_duplicateError($name)));
295 $loadErrorCount++;
296 continue;
297 }
298
299 if(str_contains($name, " ")){
300 $this->server->getLogger()->warning($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_plugin_spacesDiscouraged($name)));
301 }
302
303 $triage->plugins[$name] = new PluginLoadTriageEntry($file, $loader, $description);
304
305 $triage->softDependencies[$name] = array_merge($triage->softDependencies[$name] ?? [], $description->getSoftDepend());
306 $triage->dependencies[$name] = $description->getDepend();
307
308 foreach($description->getLoadBefore() as $before){
309 if(isset($triage->softDependencies[$before])){
310 $triage->softDependencies[$before][] = $name;
311 }else{
312 $triage->softDependencies[$before] = [$name];
313 }
314 }
315 }
316 }
317 }
318
326 private function checkDepsForTriage(string $pluginName, string $dependencyType, array &$dependencyLists, array $loadedPlugins, PluginLoadTriage $triage) : void{
327 if(isset($dependencyLists[$pluginName])){
328 foreach($dependencyLists[$pluginName] as $key => $dependency){
329 if(isset($loadedPlugins[$dependency]) || $this->getPlugin($dependency) instanceof Plugin){
330 $this->server->getLogger()->debug("Successfully resolved $dependencyType dependency \"$dependency\" for plugin \"$pluginName\"");
331 unset($dependencyLists[$pluginName][$key]);
332 }elseif(array_key_exists($dependency, $triage->plugins)){
333 $this->server->getLogger()->debug("Deferring resolution of $dependencyType dependency \"$dependency\" for plugin \"$pluginName\" (found but not loaded yet)");
334 }
335 }
336
337 if(count($dependencyLists[$pluginName]) === 0){
338 unset($dependencyLists[$pluginName]);
339 }
340 }
341 }
342
346 public function loadPlugins(string $path, int &$loadErrorCount = 0) : array{
347 if($this->loadPluginsGuard){
348 throw new \LogicException(__METHOD__ . "() cannot be called from within itself");
349 }
350 $this->loadPluginsGuard = true;
351
352 $triage = new PluginLoadTriage();
353 $this->triagePlugins($path, $triage, $loadErrorCount);
354
355 $loadedPlugins = [];
356
357 while(count($triage->plugins) > 0){
358 $loadedThisLoop = 0;
359 foreach(Utils::stringifyKeys($triage->plugins) as $name => $entry){
360 $this->checkDepsForTriage($name, "hard", $triage->dependencies, $loadedPlugins, $triage);
361 $this->checkDepsForTriage($name, "soft", $triage->softDependencies, $loadedPlugins, $triage);
362
363 if(!isset($triage->dependencies[$name]) && !isset($triage->softDependencies[$name])){
364 unset($triage->plugins[$name]);
365 $loadedThisLoop++;
366
367 $oldRegisteredLoaders = $this->fileAssociations;
368 if(($plugin = $this->internalLoadPlugin($entry->getFile(), $entry->getLoader(), $entry->getDescription())) instanceof Plugin){
369 $loadedPlugins[$name] = $plugin;
370 $diffLoaders = [];
371 foreach($this->fileAssociations as $k => $loader){
372 if(!array_key_exists($k, $oldRegisteredLoaders)){
373 $diffLoaders[] = $k;
374 }
375 }
376 if(count($diffLoaders) !== 0){
377 $this->server->getLogger()->debug("Plugin $name registered a new plugin loader during load, scanning for new plugins");
378 $plugins = $triage->plugins;
379 $this->triagePlugins($path, $triage, $loadErrorCount, $diffLoaders);
380 $diffPlugins = array_diff_key($triage->plugins, $plugins);
381 $this->server->getLogger()->debug("Re-triage found plugins: " . implode(", ", array_keys($diffPlugins)));
382 }
383 }else{
384 $loadErrorCount++;
385 }
386 }
387 }
388
389 if($loadedThisLoop === 0){
390 //No plugins loaded :(
391
392 //check for skippable soft dependencies first, in case the dependents could resolve hard dependencies
393 foreach(Utils::stringifyKeys($triage->plugins) as $name => $file){
394 if(isset($triage->softDependencies[$name]) && !isset($triage->dependencies[$name])){
395 foreach($triage->softDependencies[$name] as $k => $dependency){
396 if($this->getPlugin($dependency) === null && !array_key_exists($dependency, $triage->plugins)){
397 $this->server->getLogger()->debug("Skipping resolution of missing soft dependency \"$dependency\" for plugin \"$name\"");
398 unset($triage->softDependencies[$name][$k]);
399 }
400 }
401 if(count($triage->softDependencies[$name]) === 0){
402 unset($triage->softDependencies[$name]);
403 continue 2; //go back to the top and try again
404 }
405 }
406 }
407
408 foreach(Utils::stringifyKeys($triage->plugins) as $name => $file){
409 if(isset($triage->dependencies[$name])){
410 $unknownDependencies = [];
411
412 foreach($triage->dependencies[$name] as $k => $dependency){
413 if($this->getPlugin($dependency) === null && !array_key_exists($dependency, $triage->plugins)){
414 //assume that the plugin is never going to be loaded
415 //by this point all soft dependencies have been ignored if they were able to be, so
416 //there's no chance of this dependency ever being resolved
417 $unknownDependencies[$dependency] = $dependency;
418 }
419 }
420
421 if(count($unknownDependencies) > 0){
422 $this->server->getLogger()->critical($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_plugin_loadError(
423 $name,
424 KnownTranslationFactory::pocketmine_plugin_unknownDependency(implode(", ", $unknownDependencies))
425 )));
426 unset($triage->plugins[$name]);
427 $loadErrorCount++;
428 }
429 }
430 }
431
432 foreach(Utils::stringifyKeys($triage->plugins) as $name => $file){
433 $this->server->getLogger()->critical($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_plugin_loadError($name, KnownTranslationFactory::pocketmine_plugin_circularDependency())));
434 $loadErrorCount++;
435 }
436 break;
437 }
438 }
439
440 $this->loadPluginsGuard = false;
441 return $loadedPlugins;
442 }
443
444 public function isPluginEnabled(Plugin $plugin) : bool{
445 return isset($this->plugins[$plugin->getDescription()->getName()]) && $plugin->isEnabled();
446 }
447
448 public function enablePlugin(Plugin $plugin) : bool{
449 if(!$plugin->isEnabled()){
450 $this->server->getLogger()->info($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_plugin_enable($plugin->getDescription()->getFullName())));
451
452 $plugin->getScheduler()->setEnabled(true);
453 try{
454 $plugin->onEnableStateChange(true);
455 }catch(DisablePluginException){
456 $this->disablePlugin($plugin);
457 }
458
459 if($plugin->isEnabled()){ //the plugin may have disabled itself during onEnable()
460 $this->enabledPlugins[$plugin->getDescription()->getName()] = $plugin;
461
462 foreach($plugin->getDescription()->getDepend() as $dependency){
463 $this->pluginDependents[$dependency][$plugin->getDescription()->getName()] = true;
464 }
465 foreach($plugin->getDescription()->getSoftDepend() as $dependency){
466 if(isset($this->plugins[$dependency])){
467 $this->pluginDependents[$dependency][$plugin->getDescription()->getName()] = true;
468 }
469 }
470
471 (new PluginEnableEvent($plugin))->call();
472
473 return true;
474 }else{
475 $this->server->getLogger()->critical($this->server->getLanguage()->translate(
476 KnownTranslationFactory::pocketmine_plugin_enableError(
477 $plugin->getName(),
478 KnownTranslationFactory::pocketmine_plugin_suicide()
479 )
480 ));
481
482 return false;
483 }
484 }
485
486 return true; //TODO: maybe this should be an error?
487 }
488
489 public function disablePlugins() : void{
490 while(count($this->enabledPlugins) > 0){
491 foreach($this->enabledPlugins as $plugin){
492 if(!$plugin->isEnabled()){
493 continue; //in case a plugin disabled another plugin
494 }
495 $name = $plugin->getDescription()->getName();
496 if(isset($this->pluginDependents[$name]) && count($this->pluginDependents[$name]) > 0){
497 $this->server->getLogger()->debug("Deferring disable of plugin $name due to dependent plugins still enabled: " . implode(", ", array_keys($this->pluginDependents[$name])));
498 continue;
499 }
500
501 $this->disablePlugin($plugin);
502 }
503 }
504 }
505
506 public function disablePlugin(Plugin $plugin) : void{
507 if($plugin->isEnabled()){
508 $this->server->getLogger()->info($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_plugin_disable($plugin->getDescription()->getFullName())));
509 (new PluginDisableEvent($plugin))->call();
510
511 unset($this->enabledPlugins[$plugin->getDescription()->getName()]);
512 foreach(Utils::stringifyKeys($this->pluginDependents) as $dependency => $dependentList){
513 if(isset($this->pluginDependents[$dependency][$plugin->getDescription()->getName()])){
514 if(count($this->pluginDependents[$dependency]) === 1){
515 unset($this->pluginDependents[$dependency]);
516 }else{
517 unset($this->pluginDependents[$dependency][$plugin->getDescription()->getName()]);
518 }
519 }
520 }
521
522 $plugin->onEnableStateChange(false);
523 $plugin->getScheduler()->shutdown();
524 HandlerListManager::global()->unregisterAll($plugin);
525 }
526 }
527
528 public function tickSchedulers(int $currentTick) : void{
529 foreach($this->enabledPlugins as $pluginName => $p){
530 if(isset($this->enabledPlugins[$pluginName])){
531 //the plugin may have been disabled as a result of updating other plugins' schedulers, and therefore
532 //removed from enabledPlugins; however, foreach will still see it due to copy-on-write
533 $p->getScheduler()->mainThreadHeartbeat($currentTick);
534 }
535 }
536 }
537
538 public function clearPlugins() : void{
539 $this->disablePlugins();
540 $this->plugins = [];
541 $this->enabledPlugins = [];
542 $this->fileAssociations = [];
543 }
544
555 private function getEventsHandledBy(\ReflectionMethod $method) : ?string{
556 if($method->isStatic() || !$method->getDeclaringClass()->implementsInterface(Listener::class)){
557 return null;
558 }
559 $tags = Utils::parseDocComment((string) $method->getDocComment());
560 if(isset($tags[ListenerMethodTags::NOT_HANDLER])){
561 return null;
562 }
563
564 $parameters = $method->getParameters();
565 if(count($parameters) !== 1){
566 return null;
567 }
568
569 $paramType = $parameters[0]->getType();
570 //isBuiltin() returns false for builtin classes ..................
571 if(!$paramType instanceof \ReflectionNamedType || $paramType->isBuiltin()){
572 return null;
573 }
574
576 $paramClass = $paramType->getName();
577 $eventClass = new \ReflectionClass($paramClass);
578 if(!$eventClass->isSubclassOf(Event::class)){
579 return null;
580 }
581
583 return $eventClass->getName();
584 }
585
591 public function registerEvents(Listener $listener, Plugin $plugin) : void{
592 if(!$plugin->isEnabled()){
593 throw new PluginException("Plugin attempted to register " . get_class($listener) . " while not enabled");
594 }
595
596 $reflection = new \ReflectionClass(get_class($listener));
597 foreach($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method){
598 $tags = Utils::parseDocComment((string) $method->getDocComment());
599 if(isset($tags[ListenerMethodTags::NOT_HANDLER]) || ($eventClass = $this->getEventsHandledBy($method)) === null){
600 continue;
601 }
602 $handlerClosure = $method->getClosure($listener);
603 if($handlerClosure === null) throw new AssumptionFailedError("This should never happen");
604
605 try{
606 $priority = isset($tags[ListenerMethodTags::PRIORITY]) ? EventPriority::fromString($tags[ListenerMethodTags::PRIORITY]) : EventPriority::NORMAL;
607 }catch(\InvalidArgumentException $e){
608 throw new PluginException("Event handler " . Utils::getNiceClosureName($handlerClosure) . "() declares invalid/unknown priority \"" . $tags[ListenerMethodTags::PRIORITY] . "\"");
609 }
610
611 $handleCancelled = false;
612 if(isset($tags[ListenerMethodTags::HANDLE_CANCELLED])){
613 if(!is_a($eventClass, Cancellable::class, true)){
614 throw new PluginException(sprintf(
615 "Event handler %s() declares @%s for non-cancellable event of type %s",
616 Utils::getNiceClosureName($handlerClosure),
617 ListenerMethodTags::HANDLE_CANCELLED,
618 $eventClass
619 ));
620 }
621 switch(strtolower($tags[ListenerMethodTags::HANDLE_CANCELLED])){
622 case "true":
623 case "":
624 $handleCancelled = true;
625 break;
626 case "false":
627 break;
628 default:
629 throw new PluginException("Event handler " . Utils::getNiceClosureName($handlerClosure) . "() declares invalid @" . ListenerMethodTags::HANDLE_CANCELLED . " value \"" . $tags[ListenerMethodTags::HANDLE_CANCELLED] . "\"");
630 }
631 }
632
633 $this->registerEvent($eventClass, $handlerClosure, $priority, $plugin, $handleCancelled);
634 }
635 }
636
646 public function registerEvent(string $event, \Closure $handler, int $priority, Plugin $plugin, bool $handleCancelled = false) : RegisteredListener{
647 if(!is_subclass_of($event, Event::class)){
648 throw new PluginException($event . " is not an Event");
649 }
650
651 $handlerName = Utils::getNiceClosureName($handler);
652
653 $reflect = new \ReflectionFunction($handler);
654 if($reflect->isGenerator()){
655 throw new PluginException("Generator function $handlerName cannot be used as an event handler");
656 }
657
658 if(!$plugin->isEnabled()){
659 throw new PluginException("Plugin attempted to register event handler " . $handlerName . "() to event " . $event . " while not enabled");
660 }
661
662 $timings = Timings::getEventHandlerTimings($event, $handlerName, $plugin->getDescription()->getFullName());
663
664 $registeredListener = new RegisteredListener($handler, $priority, $plugin, $handleCancelled, $timings);
665 HandlerListManager::global()->getListFor($event)->register($registeredListener);
666 return $registeredListener;
667 }
668}
registerEvent(string $event, \Closure $handler, int $priority, Plugin $plugin, bool $handleCancelled=false)
loadPlugins(string $path, int &$loadErrorCount=0)
registerEvents(Listener $listener, Plugin $plugin)