diff --git a/Languages/en_US/Admin.php b/Languages/en_US/Admin.php
index d50cd2913e..87117c4600 100644
--- a/Languages/en_US/Admin.php
+++ b/Languages/en_US/Admin.php
@@ -760,6 +760,8 @@
$txt['hooks_field_function'] = 'Function: {real_function}';
$txt['hooks_field_included_file'] = 'Included file: {included_file}';
$txt['hooks_field_file_name'] = 'File Name';
+$txt['hooks_field_package_name'] = 'Added By';
+$txt['hooks_field_package_name_none'] = 'Not from a package';
$txt['hooks_field_hook_exists'] = 'Status';
$txt['hooks_active'] = 'Exists';
$txt['hooks_disabled'] = 'Disabled';
diff --git a/Sources/Actions/Admin/Maintenance.php b/Sources/Actions/Admin/Maintenance.php
index 7f56010ccd..bdc533bca1 100644
--- a/Sources/Actions/Admin/Maintenance.php
+++ b/Sources/Actions/Admin/Maintenance.php
@@ -30,6 +30,7 @@
use SMF\Lang;
use SMF\Logging;
use SMF\Menu;
+use SMF\PackageManager\PackageUtils;
use SMF\Sapi;
use SMF\SecurityToken;
use SMF\TaskRunner;
@@ -1882,6 +1883,22 @@ function ($accumulator, $functions) {
'reverse' => 'file_name DESC',
],
],
+ 'package_name' => [
+ 'header' => [
+ 'value' => Lang::getTxt('hooks_field_package_name', file: 'Admin'),
+ ],
+ 'data' => [
+ 'function' => function ($data) {
+ // Hooks that a mod added from its own code, and SMF's own, have no package.
+ return $data['package_name'] !== '' ? $data['package_name'] : Lang::getTxt('hooks_field_package_name_none', file: 'Admin');
+ },
+ 'class' => 'word_break',
+ ],
+ 'sort' => [
+ 'default' => 'package_name',
+ 'reverse' => 'package_name DESC',
+ ],
+ ],
'status' => [
'header' => [
'value' => Lang::getTxt('hooks_field_hook_exists', file: 'Admin'),
@@ -1987,6 +2004,7 @@ public static function getIntegrationHooksData($start, $per_page, $sort, $filter
{
$function_list = $sort_array = $temp_data = [];
$files = self::getFileRecursive($normalized_sourcedir);
+ $hook_owners = PackageUtils::getHookOwners();
foreach ($files as $currentFile => $fileInfo) {
$function_list += self::getDefinedFunctionsInFile($currentFile);
@@ -1999,6 +2017,8 @@ public static function getIntegrationHooksData($start, $per_page, $sort, $filter
'function_name DESC' => ['function_name', SORT_DESC],
'file_name' => ['file_name', SORT_ASC],
'file_name DESC' => ['file_name', SORT_DESC],
+ 'package_name' => ['package_name', SORT_ASC],
+ 'package_name DESC' => ['package_name', SORT_DESC],
'status' => ['status', SORT_ASC],
'status DESC' => ['status', SORT_DESC],
];
@@ -2023,6 +2043,8 @@ public static function getIntegrationHooksData($start, $per_page, $sort, $filter
'included_file' => $hookParsedData['hookFile'],
'file_name' => strtr($hookParsedData['absPath'] ?: ($function_list[$hookParsedData['call']] ?? ''), [$normalized_boarddir => '.']),
'instance' => $hookParsedData['object'],
+ // A disabled hook is stored with a trailing '!', which the package never asked for.
+ 'package_name' => $hook_owners[$hook][rtrim($hookParsedData['rawData'], '!')] ?? '',
'hook_exists' => $hook_exists,
'status' => ($hook_temp ? 'temp' : ($hook_exists ? ($hookParsedData['enabled'] ? 'allow' : 'moderate') : 'deny')),
'img_text' => Lang::getTxt('hooks_' . ($hook_exists ? ($hook_temp ? 'temp' : ($hookParsedData['enabled'] ? 'active' : 'disabled')) : 'missing'), file: 'Admin'),
diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php
index e26e725ae3..aebd361f97 100644
--- a/Sources/PackageManager/PackageUtils.php
+++ b/Sources/PackageManager/PackageUtils.php
@@ -487,6 +487,90 @@ public static function urlExists(string $url): bool
return preg_match('~^HTTP/.+\s+(20[01]|30[127])~i', $head) == 1;
}
+ /**
+ * Finds the package that registered each integration hook.
+ *
+ * Hooks are stored as a flat list of function names, with nothing in them
+ * to say where each one came from, so the answer comes from the packages:
+ * every installed package is asked which hooks its package-info.xml
+ * registers. A hook that a package added in its own code rather than in
+ * its package-info.xml has no owner here.
+ *
+ * @return array Package names, keyed by hook name and then by the entry
+ * that was stored for the hook.
+ */
+ public static function getHookOwners(): array
+ {
+ static $owners;
+
+ if (isset($owners)) {
+ return $owners;
+ }
+
+ $owners = [];
+
+ foreach (self::loadInstalledPackages() as $package) {
+ $info = self::getPackageInfo($package['filename']);
+
+ // The package file is gone, or is no longer readable as a package.
+ if (!\is_array($info) || !isset($info['xml'])) {
+ continue;
+ }
+
+ foreach (self::getPackageHooks($info['xml']) as $hook) {
+ $owners[$hook['hook']][$hook['call']] = $package['name'];
+ }
+ }
+
+ return $owners;
+ }
+
+ /**
+ * Gets the hooks that a package registers in its package-info.xml.
+ *
+ * Every install and upgrade block is read, whichever version of SMF or of
+ * the package it is for. A hook belongs to the package that ships it no
+ * matter which of its blocks put it there, and an entry for a block that
+ * never ran simply matches no hook.
+ *
+ * @param XmlArray $package_xml The package-info.xml of a package.
+ * @return array Each hook's name, and the entry that is stored for it.
+ */
+ public static function getPackageHooks(XmlArray $package_xml): array
+ {
+ $hooks = [];
+
+ foreach (['install', 'upgrade'] as $method) {
+ if (!$package_xml->exists($method)) {
+ continue;
+ }
+
+ foreach ($package_xml->set($method) as $block) {
+ foreach ($block->set('hook') as $hook) {
+ // A reverse hook takes one away instead of adding it.
+ if ($hook->exists('@reverse') && $hook->fetch('@reverse') == 'true') {
+ continue;
+ }
+
+ $function = $hook->exists('@function') ? $hook->fetch('@function') : '';
+ $file = $hook->exists('@file') ? $hook->fetch('@file') : '';
+
+ if ($hook->exists('@object') && $hook->fetch('@object') == 'true') {
+ $function .= '#';
+ }
+
+ $hooks[] = [
+ 'hook' => $hook->exists('@hook') ? $hook->fetch('@hook') : $hook->fetch('.'),
+ // The same shape that IntegrationHook::add() stores.
+ 'call' => $file === '' ? $function : $file . ($function === '' ? '' : '|' . $function),
+ ];
+ }
+ }
+ }
+
+ return $hooks;
+ }
+
/**
* Loads and returns an array of installed packages.
*
diff --git a/tests/Unit/PackageUtilsTest.php b/tests/Unit/PackageUtilsTest.php
new file mode 100644
index 0000000000..d0ae4a5fe8
--- /dev/null
+++ b/tests/Unit/PackageUtilsTest.php
@@ -0,0 +1,123 @@
+packageXml('
+
+
+ '));
+
+ $this->assertSame(
+ [['hook' => 'integrate_load_theme', 'call' => '$sourcedir/MyMod.php|my_mod_load_theme']],
+ $hooks,
+ );
+ }
+
+ public function testItBuildsTheEntryTheHookIsStoredAs(): void
+ {
+ $hooks = PackageUtils::getPackageHooks($this->packageXml('
+
+
+
+
+ '));
+
+ $this->assertSame(
+ [
+ 'plain_function',
+ '$sourcedir/MyMod.php|MyMod\Integration::run#',
+ '$sourcedir/MyMod.php',
+ ],
+ array_column($hooks, 'call'),
+ );
+ }
+
+ public function testItLeavesOutHooksThatAPackageRemoves(): void
+ {
+ $hooks = PackageUtils::getPackageHooks($this->packageXml('
+
+
+
+ '));
+
+ $this->assertSame(['integrate_added'], array_column($hooks, 'hook'));
+ }
+
+ public function testItCountsHooksAddedByAnUpgrade(): void
+ {
+ $hooks = PackageUtils::getPackageHooks($this->packageXml('
+
+
+
+
+
+ '));
+
+ $this->assertSame(['integrate_first', 'integrate_second'], array_column($hooks, 'hook'));
+ }
+
+ /**
+ * Packages written before a version of SMF existed keep a block per
+ * version, and which one ran depends on the forum it was installed on.
+ */
+ public function testItReadsEveryInstallBlockWhicheverVersionItIsFor(): void
+ {
+ $hooks = PackageUtils::getPackageHooks($this->packageXml('
+
+
+
+
+
+ '));
+
+ $this->assertSame(['integrate_old', 'integrate_current'], array_column($hooks, 'hook'));
+ }
+
+ public function testItFindsNothingInAPackageThatHooksNothing(): void
+ {
+ $hooks = PackageUtils::getPackageHooks($this->packageXml('
+
+
+ '));
+
+ $this->assertSame([], $hooks);
+ }
+
+ /******************
+ * Internal methods
+ ******************/
+
+ /**
+ * Wraps package-info.xml content the way a real package file has it.
+ *
+ * @param string $content The install and upgrade blocks of the package.
+ * @return XmlArray The package-info element, as getPackageInfo() returns it.
+ */
+ protected function packageXml(string $content): XmlArray
+ {
+ $xml = new XmlArray('
+
+ test:my_mod
+ My Mod
+ 1.0' . $content . '
+ ');
+
+ return $xml->path('package-info[0]');
+ }
+}