Commit a130897f authored by catch's avatar catch
Browse files

Issue #474684 by bnjmnm, dawehner, tedbow, pfrenssen, JohnAlbin, ademarco,...

Issue #474684 by bnjmnm, dawehner, tedbow, pfrenssen, JohnAlbin, ademarco, kalpaitch, vdacosta@voidtek.com, rensingh99, markcarver, jungle, jhedstrom, RobLoach, almaudoh, kevineinarsson, shaal, dpagini, thedavidmeister, sreynen, Snugug, Miguel.kode, kamkejj, alexpott, Pol, sun, Wim Leers, lauriii, tim.plunkett, eaton: Allow themes to declare dependencies on modules
parent 0d78f4c0
......@@ -522,7 +522,7 @@ services:
class: Drupal\Core\Extension\ModuleInstaller
tags:
- { name: service_collector, tag: 'module_install.uninstall_validator', call: addUninstallValidator }
arguments: ['%app.root%', '@module_handler', '@kernel']
arguments: ['%app.root%', '@module_handler', '@kernel', '@extension.list.theme']
lazy: true
extension.list.module:
class: Drupal\Core\Extension\ModuleExtensionList
......@@ -548,12 +548,18 @@ services:
- { name: module_install.uninstall_validator }
arguments: ['@string_translation', '@extension.list.module']
lazy: true
module_required_by_themes_uninstall_validator:
class: Drupal\Core\Extension\ModuleRequiredByThemesUninstallValidator
tags:
- { name: module_install.uninstall_validator }
arguments: ['@string_translation', '@extension.list.module', '@extension.list.theme']
lazy: true
theme_handler:
class: Drupal\Core\Extension\ThemeHandler
arguments: ['%app.root%', '@config.factory', '@extension.list.theme']
theme_installer:
class: Drupal\Core\Extension\ThemeInstaller
arguments: ['@theme_handler', '@config.factory', '@config.installer', '@module_handler', '@config.manager', '@asset.css.collection_optimizer', '@router.builder', '@logger.channel.default', '@state']
arguments: ['@theme_handler', '@config.factory', '@config.installer', '@module_handler', '@config.manager', '@asset.css.collection_optimizer', '@router.builder', '@logger.channel.default', '@state', '@extension.list.module']
entity.memory_cache:
class: Drupal\Core\Cache\MemoryCache\MemoryCache
entity_type.manager:
......
......@@ -50,6 +50,13 @@ class ModuleInstaller implements ModuleInstallerInterface {
*/
protected $uninstallValidators;
/**
* The theme extension list.
*
* @var \Drupal\Core\Extension\ThemeExtensionList
*/
protected $themeExtensionList;
/**
* Constructs a new ModuleInstaller instance.
*
......@@ -59,14 +66,21 @@ class ModuleInstaller implements ModuleInstallerInterface {
* The module handler.
* @param \Drupal\Core\DrupalKernelInterface $kernel
* The drupal kernel.
* @param \Drupal\Core\Extension\ThemeExtensionList $extension_list_theme
* The theme extension list.
*
* @see \Drupal\Core\DrupalKernel
* @see \Drupal\Core\CoreServiceProvider
*/
public function __construct($root, ModuleHandlerInterface $module_handler, DrupalKernelInterface $kernel) {
public function __construct($root, ModuleHandlerInterface $module_handler, DrupalKernelInterface $kernel, ThemeExtensionList $extension_list_theme = NULL) {
$this->root = $root;
$this->moduleHandler = $module_handler;
$this->kernel = $kernel;
if (is_null($extension_list_theme)) {
@trigger_error('The extension.list.theme service must be passed to ' . __NAMESPACE__ . '\ModuleInstaller::__construct(). It was added in drupal:8.9.0 and will be required before drupal:10.0.0.', E_USER_DEPRECATED);
$extension_list_theme = \Drupal::service('extension.list.theme');
}
$this->themeExtensionList = $extension_list_theme;
}
/**
......@@ -372,12 +386,14 @@ public function uninstall(array $module_list, $uninstall_dependents = TRUE) {
}
if ($uninstall_dependents) {
$theme_list = $this->themeExtensionList->getList();
// Add dependent modules to the list. The new modules will be processed as
// the foreach loop continues.
foreach ($module_list as $module => $value) {
foreach (array_keys($module_data[$module]->required_by) as $dependent) {
if (!isset($module_data[$dependent])) {
// The dependent module does not exist.
if (!isset($module_data[$dependent]) && !isset($theme_list[$dependent])) {
// The dependent module or theme does not exist.
return FALSE;
}
......@@ -578,6 +594,7 @@ protected function updateKernel($module_filenames) {
// After rebuilding the container we need to update the injected
// dependencies.
$container = $this->kernel->getContainer();
$this->themeExtensionList = $container->get('extension.list.theme');
$this->moduleHandler = $container->get('module_handler');
}
......
<?php
namespace Drupal\Core\Extension;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
/**
* Ensures modules cannot be uninstalled if enabled themes depend on them.
*/
class ModuleRequiredByThemesUninstallValidator implements ModuleUninstallValidatorInterface {
use StringTranslationTrait;
/**
* The module extension list.
*
* @var \Drupal\Core\Extension\ModuleExtensionList
*/
protected $moduleExtensionList;
/**
* The theme extension list.
*
* @var \Drupal\Core\Extension\ThemeExtensionList
*/
protected $themeExtensionList;
/**
* Constructs a new ModuleRequiredByThemesUninstallValidator.
*
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* The string translation service.
* @param \Drupal\Core\Extension\ModuleExtensionList $extension_list_module
* The module extension list.
* @param \Drupal\Core\Extension\ThemeExtensionList $extension_list_theme
* The theme extension list.
*/
public function __construct(TranslationInterface $string_translation, ModuleExtensionList $extension_list_module, ThemeExtensionList $extension_list_theme) {
$this->stringTranslation = $string_translation;
$this->moduleExtensionList = $extension_list_module;
$this->themeExtensionList = $extension_list_theme;
}
/**
* {@inheritdoc}
*/
public function validate($module) {
$reasons = [];
$themes_depending_on_module = $this->getThemesDependingOnModule($module);
if (!empty($themes_depending_on_module)) {
$module_name = $this->moduleExtensionList->get($module)->info['name'];
$theme_names = implode(', ', $themes_depending_on_module);
$reasons[] = $this->formatPlural(count($themes_depending_on_module),
'Required by the theme: @theme_names',
'Required by the themes: @theme_names',
['@module_name' => $module_name, '@theme_names' => $theme_names]);
}
return $reasons;
}
/**
* Returns themes that depend on a module.
*
* @param string $module
* The module machine name.
*
* @return string[]
* An array of the names of themes that depend on $module.
*/
protected function getThemesDependingOnModule($module) {
$installed_themes = $this->themeExtensionList->getAllInstalledInfo();
$themes_depending_on_module = array_map(function ($theme) use ($module) {
if (in_array($module, $theme['dependencies'])) {
return $theme['name'];
}
}, $installed_themes);
return array_filter($themes_depending_on_module);
}
}
......@@ -51,6 +51,7 @@ class ThemeExtensionList extends ExtensionList {
'libraries' => [],
'libraries_extend' => [],
'libraries_override' => [],
'dependencies' => [],
];
/**
......@@ -140,6 +141,22 @@ protected function doList() {
// sub-themes.
$this->fillInSubThemeData($themes, $sub_themes);
foreach ($themes as $key => $theme) {
// After $theme is processed by buildModuleDependencies(), there can be a
// `$theme->requires` array containing both module and base theme
// dependencies. The module dependencies are copied to their own property
// so they are available to operations specific to module dependencies.
if (isset($theme->requires)) {
$theme->module_dependencies = array_diff_key($theme->requires, $themes);
}
else {
// Even if no requirements are specified, the theme installation process
// expects the presence of the `requires` and `module_dependencies`
// properties, so they should be initialized here as empty arrays.
$theme->requires = [];
$theme->module_dependencies = [];
}
}
return $themes;
}
......
......@@ -2,6 +2,7 @@
namespace Drupal\Core\Extension;
use Drupal\Component\Utility\Html;
use Drupal\Core\Asset\AssetCollectionOptimizerInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Config\ConfigFactoryInterface;
......@@ -10,6 +11,8 @@
use Drupal\Core\Extension\Exception\UnknownExtensionException;
use Drupal\Core\Routing\RouteBuilderInterface;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\system\ModuleDependencyMessageTrait;
use Psr\Log\LoggerInterface;
/**
......@@ -17,6 +20,9 @@
*/
class ThemeInstaller implements ThemeInstallerInterface {
use ModuleDependencyMessageTrait;
use StringTranslationTrait;
/**
* @var \Drupal\Core\Extension\ThemeHandlerInterface
*/
......@@ -62,6 +68,13 @@ class ThemeInstaller implements ThemeInstallerInterface {
*/
protected $logger;
/**
* The module extension list.
*
* @var \Drupal\Core\Extension\ModuleExtensionList
*/
protected $moduleExtensionList;
/**
* Constructs a new ThemeInstaller.
*
......@@ -86,8 +99,10 @@ class ThemeInstaller implements ThemeInstallerInterface {
* A logger instance.
* @param \Drupal\Core\State\StateInterface $state
* The state store.
* @param \Drupal\Core\Extension\ModuleExtensionList $module_extension_list
* The module extension list.
*/
public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryInterface $config_factory, ConfigInstallerInterface $config_installer, ModuleHandlerInterface $module_handler, ConfigManagerInterface $config_manager, AssetCollectionOptimizerInterface $css_collection_optimizer, RouteBuilderInterface $route_builder, LoggerInterface $logger, StateInterface $state) {
public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryInterface $config_factory, ConfigInstallerInterface $config_installer, ModuleHandlerInterface $module_handler, ConfigManagerInterface $config_manager, AssetCollectionOptimizerInterface $css_collection_optimizer, RouteBuilderInterface $route_builder, LoggerInterface $logger, StateInterface $state, ModuleExtensionList $module_extension_list = NULL) {
$this->themeHandler = $theme_handler;
$this->configFactory = $config_factory;
$this->configInstaller = $config_installer;
......@@ -97,6 +112,11 @@ public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryI
$this->routeBuilder = $route_builder;
$this->logger = $logger;
$this->state = $state;
if ($module_extension_list === NULL) {
@trigger_error('The extension.list.module service must be passed to ' . __NAMESPACE__ . '\ThemeInstaller::__construct(). It was added in drupal:8.9.0 and will be required before drupal:10.0.0.', E_USER_DEPRECATED);
$module_extension_list = \Drupal::service('extension.list.module');
}
$this->moduleExtensionList = $module_extension_list;
}
/**
......@@ -106,6 +126,8 @@ public function install(array $theme_list, $install_dependencies = TRUE) {
$extension_config = $this->configFactory->getEditable('core.extension');
$theme_data = $this->themeHandler->rebuildThemeData();
$installed_themes = $extension_config->get('theme') ?: [];
$installed_modules = $extension_config->get('module') ?: [];
if ($install_dependencies) {
$theme_list = array_combine($theme_list, $theme_list);
......@@ -116,16 +138,41 @@ public function install(array $theme_list, $install_dependencies = TRUE) {
}
// Only process themes that are not installed currently.
$installed_themes = $extension_config->get('theme') ?: [];
if (!$theme_list = array_diff_key($theme_list, $installed_themes)) {
// Nothing to do. All themes already installed.
return TRUE;
}
$module_list = $this->moduleExtensionList->getList();
foreach ($theme_list as $theme => $value) {
// Add dependencies to the list. The new themes will be processed as
// the parent foreach loop continues.
foreach (array_keys($theme_data[$theme]->requires) as $dependency) {
$module_dependencies = $theme_data[$theme]->module_dependencies;
// $theme_data[$theme]->requires contains both theme and module
// dependencies keyed by the extension machine names and
// $theme_data[$theme]->module_dependencies contains only modules keyed
// by the module extension machine name. Therefore we can find the theme
// dependencies by finding array keys for 'requires' that are not in
// $module_dependencies.
$theme_dependencies = array_diff_key($theme_data[$theme]->requires, $module_dependencies);
// We can find the unmet module dependencies by finding the module
// machine names keys that are not in $installed_modules keys.
$unmet_module_dependencies = array_diff_key($module_dependencies, $installed_modules);
// Prevent themes with unmet module dependencies from being installed.
if (!empty($unmet_module_dependencies)) {
$unmet_module_dependencies_list = implode(', ', array_keys($unmet_module_dependencies));
throw new MissingDependencyException("Unable to install theme: '$theme' due to unmet module dependencies: '$unmet_module_dependencies_list'.");
}
foreach ($module_dependencies as $dependency => $dependency_object) {
if ($incompatible = $this->checkDependencyMessage($module_list, $dependency, $dependency_object)) {
$sanitized_message = Html::decodeEntities(strip_tags($incompatible));
throw new MissingDependencyException("Unable to install theme: $sanitized_message");
}
}
// Add dependencies to the list of themes to install. The new themes
// will be processed as the parent foreach loop continues.
foreach (array_keys($theme_dependencies) as $dependency) {
if (!isset($theme_data[$dependency])) {
// The dependency does not exist.
return FALSE;
......@@ -147,9 +194,6 @@ public function install(array $theme_list, $install_dependencies = TRUE) {
arsort($theme_list);
$theme_list = array_keys($theme_list);
}
else {
$installed_themes = $extension_config->get('theme') ?: [];
}
$themes_installed = [];
foreach ($theme_list as $key) {
......
......@@ -25,6 +25,9 @@ interface ThemeInstallerInterface {
*
* @throws \Drupal\Core\Extension\Exception\UnknownExtensionException
* Thrown when the theme does not exist.
*
* @throws \Drupal\Core\Extension\MissingDependencyException
* Thrown when a requested dependency can't be found.
*/
public function install(array $theme_list, $install_dependencies = TRUE);
......
<?php
// @codingStandardsIgnoreFile
/**
* This file was generated via php core/scripts/generate-proxy-class.php 'Drupal\Core\Extension\ModuleRequiredByThemesUninstallValidator' "core/lib/Drupal/Core".
*/
namespace Drupal\Core\ProxyClass\Extension {
/**
* Provides a proxy class for \Drupal\Core\Extension\ModuleRequiredByThemesUninstallValidator.
*
* @see \Drupal\Component\ProxyBuilder
*/
class ModuleRequiredByThemesUninstallValidator implements \Drupal\Core\Extension\ModuleUninstallValidatorInterface
{
use \Drupal\Core\DependencyInjection\DependencySerializationTrait;
/**
* The id of the original proxied service.
*
* @var string
*/
protected $drupalProxyOriginalServiceId;
/**
* The real proxied service, after it was lazy loaded.
*
* @var \Drupal\Core\Extension\ModuleRequiredByThemesUninstallValidator
*/
protected $service;
/**
* The service container.
*
* @var \Symfony\Component\DependencyInjection\ContainerInterface
*/
protected $container;
/**
* Constructs a ProxyClass Drupal proxy object.
*
* @param \Symfony\Component\DependencyInjection\ContainerInterface $container
* The container.
* @param string $drupal_proxy_original_service_id
* The service ID of the original service.
*/
public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container, $drupal_proxy_original_service_id)
{
$this->container = $container;
$this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id;
}
/**
* Lazy loads the real service from the container.
*
* @return object
* Returns the constructed real service.
*/
protected function lazyLoadItself()
{
if (!isset($this->service)) {
$this->service = $this->container->get($this->drupalProxyOriginalServiceId);
}
return $this->service;
}
/**
* {@inheritdoc}
*/
public function validate($module)
{
return $this->lazyLoadItself()->validate($module);
}
/**
* {@inheritdoc}
*/
public function setStringTranslation(\Drupal\Core\StringTranslation\TranslationInterface $translation)
{
return $this->lazyLoadItself()->setStringTranslation($translation);
}
}
}
......@@ -4,12 +4,14 @@
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Menu\MenuLinkTreeInterface;
use Drupal\Core\Menu\MenuTreeParameters;
use Drupal\Core\Theme\ThemeAccessCheck;
use Drupal\Core\Url;
use Drupal\system\ModuleDependencyMessageTrait;
use Drupal\system\SystemManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
......@@ -18,6 +20,8 @@
*/
class SystemController extends ControllerBase {
use ModuleDependencyMessageTrait;
/**
* System Manager Service.
*
......@@ -53,6 +57,13 @@ class SystemController extends ControllerBase {
*/
protected $menuLinkTree;
/**
* The module extension list.
*
* @var \Drupal\Core\Extension\ModuleExtensionList
*/
protected $moduleExtensionList;
/**
* Constructs a new SystemController.
*
......@@ -66,13 +77,20 @@ class SystemController extends ControllerBase {
* The theme handler.
* @param \Drupal\Core\Menu\MenuLinkTreeInterface $menu_link_tree
* The menu link tree service.
* @param \Drupal\Core\Extension\ModuleExtensionList $module_extension_list
* The module extension list.
*/
public function __construct(SystemManager $systemManager, ThemeAccessCheck $theme_access, FormBuilderInterface $form_builder, ThemeHandlerInterface $theme_handler, MenuLinkTreeInterface $menu_link_tree) {
public function __construct(SystemManager $systemManager, ThemeAccessCheck $theme_access, FormBuilderInterface $form_builder, ThemeHandlerInterface $theme_handler, MenuLinkTreeInterface $menu_link_tree, ModuleExtensionList $module_extension_list = NULL) {
$this->systemManager = $systemManager;
$this->themeAccess = $theme_access;
$this->formBuilder = $form_builder;
$this->themeHandler = $theme_handler;
$this->menuLinkTree = $menu_link_tree;
if ($module_extension_list === NULL) {
@trigger_error('The extension.list.module service must be passed to ' . __NAMESPACE__ . '\SystemController::__construct. It was added in Drupal 8.9.0 and will be required before Drupal 10.0.0.', E_USER_DEPRECATED);
$module_extension_list = \Drupal::service('extension.list.module');
}
$this->moduleExtensionList = $module_extension_list;
}
/**
......@@ -84,7 +102,8 @@ public static function create(ContainerInterface $container) {
$container->get('access_check.theme'),
$container->get('form_builder'),
$container->get('theme_handler'),
$container->get('menu.link_tree')
$container->get('menu.link_tree'),
$container->get('extension.list.module')
);
}
......@@ -231,9 +250,41 @@ public function themesPage() {
$theme->incompatible_base = (isset($theme->info['base theme']) && !($theme->base_themes === array_filter($theme->base_themes)));
// Confirm that the theme engine is available.
$theme->incompatible_engine = isset($theme->info['engine']) && !isset($theme->owner);
// Confirm that module dependencies are available.
$theme->incompatible_module = FALSE;
// Confirm that the user has permission to enable modules.
$theme->insufficient_module_permissions = FALSE;
}
// Check module dependencies.
if ($theme->module_dependencies) {
$modules = $this->moduleExtensionList->getList();
foreach ($theme->module_dependencies as $dependency => $dependency_object) {
if ($incompatible = $this->checkDependencyMessage($modules, $dependency, $dependency_object)) {
$theme->module_dependencies_list[$dependency] = $incompatible;
$theme->incompatible_module = TRUE;
continue;
}
// @todo Add logic for not displaying hidden modules in
// https://drupal.org/node/3117829.
$module_name = $modules[$dependency]->info['name'];
$theme->module_dependencies_list[$dependency] = $modules[$dependency]->status ? $this->t('@module_name', ['@module_name' => $module_name]) : $this->t('@module_name (<span class="admin-disabled">disabled</span>)', ['@module_name' => $module_name]);
// Create an additional property that contains only disabled module
// dependencies. This will determine if it is possible to install the
// theme, or if modules must first be enabled.
if (!$modules[$dependency]->status) {
$theme->module_dependencies_disabled[$dependency] = $module_name;
if (!$this->currentUser()->hasPermission('administer modules')) {
$theme->insufficient_module_permissions = TRUE;
}
}
}
}
$theme->operations = [];
if (!empty($theme->status) || !$theme->info['core_incompatible'] && !$theme->incompatible_php && !$theme->incompatible_base && !$theme->incompatible_engine) {
if (!empty($theme->status) || !$theme->info['core_incompatible'] && !$theme->incompatible_php && !$theme->incompatible_base && !$theme->incompatible_engine && !$theme->incompatible_module && empty($theme->module_dependencies_disabled)) {
// Create the operations links.
$query['theme'] = $theme->getName();
if ($this->themeAccess->checkAccess($theme->getName())) {
......
......@@ -6,6 +6,7 @@
use Drupal\Core\Config\PreExistingConfigException;
use Drupal\Core\Config\UnmetDependenciesException;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Extension\MissingDependencyException;
use Drupal\Core\Extension\ThemeExtensionList;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Drupal\Core\Extension\ThemeInstallerInterface;
......@@ -161,6 +162,9 @@ public function install(Request $request) {
catch (UnmetDependenciesException $e) {
$this->messenger()->addError($e->getTranslatedMessage($this->getStringTranslation(), $theme));
}
catch (MissingDependencyException $e) {
$this->messenger()->addError($this->t('Unable to install @theme due to missing module dependencies.', ['@theme' => $theme]));
}
return $this->redirect('system.themes_page');
}
......
......@@ -17,6 +17,7 @@
use Drupal\Core\Session\AccountInterface;
use Drupal\user\PermissionHandlerInterface;
use Drupal\Core\Url;
use Drupal\system\ModuleDependencyMessageTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
......@@ -31,6 +32,8 @@
*/
class ModulesListForm extends FormBase {
use ModuleDependencyMessageTrait;
/**
* The current user.
*
......@@ -326,38 +329,16 @@ protected function buildRow(array $modules, Extension $module, $distribution) {
// If this module requires other modules, add them to the array.
/** @var \Drupal\Core\Extension\Dependency $dependency_object */
foreach ($module->requires as $dependency => $dependency_object) {
if (!isset($modules[$dependency])) {
$row['#requires'][$dependency] = $this->t('@module (<span class="admin-missing">missing</span>)', ['@module' => $dependency]);
// @todo Add logic for not displaying hidden modules in
// https://drupal.org/node/3117829.
if ($incompatible = $this->checkDependencyMessage($modules, $dependency, $dependency_object)) {
$row['#requires'][$dependency] = $incompatible;
$row['enable']['#disabled'] = TRUE;
continue;
}
// Only display visible modules.
elseif (empty($modules[$dependency]->hidden)) {
$name = $modules[$dependency]->info['name'];
// Disable the module's checkbox if it is incompatible with the
// dependency's version.