PocketMine-MP 5.21.2 git-a6534ecbbbcf369264567d27e5ed70f7f5be9816
Loading...
Searching...
No Matches
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, $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
490 public function disablePlugins() : void{
491 while(count($this->enabledPlugins) > 0){
492 foreach($this->enabledPlugins as $plugin){
493 $name = $plugin->getDescription()->getName();
494 if(isset($this->pluginDependents[$name]) && count($this->pluginDependents[$name]) > 0){
495 $this->server->getLogger()->debug("Deferring disable of plugin $name due to dependent plugins still enabled: " . implode(", ", array_keys($this->pluginDependents[$name])));
496 continue;
497 }
498
499 $this->disablePlugin($plugin);
500 }
501 }
502 }
503
504 private function disablePlugin(Plugin $plugin) : void{
505 if($plugin->isEnabled()){
506 $this->server->getLogger()->info($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_plugin_disable($plugin->getDescription()->getFullName())));
507 (new PluginDisableEvent($plugin))->call();
508
509 unset($this->enabledPlugins[$plugin->getDescription()->getName()]);
510 foreach(Utils::stringifyKeys($this->pluginDependents) as $dependency => $dependentList){
511 if(isset($this->pluginDependents[$dependency][$plugin->getDescription()->getName()])){
512 if(count($this->pluginDependents[$dependency]) === 1){
513 unset($this->pluginDependents[$dependency]);
514 }else{
515 unset($this->pluginDependents[$dependency][$plugin->getDescription()->getName()]);
516 }
517 }
518 }
519
520 $plugin->onEnableStateChange(false);
521 $plugin->getScheduler()->shutdown();
522 HandlerListManager::global()->unregisterAll($plugin);
523 }
524 }
525
526 public function tickSchedulers(int $currentTick) : void{
527 foreach($this->enabledPlugins as $pluginName => $p){
528 $p->getScheduler()->mainThreadHeartbeat($currentTick);
529 }
530 }
531
542 private function getEventsHandledBy(\ReflectionMethod $method) : ?string{
543 if($method->isStatic() || !$method->getDeclaringClass()->implementsInterface(Listener::class)){
544 return null;
545 }
546 $tags = Utils::parseDocComment((string) $method->getDocComment());
547 if(isset($tags[ListenerMethodTags::NOT_HANDLER])){
548 return null;
549 }
550
551 $parameters = $method->getParameters();
552 if(count($parameters) !== 1){
553 return null;
554 }
555
556 $paramType = $parameters[0]->getType();
557 //isBuiltin() returns false for builtin classes ..................
558 if(!$paramType instanceof \ReflectionNamedType || $paramType->isBuiltin()){
559 return null;
560 }
561
563 $paramClass = $paramType->getName();
564 $eventClass = new \ReflectionClass($paramClass);
565 if(!$eventClass->isSubclassOf(Event::class)){
566 return null;
567 }
568
570 return $eventClass->getName();
571 }
572
578 public function registerEvents(Listener $listener, Plugin $plugin) : void{
579 if(!$plugin->isEnabled()){
580 throw new PluginException("Plugin attempted to register " . get_class($listener) . " while not enabled");
581 }
582
583 $reflection = new \ReflectionClass(get_class($listener));
584 foreach($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method){
585 $tags = Utils::parseDocComment((string) $method->getDocComment());
586 if(isset($tags[ListenerMethodTags::NOT_HANDLER]) || ($eventClass = $this->getEventsHandledBy($method)) === null){
587 continue;
588 }
589 $handlerClosure = $method->getClosure($listener);
590 if($handlerClosure === null) throw new AssumptionFailedError("This should never happen");
591
592 try{
593 $priority = isset($tags[ListenerMethodTags::PRIORITY]) ? EventPriority::fromString($tags[ListenerMethodTags::PRIORITY]) : EventPriority::NORMAL;
594 }catch(\InvalidArgumentException $e){
595 throw new PluginException("Event handler " . Utils::getNiceClosureName($handlerClosure) . "() declares invalid/unknown priority \"" . $tags[ListenerMethodTags::PRIORITY] . "\"");
596 }
597
598 $handleCancelled = false;
599 if(isset($tags[ListenerMethodTags::HANDLE_CANCELLED])){
600 if(!is_a($eventClass, Cancellable::class, true)){
601 throw new PluginException(sprintf(
602 "Event handler %s() declares @%s for non-cancellable event of type %s",
603 Utils::getNiceClosureName($handlerClosure),
604 ListenerMethodTags::HANDLE_CANCELLED,
605 $eventClass
606 ));
607 }
608 switch(strtolower($tags[ListenerMethodTags::HANDLE_CANCELLED])){
609 case "true":
610 case "":
611 $handleCancelled = true;
612 break;
613 case "false":
614 break;
615 default:
616 throw new PluginException("Event handler " . Utils::getNiceClosureName($handlerClosure) . "() declares invalid @" . ListenerMethodTags::HANDLE_CANCELLED . " value \"" . $tags[ListenerMethodTags::HANDLE_CANCELLED] . "\"");
617 }
618 }
619
620 $this->registerEvent($eventClass, $handlerClosure, $priority, $plugin, $handleCancelled);
621 }
622 }
623
633 public function registerEvent(string $event, \Closure $handler, int $priority, Plugin $plugin, bool $handleCancelled = false) : RegisteredListener{
634 if(!is_subclass_of($event, Event::class)){
635 throw new PluginException($event . " is not an Event");
636 }
637
638 $handlerName = Utils::getNiceClosureName($handler);
639
640 $reflect = new \ReflectionFunction($handler);
641 if($reflect->isGenerator()){
642 throw new PluginException("Generator function $handlerName cannot be used as an event handler");
643 }
644
645 if(!$plugin->isEnabled()){
646 throw new PluginException("Plugin attempted to register event handler " . $handlerName . "() to event " . $event . " while not enabled");
647 }
648
649 $timings = Timings::getEventHandlerTimings($event, $handlerName, $plugin->getDescription()->getFullName());
650
651 $registeredListener = new RegisteredListener($handler, $priority, $plugin, $handleCancelled, $timings);
652 HandlerListManager::global()->getListFor($event)->register($registeredListener);
653 return $registeredListener;
654 }
655}
registerEvent(string $event, \Closure $handler, int $priority, Plugin $plugin, bool $handleCancelled=false)
loadPlugins(string $path, int &$loadErrorCount=0)
registerEvents(Listener $listener, Plugin $plugin)