Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .tools/psalm/baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1481,7 +1481,6 @@
<MixedAssignment>
<code><![CDATA[$key]]></code>
<code><![CDATA[$normal[]]]></code>
<code><![CDATA[$reinstall]]></code>
<code><![CDATA[$value]]></code>
</MixedAssignment>
</file>
Expand Down
2 changes: 1 addition & 1 deletion pages/addon/details.help.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
$author = $package->getAuthor();
$supportPage = $package->getSupportPage();
if (is_readable($package->getPath('help.php'))) {
if (!$package->isAvailable() && is_readable($package->getPath('lang'))) {
if (!$package->isActivated() && is_readable($package->getPath('lang'))) {
I18n::addDirectory($package->getPath('lang'));
}
ob_start();
Expand Down
2 changes: 1 addition & 1 deletion pages/addon/list.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@

$class = '';
$status = '&nbsp;';
if ($package->isAvailable()) {
if ($package->isActivated()) {
$status = $getLink($package, 'deactivate', 'rex-icon-package-is-activated');
$class .= ' rex-package-is-activated';
} elseif ($package->isInstalled()) {
Expand Down
2 changes: 1 addition & 1 deletion pages/credits.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@

<tbody>';

foreach (Addon::getAvailableAddons() as $package) {
foreach (Addon::getActivatedAddons() as $package) {
$helpUrl = Url::backendPage('packages', ['subpage' => 'help', 'package' => $package->name]);

$license = '';
Expand Down
4 changes: 3 additions & 1 deletion rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,10 @@
->withConfiguredRule(RenameMethodRector::class, [
new MethodCallRename(Addon\Addon::class, 'getRegisteredPackages', 'getRegisteredAddons'),
new MethodCallRename(Addon\Addon::class, 'getInstalledPackages', 'getInstalledAddons'),
new MethodCallRename(Addon\Addon::class, 'getAvailablePackages', 'getAvailableAddons'),
new MethodCallRename(Addon\Addon::class, 'getAvailablePackages', 'getActivatedAddons'),
new MethodCallRename(Addon\Addon::class, 'getAvailableAddons', 'getActivatedAddons'),
new MethodCallRename(Addon\Addon::class, 'getSetupPackages', 'getSetupAddons'),
new MethodCallRename(Addon\Addon::class, 'isAvailable', 'isActivated'),

new MethodCallRename(ApiFunction\Result::class, 'toJSON', 'toJson'),
new MethodCallRename(ApiFunction\Result::class, 'fromJSON', 'fromJson'),
Expand Down
43 changes: 26 additions & 17 deletions src/Addon/Addon.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ abstract class Addon
/** Loading position relative to other addons during boot. Override to load this addon early or late. */
public protected(set) LoadOrder $load = LoadOrder::Normal;

/** Lifecycle state of the addon. */
public private(set) AddonState $state = AddonState::Uninstalled;

/**
* Properties.
*
Expand Down Expand Up @@ -214,16 +217,26 @@ final public function removeProperty(string $key): void
unset($this->properties[$key]);
}

/** Returns if the addon is available (activated and installed). */
final public function isAvailable(): bool
/**
* Sets the lifecycle state of the addon.
*
* @internal
*/
final public function setState(AddonState $state): void
{
$this->state = $state;
}

/** Returns if the addon is activated (and therefore installed). */
final public function isActivated(): bool
{
return $this->isInstalled() && (bool) $this->getProperty('status', false);
return AddonState::Activated === $this->state;
}

/** Returns if the addon is installed. */
/** Returns if the addon is installed (activated or not). */
final public function isInstalled(): bool
{
return (bool) $this->getProperty('install', false);
return AddonState::Uninstalled !== $this->state;
}

final public function getAuthor(?string $default = null): ?string
Expand Down Expand Up @@ -369,13 +382,10 @@ final public function loadProperties(bool $force = false): void
$properties = $cache[$id]['data'];
}

$this->properties = array_intersect_key($this->properties, ['install' => null, 'status' => null]);
$this->properties = [];
if ($properties) {
foreach ($properties as $key => $value) {
$key = Type::string($key);
if (isset($this->properties[$key])) {
continue;
}
if ('supportpage' !== $key) {
$value = I18n::translateArray($value, false, $this->i18n(...));
} elseif (null !== $value && !preg_match('@^https?://@i', $value)) {
Expand Down Expand Up @@ -465,14 +475,14 @@ public function getPages(): iterable
/**
* Install hook — runs on install/reinstall. Override for schema/data setup. Must be idempotent.
*
* @throws UserMessageException
* @throws UserMessageException to abort the installation with a message
*/
public function install(): void {}

/**
* Uninstall hook — runs on uninstall. Override for cleanup.
*
* @throws UserMessageException
* @throws UserMessageException to abort the uninstallation with a message
*/
public function uninstall(): void {}

Expand All @@ -497,13 +507,13 @@ final public static function getInstalledAddons(): array
}

/**
* Returns the available addons.
* Returns the activated addons.
*
* @return array<non-empty-string, self>
*/
final public static function getAvailableAddons(): array
final public static function getActivatedAddons(): array
{
return self::filterPackages(self::$addons, 'isAvailable');
return self::filterPackages(self::$addons, 'isActivated');
}

/**
Expand All @@ -530,7 +540,7 @@ final public static function initialize(bool $dbExists = true): void
} else {
$config = [];
foreach (Core::getProperty('setup_addons') as $addon) {
$config[(string) $addon]['install'] = false;
$config[(string) $addon]['state'] = AddonState::Uninstalled->value;
}
}

Expand Down Expand Up @@ -559,8 +569,7 @@ final public static function initialize(bool $dbExists = true): void
}
$addon = new $class($composerPackages[$addonName], $addonName);
}
$addon->setProperty('install', $addonConfig['install'] ?? false);
$addon->setProperty('status', $addonConfig['status'] ?? false);
$addon->state = AddonState::from($addonConfig['state'] ?? AddonState::Uninstalled->value);
self::$addons[$addonName] = $addon;
}
}
Expand Down
106 changes: 36 additions & 70 deletions src/Addon/AddonManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
use const JSON_UNESCAPED_UNICODE;

/**
* @phpstan-type TAddonConfig array<non-empty-string, array{class: class-string<Addon>, install: bool, status: bool}>
* @phpstan-type TAddonConfig array<non-empty-string, array{class: class-string<Addon>, state: value-of<AddonState>}>
* @phpstan-type TAddonOrder list<non-empty-string>
*/
class AddonManager
Expand Down Expand Up @@ -83,30 +83,14 @@ public function install(): bool
throw new UserMessageException($message);
}

$reinstall = $this->addon->getProperty('install');
$this->addon->setProperty('install', true);
$reinstall = $this->addon->isInstalled();

I18n::addDirectory($this->addon->getPath('lang'));

// run install hook
// run install hook (can only abort by throwing a UserMessageException)
$this->addon->install();
$successMessage = (string) $this->addon->getProperty('successmsg', '');

/** @psalm-taint-escape html */
$instmsg = (string) $this->addon->getProperty('installmsg', '');
if ('' != $instmsg) {
throw new UserMessageException($instmsg);
}
if (!$this->addon->isInstalled()) {
throw new UserMessageException($this->i18n('no_reason'));
}

if (!$reinstall) {
$this->addon->setProperty('status', true);
}
static::saveConfig();
self::generateAddonOrder();

foreach ($this->addon->getProperty('default_config', []) as $key => $value) {
if (!$this->addon->hasConfig($key)) {
$this->addon->setConfig($key, $value);
Expand All @@ -121,6 +105,13 @@ public function install(): bool
}
}

// everything succeeded — commit (fresh installs are activated right away)
if (!$reinstall) {
$this->addon->setState(AddonState::Activated);
}
static::saveConfig();
self::generateAddonOrder();

$this->message = $this->i18n($reinstall ? 'reinstalled' : 'installed', $this->addon->name);
if ($successMessage) {
$this->message .= ' ' . $successMessage;
Expand All @@ -131,7 +122,6 @@ public function install(): bool
$this->message = $e->getMessage();
}

$this->addon->setProperty('install', false);
$this->message = $this->i18n('no_install', $this->addon->name) . '<br />' . $this->message;

return false;
Expand All @@ -144,30 +134,20 @@ public function install(): bool
*/
public function uninstall(): bool
{
$isActivated = $this->addon->isAvailable();
$originalState = $this->addon->state;
$isActivated = $this->addon->isActivated();
if ($isActivated && !$this->deactivate()) {
return false;
}

try {
$this->addon->setProperty('install', false);

if (!$isActivated) {
I18n::addDirectory($this->addon->getPath('lang'));
}

// run uninstall hook
// run uninstall hook (can only abort by throwing a UserMessageException)
$this->addon->uninstall();

/** @psalm-taint-escape html */
$instmsg = (string) $this->addon->getProperty('installmsg', '');
if ('' != $instmsg) {
throw new UserMessageException($instmsg);
}
if ($this->addon->isInstalled()) {
throw new UserMessageException($this->i18n('no_reason'));
}

// delete assets
$assets = $this->addon->getAssetsPath();
if (is_dir($assets) && !Dir::delete($assets)) {
Expand All @@ -179,6 +159,8 @@ public function uninstall(): bool

Config::removeNamespace($this->addon->name);

// everything succeeded — commit the new state
$this->addon->setState(AddonState::Uninstalled);
static::saveConfig();
$this->message = $this->i18n('uninstalled', $this->addon->name);

Expand All @@ -187,11 +169,12 @@ public function uninstall(): bool
$this->message = $e->getMessage();
}

$this->addon->setProperty('install', true);
if ($isActivated) {
$this->addon->setProperty('status', true);
// the deactivation was already committed — restore the previous state
$this->addon->setState($originalState);
static::saveConfig();
self::generateAddonOrder();
}
static::saveConfig();
$this->message = $this->i18n('no_uninstall', $this->addon->name) . '<br />' . $this->message;

return false;
Expand All @@ -204,31 +187,20 @@ public function uninstall(): bool
*/
public function activate(): bool
{
if ($this->addon->isInstalled()) {
$state = '';
if (!$this->checkRequirements()) {
$state .= $this->message;
}
$state = $state ?: true;

if (true === $state) {
$this->addon->setProperty('status', true);
static::saveConfig();
}
if (true === $state) {
self::generateAddonOrder();
}
} else {
$state = $this->i18n('not_installed', $this->addon->name);
if (!$this->addon->isInstalled()) {
$this->message = $this->i18n('no_activation', $this->addon->name) . '<br />' . $this->i18n('not_installed', $this->addon->name);
return false;
}

if (true !== $state) {
// error while config generation, rollback addon status
$this->addon->setProperty('status', false);
$this->message = $this->i18n('no_activation', $this->addon->name) . '<br />' . $state;
if (!$this->checkRequirements()) {
$this->message = $this->i18n('no_activation', $this->addon->name) . '<br />' . $this->message;
return false;
}

$this->addon->setState(AddonState::Activated);
static::saveConfig();
self::generateAddonOrder();

$this->message = $this->i18n('activated', $this->addon->name);
return true;
}
Expand All @@ -240,10 +212,8 @@ public function activate(): bool
*/
public function deactivate(): bool
{
$state = $this->checkDependencies();

if ($state) {
$this->addon->setProperty('status', false);
if ($this->checkDependencies()) {
$this->addon->setState(AddonState::Installed);
static::saveConfig();

// clear cache of addon
Expand Down Expand Up @@ -275,7 +245,7 @@ public function checkRequirements(): bool
$state = [];

foreach (self::getRequiredAddons($this->addon) as $addonName) {
if (Addon::get($addonName)?->isAvailable()) {
if (Addon::get($addonName)?->isActivated()) {
continue;
}

Expand All @@ -300,7 +270,7 @@ public function checkDependencies(): bool
$i18nPrefix = 'package_dependencies_error_';
$state = [];

foreach (Addon::getAvailableAddons() as $addon) {
foreach (Addon::getActivatedAddons() as $addon) {
if ($addon === $this->addon) {
continue;
}
Expand Down Expand Up @@ -368,7 +338,7 @@ public static function generateAddonOrder(): void
}
}
};
foreach (Addon::getAvailableAddons() as $addon) {
foreach (Addon::getActivatedAddons() as $addon) {
$id = $addon->name;
if (LoadOrder::Early === $addon->load) {
$early[] = $id;
Expand Down Expand Up @@ -397,8 +367,7 @@ protected static function saveConfig(): void
$config = [];
foreach (Addon::getRegisteredAddons() as $addonName => $addon) {
$config[$addonName]['class'] = $addon::class;
$config[$addonName]['install'] = $addon->isInstalled();
$config[$addonName]['status'] = $addon->isAvailable();
$config[$addonName]['state'] = $addon->state->value;
}

self::saveAddonsData(config: $config);
Expand All @@ -423,12 +392,9 @@ public static function synchronizeWithFileSystem(): void
}
$config[$addonName]['class'] = $addonClasses[$addonName];
if (!Addon::exists($addonName)) {
$config[$addonName]['install'] = false;
$config[$addonName]['status'] = false;
$config[$addonName]['state'] = AddonState::Uninstalled->value;
} else {
$addon = Addon::require($addonName);
$config[$addonName]['install'] = $addon->isInstalled();
$config[$addonName]['status'] = $addon->isAvailable();
$config[$addonName]['state'] = Addon::require($addonName)->state->value;
}
}
ksort($config);
Expand Down
Loading