Initial Version
This commit is contained in:
464
core/lib/Module/ModuleManager.php
Normal file
464
core/lib/Module/ModuleManager.php
Normal file
@@ -0,0 +1,464 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use Exception;
|
||||
use KTXC\Module\Store\ModuleStore;
|
||||
use KTXC\Module\Store\ModuleEntry;
|
||||
use KTXC\Server;
|
||||
use KTXF\Module\ModuleInstanceInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
|
||||
class ModuleManager
|
||||
{
|
||||
private string $serverRoot = '';
|
||||
private array $moduleInstances = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ModuleStore $repository,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
// Initialize server root path
|
||||
$this->serverRoot = Server::runtimeRootLocation();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all modules as unified Module objects
|
||||
*
|
||||
* @param bool $installedOnly If true, only return modules that are in the database
|
||||
* @param bool $enabledOnly If true, only return modules that are enabled (implies installedOnly)
|
||||
* @return Module[]
|
||||
*/
|
||||
public function list(bool $installedOnly = true, $enabledOnly = true): ModuleCollection
|
||||
{
|
||||
$modules = New ModuleCollection();
|
||||
// load all modules from store
|
||||
$entries = $this->repository->list();
|
||||
foreach ($entries as $entry) {
|
||||
if ($enabledOnly && !$entry->getEnabled()) {
|
||||
continue; // Skip disabled modules if filtering for enabled only
|
||||
}
|
||||
// instance module
|
||||
$handle = $entry->getHandle();
|
||||
if (isset($this->moduleInstances[$entry->getHandle()])) {
|
||||
$modules[$handle] = new ModuleObject($this->moduleInstances[$handle], $entry);
|
||||
} else {
|
||||
$moduleInstance = $this->moduleInstance($handle, $entry->getNamespace());
|
||||
$modules[$handle] = new ModuleObject($moduleInstance, $entry);
|
||||
$this->moduleInstances[$handle] = $moduleInstance;
|
||||
}
|
||||
}
|
||||
// load all modules from filesystem
|
||||
if ($installedOnly === false) {
|
||||
$discovered = $this->modulesDiscover();
|
||||
foreach ($discovered as $moduleInstance) {
|
||||
$handle = $moduleInstance->handle();
|
||||
if (!isset($modules[$handle])) {
|
||||
$modules[$handle] = new ModuleObject($moduleInstance, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
public function install(string $handle): void
|
||||
{
|
||||
// First, try to find the module by scanning the filesystem
|
||||
$modulesDir = $this->serverRoot . '/modules';
|
||||
$namespace = null;
|
||||
|
||||
// Scan for the module by checking if handle matches any folder or module's handle() method
|
||||
if (is_dir($modulesDir)) {
|
||||
$moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR);
|
||||
foreach ($moduleDirs as $moduleDir) {
|
||||
$testModuleFile = $moduleDir . '/lib/Module.php';
|
||||
if (!file_exists($testModuleFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract namespace from the Module.php file
|
||||
$testNamespace = $this->extractNamespaceFromFile($testModuleFile);
|
||||
if (!$testNamespace) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to instantiate with a temporary handle to check if it matches
|
||||
$folderName = basename($moduleDir);
|
||||
$testInstance = $this->moduleInstance($folderName, $testNamespace);
|
||||
|
||||
if ($testInstance && $testInstance->handle() === $handle) {
|
||||
$namespace = $testNamespace;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$namespace) {
|
||||
$this->logger->error('Module not found for installation', ['handle' => $handle]);
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($handle, $namespace);
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->install();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module installation failed: ' . $handle, [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$module = new ModuleEntry();
|
||||
$module->setHandle($handle);
|
||||
$module->setVersion($moduleInstance->version());
|
||||
$module->setEnabled(false);
|
||||
$module->setInstalled(true);
|
||||
// Store the namespace we found
|
||||
$module->setNamespace($namespace);
|
||||
$this->repository->deposit($module);
|
||||
}
|
||||
|
||||
public function uninstall(string $handle): void
|
||||
{
|
||||
$moduleEntry = $this->repository->fetch($handle);
|
||||
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
|
||||
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
|
||||
throw new Exception('Module not installed: ' . $handle);
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->uninstall();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module uninstallation failed: ' . $moduleEntry->getHandle(), [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->repository->destroy($moduleEntry);
|
||||
}
|
||||
|
||||
public function enable(string $handle): void
|
||||
{
|
||||
$moduleEntry = $this->repository->fetch($handle);
|
||||
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
|
||||
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
|
||||
throw new Exception('Module not installed: ' . $handle);
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->enable();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module enabling failed: ' . $moduleEntry->getHandle(), [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleEntry->setEnabled(true);
|
||||
$this->repository->deposit($moduleEntry);
|
||||
}
|
||||
|
||||
public function disable(string $handle): void
|
||||
{
|
||||
$moduleEntry = $this->repository->fetch($handle);
|
||||
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
|
||||
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
|
||||
throw new Exception('Module not installed: ' . $handle);
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->disable();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module disabling failed: ' . $moduleEntry->getHandle(), [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleEntry->setEnabled(false);
|
||||
$this->repository->deposit($moduleEntry);
|
||||
}
|
||||
|
||||
public function upgrade(string $handle): void
|
||||
{
|
||||
$moduleEntry = $this->repository->fetch($handle);
|
||||
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
|
||||
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
|
||||
throw new Exception('Module not installed: ' . $handle);
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->upgrade();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module upgrade failed: ' . $moduleEntry->getHandle(), [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleEntry->setVersion($moduleInstance->version());
|
||||
$this->repository->deposit($moduleEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan filesystem for module directories and return module instances
|
||||
*
|
||||
* @return array<string, ModuleInstanceInterface> Map of handle => ModuleInstanceInterface
|
||||
*/
|
||||
private function modulesDiscover(): array
|
||||
{
|
||||
$modules = [];
|
||||
$modulesDir = $this->serverRoot . '/modules';
|
||||
|
||||
if (!is_dir($modulesDir)) {
|
||||
return $modules;
|
||||
}
|
||||
|
||||
// Get list of installed module handles to skip
|
||||
$installedHandles = [];
|
||||
foreach ($this->repository->list() as $entry) {
|
||||
$installedHandles[] = $entry->getHandle();
|
||||
}
|
||||
|
||||
// Scan for module directories
|
||||
$moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR);
|
||||
foreach ($moduleDirs as $moduleDir) {
|
||||
$moduleFile = $moduleDir . '/lib/Module.php';
|
||||
if (!file_exists($moduleFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract namespace from the Module.php file
|
||||
$namespace = $this->extractNamespaceFromFile($moduleFile);
|
||||
if (!$namespace) {
|
||||
$this->logger->warning('Could not extract namespace from Module.php', [
|
||||
'file' => $moduleFile
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use the folder name as a temporary handle to instantiate the module
|
||||
$folderName = basename($moduleDir);
|
||||
$moduleInstance = $this->moduleInstance($folderName, $namespace);
|
||||
if (!$moduleInstance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the actual handle from the module instance
|
||||
$handle = $moduleInstance->handle();
|
||||
|
||||
// Skip if already installed
|
||||
if (in_array($handle, $installedHandles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-cache with the correct handle if different from folder name
|
||||
if ($handle !== $folderName) {
|
||||
unset($this->moduleInstances[$folderName]);
|
||||
$this->moduleInstances[$handle] = $moduleInstance;
|
||||
}
|
||||
|
||||
$modules[$handle] = $moduleInstance;
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot all enabled modules (must be called after container is ready).
|
||||
*/
|
||||
public function modulesBoot(): void
|
||||
{
|
||||
// Only load modules that are enabled in the database
|
||||
$modules = $this->list();
|
||||
$this->logger->debug('Booting enabled modules', ['count' => count($modules)]);
|
||||
foreach ($modules as $module) {
|
||||
$handle = $module->handle();
|
||||
try {
|
||||
$module->boot();
|
||||
$this->logger->debug('Module booted', ['handle' => $handle]);
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module boot failed: ' . $handle, [
|
||||
'exception' => $e,
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function moduleInstance(string $handle, ?string $namespace = null): ?ModuleInstanceInterface
|
||||
{
|
||||
// Return from cache if already instantiated
|
||||
if (isset($this->moduleInstances[$handle])) {
|
||||
return $this->moduleInstances[$handle];
|
||||
}
|
||||
|
||||
// Determine the namespace segment
|
||||
// If namespace is provided, use it; otherwise derive from handle
|
||||
$nsSegment = $namespace ?: $this->studly($handle);
|
||||
|
||||
$className = 'KTXM\\' . $nsSegment . '\\Module';
|
||||
|
||||
if (!class_exists($className)) {
|
||||
$this->logger->error('Module class not found', [
|
||||
'handle' => $handle,
|
||||
'namespace' => $namespace,
|
||||
'resolved' => $className
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!in_array(ModuleInstanceInterface::class, class_implements($className))) {
|
||||
$this->logger->error('Module class does not implement ModuleInstanceInterface', [
|
||||
'class' => $className
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$module = $this->moduleLoad($className);
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Failed to lazily create module instance', [
|
||||
'handle' => $handle,
|
||||
'namespace' => $namespace,
|
||||
'exception' => $e->getMessage()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache by handle
|
||||
if ($module) {
|
||||
$this->moduleInstances[$handle] = $module;
|
||||
}
|
||||
|
||||
return $module;
|
||||
}
|
||||
|
||||
private function moduleLoad(string $className): ?ModuleInstanceInterface
|
||||
{
|
||||
try {
|
||||
// Use reflection to check constructor requirements
|
||||
$reflectionClass = new ReflectionClass($className);
|
||||
$constructor = $reflectionClass->getConstructor();
|
||||
|
||||
if (!$constructor || $constructor->getNumberOfRequiredParameters() === 0) {
|
||||
// Simple instantiation for modules without dependencies
|
||||
return new $className();
|
||||
}
|
||||
|
||||
// For modules with dependencies, try to resolve them from the container
|
||||
$container = Server::runtimeContainer();
|
||||
$parameters = $constructor->getParameters();
|
||||
$args = [];
|
||||
|
||||
foreach ($parameters as $parameter) {
|
||||
$type = $parameter->getType();
|
||||
if ($type && !$type->isBuiltin()) {
|
||||
$typeName = $type->getName();
|
||||
|
||||
// Try to get service from container
|
||||
if ($container->has($typeName)) {
|
||||
$args[] = $container->get($typeName);
|
||||
} elseif ($parameter->isDefaultValueAvailable()) {
|
||||
$args[] = $parameter->getDefaultValue();
|
||||
} else {
|
||||
// Cannot resolve dependency
|
||||
$this->logger->warning('Cannot resolve dependency for module: ' . $className, [
|
||||
'dependency' => $typeName
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
} elseif ($parameter->isDefaultValueAvailable()) {
|
||||
$args[] = $parameter->getDefaultValue();
|
||||
} else {
|
||||
// Cannot resolve primitive dependency
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $reflectionClass->newInstanceArgs($args);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Failed to instantiate module: ' . $className, [
|
||||
'exception' => $e->getMessage()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function studly(string $value): string
|
||||
{
|
||||
$value = str_replace(['-', '_'], ' ', strtolower($value));
|
||||
$value = ucwords($value);
|
||||
return str_replace(' ', '', $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the PHP namespace from a Module.php file by parsing its contents
|
||||
*
|
||||
* @param string $moduleFilePath Absolute path to the Module.php file (located at /modules/{handle}/lib/Module.php)
|
||||
* @return string|null The namespace segment (e.g., 'ContactsManager' from 'KTXM\ContactsManager')
|
||||
*/
|
||||
private function extractNamespaceFromFile(string $moduleFilePath): ?string
|
||||
{
|
||||
if (!file_exists($moduleFilePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($moduleFilePath);
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match namespace declaration: namespace KTXM\<Namespace>;
|
||||
if (preg_match('/^\s*namespace\s+KTXM\\\\([a-zA-Z0-9_]+)\s*;/m', $content, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user