From bd7fc29d578f765cae6cb28f7284baccbaf6cecc Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 19 Jul 2026 13:35:25 +0200 Subject: [PATCH 1/3] fix(NavigationManager): resolve closure and event entries when all apps loaded Signed-off-by: Ferdinand Thiessen --- lib/OC.php | 3 + lib/private/NavigationManager.php | 43 ++++++--- tests/lib/NavigationManagerTest.php | 131 +++++++++++++++++++++++++++- 3 files changed, 161 insertions(+), 16 deletions(-) diff --git a/lib/OC.php b/lib/OC.php index a39f15f0bba4b..1f696846b35d1 100644 --- a/lib/OC.php +++ b/lib/OC.php @@ -7,6 +7,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +use OC\NavigationManager; use OC\Profiler\BuiltInProfiler; use OC\Security\CSP\ContentSecurityPolicyNonceManager; use OC\Share20\GroupDeletedListener; @@ -1203,6 +1204,8 @@ public static function handleRequest(): void { } // All apps are now loaded to handle the request + Server::get(NavigationManager::class)->setup(); + // if we are not on CLI, try to match the request to a route and handle it if (!self::$CLI) { try { diff --git a/lib/private/NavigationManager.php b/lib/private/NavigationManager.php index 468e2617d67f0..4942ba474936f 100644 --- a/lib/private/NavigationManager.php +++ b/lib/private/NavigationManager.php @@ -166,10 +166,14 @@ private function proceedNavigation(array $list, string $type): array { /** * removes all the entries */ - public function clear(bool $loadDefaultLinks = true): void { + public function clear(bool $resetInit = true): void { $this->entries = []; $this->closureEntries = []; - $this->init = !$loadDefaultLinks; + + if ($resetInit) { + $this->loadedAppInfo = []; + $this->init = false; + } } #[Override] @@ -203,20 +207,25 @@ private function init(): void { } /** - * Resolve the app navigation entries from closures and info.xml files. + * Setup the navigation manager. + * @internal - This is only used by Nextcloud core to setup the navigation manager. It is not intended for use by apps. */ - private function resolveAppNavigationEntries(): void { + public function setup(): void { // Resolve app navigation closures while ($c = array_pop($this->closureEntries)) { $this->add($c()); } // Resolve dynamically added navigation entries via event listeners - if ($this->loadedAppInfo === []) { - $this->eventDispatcher->dispatchTyped(new LoadAdditionalEntriesEvent()); - } + $this->eventDispatcher->dispatchTyped(new LoadAdditionalEntriesEvent()); + } - // Resolve classic info.xml based navigation entries + /** + * Resolve classic info.xml based navigation entires. + * Some code relies on this to be available earlier then the app loading finished. + * So we need to resolve the navigation entries here, even if not all apps are loaded yet. + */ + private function resolveAppNavigationEntries(): void { if ($this->userSession->isLoggedIn()) { $user = $this->userSession->getUser(); $apps = $this->appManager->getEnabledAppsForUser($user); @@ -225,16 +234,23 @@ private function resolveAppNavigationEntries(): void { } foreach ($apps as $app) { - // skip already loaded apps - if (in_array($app, $this->loadedAppInfo)) { + if (in_array($app, $this->loadedAppInfo, true)) { + // already loaded + continue; + } + if (!$this->appManager->isAppLoaded($app)) { + // app is not loaded yet, skip it continue; } // load plugins and collections from info.xml $info = $this->appManager->getAppInfo($app); if (!isset($info['navigations']['navigation'])) { + // this app does not have any navigation entries, skip it + $this->loadedAppInfo[] = $app; continue; } + foreach ($info['navigations']['navigation'] as $key => $nav) { $nav['type'] = $nav['type'] ?? 'link'; if (!isset($nav['name'])) { @@ -250,8 +266,11 @@ private function resolveAppNavigationEntries(): void { } $id = $nav['id'] ?? $app . ($key === 0 ? '' : $key); $order = $nav['order'] ?? 100; - $type = $nav['type']; - $route = !empty($nav['route']) ? $this->urlGenerator->linkToRoute($nav['route']) : ''; + $type = $nav['type'] ?? 'link'; + $route = $nav['route'] ?? ''; + if ($route !== '') { + $route = $this->urlGenerator->linkToRoute($route); + } $icon = $nav['icon'] ?? null; if ($icon !== null) { try { diff --git a/tests/lib/NavigationManagerTest.php b/tests/lib/NavigationManagerTest.php index 1b2805f9c0860..fbd10b7168278 100644 --- a/tests/lib/NavigationManagerTest.php +++ b/tests/lib/NavigationManagerTest.php @@ -157,6 +157,7 @@ public function testAddClosure(array $entry, array $expectedEntry): void { $this->assertEquals(0, $testAddClosureNumberOfCalls, 'Expected that the closure is not called by add()'); + $this->navigationManager->setup(); $navigationEntries = $this->navigationManager->getAll('all'); $this->assertEquals(1, $testAddClosureNumberOfCalls, 'Expected that the closure is called by getAll()'); $this->assertCount(1, $navigationEntries, 'Expected that 1 navigation entry exists'); @@ -233,6 +234,12 @@ public function testWithAppManager($expected, $navigation, $isAdmin = false): vo ->method('getAppInfo') ->with('test') ->willReturn($navigation); + $this->appManager->expects($this->any()) + ->method('isAppLoaded') + ->willReturnMap([ + ['test', true], + ['files', true], + ]); $this->urlGenerator->expects($this->any()) ->method('imagePath') ->willReturnCallback(function ($appName, $file) { @@ -259,11 +266,12 @@ public function testWithAppManager($expected, $navigation, $isAdmin = false): vo $this->groupManager->expects($this->any())->method('isAdmin')->willReturn($isAdmin); $this->navigationManager->clear(); - $this->dispatcher->expects($this->once()) + $this->dispatcher->expects($this->atLeastOnce()) ->method('dispatchTyped') ->willReturnCallback(function ($event): void { $this->assertInstanceOf(LoadAdditionalEntriesEvent::class, $event); }); + $this->navigationManager->setup(); $entries = $this->navigationManager->getAll('all'); $this->assertEquals($expected, $entries); } @@ -427,8 +435,20 @@ function (string $userId, string $appName, string $key, mixed $default = '') use ->method('isEnabledForUser') ->with('theming') ->willReturn(true); - $this->appManager->expects($this->once())->method('getAppInfo')->with('test')->willReturn($navigation); - $this->appManager->expects($this->once())->method('getAppIcon')->with('test')->willReturn('/apps/test/img/app.svg'); + $this->appManager->expects($this->once()) + ->method('getAppIcon') + ->with('test') + ->willReturn('/apps/test/img/app.svg'); + $this->appManager->expects($this->once()) + ->method('getAppInfo') + ->with('test') + ->willReturn($navigation); + $this->appManager->expects($this->atLeastOnce()) + ->method('isAppLoaded') + ->willReturnMap([ + ['test', true], + ['files', true], + ]); $this->l10nFac->expects($this->any())->method('get')->willReturn($l); $this->urlGenerator->expects($this->any())->method('imagePath')->willReturnCallback(function ($appName, $file) { return "/apps/$appName/img/$file"; @@ -455,10 +475,106 @@ function (string $userId, string $appName, string $key, mixed $default = '') use ->willReturnCallback(function ($event): void { $this->assertInstanceOf(LoadAdditionalEntriesEvent::class, $event); }); + $this->navigationManager->setup(); $entries = $this->navigationManager->getAll(); $this->assertEquals($expected, $entries); } + /** + * Navigation entries of enabled apps that are not booted yet must not be resolved. + */ + public function testResolveOnlyLoadedApps(): void { + /* Return default value */ + $this->config->method('getUserValue')->willReturnArgument(3); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user001'); + $this->userSession->method('getUser')->willReturn($user); + $this->userSession->method('isLoggedIn')->willReturn(true); + $this->appManager->method('getEnabledAppsForUser')->with($user)->willReturn(['test']); + + // The app is enabled but not booted yet ... + $this->appManager->expects($this->atLeastOnce()) + ->method('isAppLoaded') + ->with('test') + ->willReturn(false); + // ... so its info.xml navigation entries must never be read + $this->appManager->expects($this->never())->method('getAppInfo'); + + $this->navigationManager->clear(); + $this->assertEquals([], $this->navigationManager->getAll('all')); + } + + /** + * The LoadAdditionalEntriesEvent is only dispatched by setup(), not by getAll(). + */ + public function testGetAllDoesNotDispatchAdditionalEntries(): void { + $this->userSession->method('isLoggedIn')->willReturn(false); + $this->appManager->method('getEnabledApps')->willReturn([]); + + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + + $this->navigationManager->clear(); + $this->assertEquals([], $this->navigationManager->getAll('all')); + } + + /** + * An app's info.xml must only be resolved once, even across multiple getAll() calls + * and even when the app does not provide any navigation entries. + */ + public function testAppInfoResolvedOnlyOnce(): void { + $this->config->method('getUserValue')->willReturnArgument(3); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user001'); + $this->userSession->method('getUser')->willReturn($user); + $this->userSession->method('isLoggedIn')->willReturn(true); + $this->appManager->method('getEnabledAppsForUser')->with($user)->willReturn(['test']); + $this->appManager->method('isAppLoaded')->with('test')->willReturn(true); + + // App has no navigation entries; info.xml must only be read once + $this->appManager->expects($this->once()) + ->method('getAppInfo') + ->with('test') + ->willReturn(['navigations' => []]); + + $this->navigationManager->clear(); + $this->assertEquals([], $this->navigationManager->getAll('all')); + $this->assertEquals([], $this->navigationManager->getAll('all')); + } + + /** + * clear(false) keeps the resolved state, so already loaded apps are not resolved again; + * clear(true) resets it, forcing a fresh resolve. + */ + public function testClearResetsResolvedStateOnlyWhenRequested(): void { + $this->config->method('getUserValue')->willReturnArgument(3); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user001'); + $this->userSession->method('getUser')->willReturn($user); + $this->userSession->method('isLoggedIn')->willReturn(true); + $this->appManager->method('getEnabledAppsForUser')->with($user)->willReturn(['test']); + $this->appManager->method('isAppLoaded')->with('test')->willReturn(true); + + // Resolved once for the initial getAll(), then again after clear(true) resets the state + $this->appManager->expects($this->exactly(2)) + ->method('getAppInfo') + ->with('test') + ->willReturn(['navigations' => []]); + + $this->navigationManager->clear(); + $this->assertEquals([], $this->navigationManager->getAll('all')); + + // Soft clear keeps the resolved state, so getAppInfo is not called again + $this->navigationManager->clear(false); + $this->assertEquals([], $this->navigationManager->getAll('all')); + + // Full clear resets the resolved state, so the app is resolved again + $this->navigationManager->clear(true); + $this->assertEquals([], $this->navigationManager->getAll('all')); + } + public static function provideDefaultEntries(): array { return [ // none specified, default to files @@ -630,7 +746,13 @@ public function testGetDefaultEntryIdForUser(string $defaultApps, string $userDe ]; }); - $this->appManager->method('getEnabledApps')->willReturn([]); + $this->appManager->method('getEnabledApps')->willReturn(['files']); + $this->appManager->expects($this->atLeastOnce()) + ->method('isAppLoaded') + ->willReturnMap([ + ['test', true], + ['files', true], + ]); $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('user1'); @@ -651,6 +773,7 @@ public function testGetDefaultEntryIdForUser(string $defaultApps, string $userDe ['user1', 'core', 'apporder', '[]', $userApporder], ]); + $this->navigationManager->setup(); $this->assertEquals($expectedApp, $this->navigationManager->getDefaultEntryIdForUser(null, $withFallbacks)); } From e338886ea505f3e1773668c73e9106fe4f383f2b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 19 Jul 2026 13:42:04 +0200 Subject: [PATCH 2/3] refactor: apps to use event instead of closure for conditional navigation entries Signed-off-by: Ferdinand Thiessen --- apps/profile/appinfo/info.xml | 4 +- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + apps/profile/lib/AppInfo/Application.php | 36 +---- .../LoadAdditionalEntriesListener.php | 52 ++++++++ .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + apps/settings/lib/AppInfo/Application.php | 95 +------------- .../LoadAdditionalEntriesListener.php | 124 ++++++++++++++++++ core/AppInfo/Application.php | 36 +---- .../LoadAdditionalEntriesListener.php | 55 ++++++++ lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + 13 files changed, 248 insertions(+), 160 deletions(-) create mode 100644 apps/profile/lib/Listener/LoadAdditionalEntriesListener.php create mode 100644 apps/settings/lib/Listener/LoadAdditionalEntriesListener.php create mode 100644 core/Listener/LoadAdditionalEntriesListener.php diff --git a/apps/profile/appinfo/info.xml b/apps/profile/appinfo/info.xml index 1310eb1f7f05a..b0faec62e454f 100644 --- a/apps/profile/appinfo/info.xml +++ b/apps/profile/appinfo/info.xml @@ -8,9 +8,9 @@ profile Profile This application provides the profile - Provides a customisable user profile interface. + Provides a customizable user profile interface. 2.0.0-dev.0 - agpl + AGPL-3.0-or-later Nextcloud GmbH Profile social diff --git a/apps/profile/composer/composer/autoload_classmap.php b/apps/profile/composer/composer/autoload_classmap.php index 8e2b1a20ee04e..623dce51d5393 100644 --- a/apps/profile/composer/composer/autoload_classmap.php +++ b/apps/profile/composer/composer/autoload_classmap.php @@ -9,6 +9,7 @@ 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', 'OCA\\Profile\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php', 'OCA\\Profile\\Controller\\ProfilePageController' => $baseDir . '/../lib/Controller/ProfilePageController.php', + 'OCA\\Profile\\Listener\\LoadAdditionalEntriesListener' => $baseDir . '/../lib/Listener/LoadAdditionalEntriesListener.php', 'OCA\\Profile\\Listener\\ProfilePickerReferenceListener' => $baseDir . '/../lib/Listener/ProfilePickerReferenceListener.php', 'OCA\\Profile\\Reference\\ProfilePickerReferenceProvider' => $baseDir . '/../lib/Reference/ProfilePickerReferenceProvider.php', ); diff --git a/apps/profile/composer/composer/autoload_static.php b/apps/profile/composer/composer/autoload_static.php index a12287d97196e..41efca598c418 100644 --- a/apps/profile/composer/composer/autoload_static.php +++ b/apps/profile/composer/composer/autoload_static.php @@ -24,6 +24,7 @@ class ComposerStaticInitProfile 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', 'OCA\\Profile\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php', 'OCA\\Profile\\Controller\\ProfilePageController' => __DIR__ . '/..' . '/../lib/Controller/ProfilePageController.php', + 'OCA\\Profile\\Listener\\LoadAdditionalEntriesListener' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalEntriesListener.php', 'OCA\\Profile\\Listener\\ProfilePickerReferenceListener' => __DIR__ . '/..' . '/../lib/Listener/ProfilePickerReferenceListener.php', 'OCA\\Profile\\Reference\\ProfilePickerReferenceProvider' => __DIR__ . '/..' . '/../lib/Reference/ProfilePickerReferenceProvider.php', ); diff --git a/apps/profile/lib/AppInfo/Application.php b/apps/profile/lib/AppInfo/Application.php index 35569c8c6ae31..52d5939cdb6eb 100644 --- a/apps/profile/lib/AppInfo/Application.php +++ b/apps/profile/lib/AppInfo/Application.php @@ -9,6 +9,7 @@ namespace OCA\Profile\AppInfo; +use OCA\Profile\Listener\LoadAdditionalEntriesListener; use OCA\Profile\Listener\ProfilePickerReferenceListener; use OCA\Profile\Reference\ProfilePickerReferenceProvider; use OCP\AppFramework\App; @@ -16,11 +17,7 @@ use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\Collaboration\Reference\RenderReferenceEvent; -use OCP\INavigationManager; -use OCP\IURLGenerator; -use OCP\IUserSession; -use OCP\L10N\IFactory; -use OCP\Server; +use OCP\Navigation\Events\LoadAdditionalEntriesEvent; class Application extends App implements IBootstrap { public const APP_ID = 'profile'; @@ -33,37 +30,10 @@ public function __construct(array $urlParams = []) { public function register(IRegistrationContext $context): void { $context->registerReferenceProvider(ProfilePickerReferenceProvider::class); $context->registerEventListener(RenderReferenceEvent::class, ProfilePickerReferenceListener::class); + $context->registerEventListener(LoadAdditionalEntriesEvent::class, LoadAdditionalEntriesListener::class); } #[\Override] public function boot(IBootContext $context): void { - $context->injectFn($this->registerNavigationEntry(...)); - } - - /** - * Registers the navigation entry for the profile app in the user settings. - * Needed as the href is dynamic and thus we cannot use the appinfo/info.xml - */ - public function registerNavigationEntry( - INavigationManager $navigationManager, - IUserSession $userSession, - IURLGenerator $urlGenerator, - ): void { - if (!$userSession->isLoggedIn()) { - return; - } - - $l = Server::get(IFactory::class)->get('profile'); - // Profile - $navigationManager->add([ - 'type' => 'settings', - 'id' => 'profile', - 'order' => 1, - 'href' => $urlGenerator->linkToRoute( - 'profile.ProfilePage.index', - ['targetUserId' => $userSession->getUser()->getUID()], - ), - 'name' => $l->t('View profile'), - ]); } } diff --git a/apps/profile/lib/Listener/LoadAdditionalEntriesListener.php b/apps/profile/lib/Listener/LoadAdditionalEntriesListener.php new file mode 100644 index 0000000000000..6107a4b74358c --- /dev/null +++ b/apps/profile/lib/Listener/LoadAdditionalEntriesListener.php @@ -0,0 +1,52 @@ + */ +class LoadAdditionalEntriesListener implements IEventListener { + + public function __construct( + private readonly IL10N $l10n, + private readonly INavigationManager $navigationManager, + private readonly IURLGenerator $urlGenerator, + private readonly IUserSession $userSession, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof LoadAdditionalEntriesEvent)) { + return; + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return; + } + + $this->navigationManager->add([ + 'type' => 'settings', + 'id' => 'profile', + 'order' => 1, + 'href' => $this->urlGenerator->linkToRoute( + 'profile.ProfilePage.index', + ['targetUserId' => $user->getUID()], + ), + 'name' => $this->l10n->t('View profile'), + ]); + } +} diff --git a/apps/settings/composer/composer/autoload_classmap.php b/apps/settings/composer/composer/autoload_classmap.php index a526b46873f77..618e4cff3c619 100644 --- a/apps/settings/composer/composer/autoload_classmap.php +++ b/apps/settings/composer/composer/autoload_classmap.php @@ -41,6 +41,7 @@ 'OCA\\Settings\\Hooks' => $baseDir . '/../lib/Hooks.php', 'OCA\\Settings\\Listener\\AppPasswordCreatedActivityListener' => $baseDir . '/../lib/Listener/AppPasswordCreatedActivityListener.php', 'OCA\\Settings\\Listener\\GroupRemovedListener' => $baseDir . '/../lib/Listener/GroupRemovedListener.php', + 'OCA\\Settings\\Listener\\LoadAdditionalEntriesListener' => $baseDir . '/../lib/Listener/LoadAdditionalEntriesListener.php', 'OCA\\Settings\\Listener\\MailProviderListener' => $baseDir . '/../lib/Listener/MailProviderListener.php', 'OCA\\Settings\\Listener\\UserAddedToGroupActivityListener' => $baseDir . '/../lib/Listener/UserAddedToGroupActivityListener.php', 'OCA\\Settings\\Listener\\UserRemovedFromGroupActivityListener' => $baseDir . '/../lib/Listener/UserRemovedFromGroupActivityListener.php', diff --git a/apps/settings/composer/composer/autoload_static.php b/apps/settings/composer/composer/autoload_static.php index 2cba33bd7dcb4..9fdba0484599c 100644 --- a/apps/settings/composer/composer/autoload_static.php +++ b/apps/settings/composer/composer/autoload_static.php @@ -56,6 +56,7 @@ class ComposerStaticInitSettings 'OCA\\Settings\\Hooks' => __DIR__ . '/..' . '/../lib/Hooks.php', 'OCA\\Settings\\Listener\\AppPasswordCreatedActivityListener' => __DIR__ . '/..' . '/../lib/Listener/AppPasswordCreatedActivityListener.php', 'OCA\\Settings\\Listener\\GroupRemovedListener' => __DIR__ . '/..' . '/../lib/Listener/GroupRemovedListener.php', + 'OCA\\Settings\\Listener\\LoadAdditionalEntriesListener' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalEntriesListener.php', 'OCA\\Settings\\Listener\\MailProviderListener' => __DIR__ . '/..' . '/../lib/Listener/MailProviderListener.php', 'OCA\\Settings\\Listener\\UserAddedToGroupActivityListener' => __DIR__ . '/..' . '/../lib/Listener/UserAddedToGroupActivityListener.php', 'OCA\\Settings\\Listener\\UserRemovedFromGroupActivityListener' => __DIR__ . '/..' . '/../lib/Listener/UserRemovedFromGroupActivityListener.php', diff --git a/apps/settings/lib/AppInfo/Application.php b/apps/settings/lib/AppInfo/Application.php index ff1f0d222f0e8..5faf7b5110b0c 100644 --- a/apps/settings/lib/AppInfo/Application.php +++ b/apps/settings/lib/AppInfo/Application.php @@ -17,6 +17,7 @@ use OCA\Settings\Hooks; use OCA\Settings\Listener\AppPasswordCreatedActivityListener; use OCA\Settings\Listener\GroupRemovedListener; +use OCA\Settings\Listener\LoadAdditionalEntriesListener; use OCA\Settings\Listener\MailProviderListener; use OCA\Settings\Listener\UserAddedToGroupActivityListener; use OCA\Settings\Listener\UserRemovedFromGroupActivityListener; @@ -92,14 +93,11 @@ use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserAddedEvent; use OCP\Group\Events\UserRemovedEvent; -use OCP\Group\ISubAdmin; use OCP\IConfig; -use OCP\IGroupManager; -use OCP\INavigationManager; use OCP\IURLGenerator; -use OCP\IUserSession; use OCP\L10N\IFactory; use OCP\Mail\IMailer; +use OCP\Navigation\Events\LoadAdditionalEntriesEvent; use OCP\Security\ICrypto; use OCP\Security\ISecureRandom; use OCP\Server; @@ -135,6 +133,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(GroupDeletedEvent::class, GroupRemovedListener::class); $context->registerEventListener(PasswordUpdatedEvent::class, Hooks::class); $context->registerEventListener(UserChangedEvent::class, Hooks::class); + $context->registerEventListener(LoadAdditionalEntriesEvent::class, LoadAdditionalEntriesListener::class); // Register Mail Provider listeners $context->registerEventListener(DeclarativeSettingsGetValueEvent::class, MailProviderListener::class); @@ -233,93 +232,5 @@ public function register(IRegistrationContext $context): void { #[\Override] public function boot(IBootContext $context): void { - $context->injectFn($this->registerNavigationEntries(...)); - } - - /** - * Registers the navigation entries for the user settings. - * Needed as some entries are dynamic and thus we cannot use the appinfo/info.xml - * - * Registers the following entries: - * - Appearance and accessibility - * - Personal settings (named "Settings" for non-admins) - * - Accounts (only for subadmins) - * - Help & privacy (conditionally enabled based on config) - */ - public function registerNavigationEntries( - INavigationManager $navigationManager, - IURLGenerator $urlGenerator, - IUserSession $userSession, - IConfig $config, - ): void { - if ($userSession->getUser() === null) { - return; - } - - $l = Server::get(IFactory::class) - ->get('settings'); - $groupManager = Server::get(IGroupManager::class); - $subAdmin = Server::get(ISubAdmin::class); - $isAdmin = $groupManager->isAdmin($userSession->getUser()->getUID()); - $isSubAdmin = $subAdmin->isSubAdmin($userSession->getUser()); - - // Accessibility settings - the URL is dynamic (route parameters) which is currently not supported by appinfo.xml - $navigationManager->add([ - 'type' => 'settings', - 'id' => 'accessibility_settings', - 'order' => 2, - 'href' => $urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'theming']), - 'name' => $l->t('Appearance and accessibility'), - 'icon' => $urlGenerator->imagePath('theming', 'accessibility-dark.svg'), - ]); - - // Personal settings - this entry is dynamic so we cannot use appinfo - $navigationManager->add([ - 'type' => 'settings', - 'id' => 'settings_personal', - 'order' => 3, - 'href' => $urlGenerator->linkToRoute('settings.PersonalSettings.index'), - 'name' => $isAdmin - ? $l->t('Personal settings') - : $l->t('Settings'), - 'icon' => $isAdmin - ? $urlGenerator->imagePath('settings', 'personal.svg') - : $urlGenerator->imagePath('settings', 'admin.svg'), - ]); - - if ($isAdmin) { - $navigationManager->add([ - 'type' => 'settings', - 'id' => 'settings_administration', - 'order' => 4, - 'href' => $urlGenerator->linkToRoute('settings.adminSettings.index'), - 'name' => $l->t('Administration settings'), - 'icon' => $urlGenerator->imagePath('settings', 'admin.svg'), - ]); - } - - // User management is conditionally enabled for subadmins, but appinfo currently only supports full admins - if ($isSubAdmin) { - $navigationManager->add([ - 'type' => 'settings', - 'id' => 'core_users', - 'order' => 6, - 'href' => $urlGenerator->linkToRoute('settings.Users.usersList'), - 'name' => $l->t('Accounts'), - 'icon' => $urlGenerator->imagePath('settings', 'users.svg'), - ]); - } - - // conditionally enabled navigation entry - if ($config->getSystemValueBool('knowledgebaseenabled', true)) { - $navigationManager->add([ - 'type' => 'settings', - 'id' => 'help', - 'order' => 99998, - 'href' => $urlGenerator->linkToRoute('settings.Help.help'), - 'name' => $l->t('Help & privacy'), - 'icon' => $urlGenerator->imagePath('settings', 'help.svg'), - ]); - } } } diff --git a/apps/settings/lib/Listener/LoadAdditionalEntriesListener.php b/apps/settings/lib/Listener/LoadAdditionalEntriesListener.php new file mode 100644 index 0000000000000..fc8b6c533ecc3 --- /dev/null +++ b/apps/settings/lib/Listener/LoadAdditionalEntriesListener.php @@ -0,0 +1,124 @@ + */ +class LoadAdditionalEntriesListener implements IEventListener { + + public function __construct( + private readonly IConfig $config, + private readonly IL10N $l10n, + private readonly INavigationManager $navigationManager, + private readonly IURLGenerator $urlGenerator, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly ISubAdmin $subAdmin, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof LoadAdditionalEntriesEvent)) { + return; + } + + $this->registerNavigationEntries(); + } + + /** + * Registers the navigation entries for the user settings. + * Needed as some entries are dynamic and thus we cannot use the appinfo/info.xml + * + * Registers the following entries: + * - Appearance and accessibility + * - Personal settings (named "Settings" for non-admins) + * - Accounts (only for subadmins) + * - Help & privacy (conditionally enabled based on config) + */ + private function registerNavigationEntries(): void { + $user = $this->userSession->getUser(); + if ($user === null) { + return; + } + + $isAdmin = $this->groupManager->isAdmin($user->getUID()); + $isSubAdmin = $this->subAdmin->isSubAdmin($user); + + // Accessibility settings - the URL is dynamic (route parameters) which is currently not supported by appinfo.xml + $this->navigationManager->add([ + 'type' => 'settings', + 'id' => 'accessibility_settings', + 'order' => 2, + 'href' => $this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'theming']), + 'name' => $this->l10n->t('Appearance and accessibility'), + 'icon' => $this->urlGenerator->imagePath('theming', 'accessibility-dark.svg'), + ]); + + // Personal settings - this entry is dynamic so we cannot use appinfo + $this->navigationManager->add([ + 'type' => 'settings', + 'id' => 'settings_personal', + 'order' => 3, + 'href' => $this->urlGenerator->linkToRoute('settings.PersonalSettings.index'), + 'name' => $isAdmin + ? $this->l10n->t('Personal settings') + : $this->l10n->t('Settings'), + 'icon' => $isAdmin + ? $this->urlGenerator->imagePath('settings', 'personal.svg') + : $this->urlGenerator->imagePath('settings', 'admin.svg'), + ]); + + if ($isAdmin) { + $this->navigationManager->add([ + 'type' => 'settings', + 'id' => 'settings_administration', + 'order' => 4, + 'href' => $this->urlGenerator->linkToRoute('settings.adminSettings.index'), + 'name' => $this->l10n->t('Administration settings'), + 'icon' => $this->urlGenerator->imagePath('settings', 'admin.svg'), + ]); + } + + // User management is conditionally enabled for subadmins, but appinfo currently only supports full admins + if ($isSubAdmin) { + $this->navigationManager->add([ + 'type' => 'settings', + 'id' => 'core_users', + 'order' => 6, + 'href' => $this->urlGenerator->linkToRoute('settings.Users.usersList'), + 'name' => $this->l10n->t('Accounts'), + 'icon' => $this->urlGenerator->imagePath('settings', 'users.svg'), + ]); + } + + // conditionally enabled navigation entry + if ($this->config->getSystemValueBool('knowledgebaseenabled', true)) { + $this->navigationManager->add([ + 'type' => 'settings', + 'id' => 'help', + 'order' => 99998, + 'href' => $this->urlGenerator->linkToRoute('settings.Help.help'), + 'name' => $this->l10n->t('Help & privacy'), + 'icon' => $this->urlGenerator->imagePath('settings', 'help.svg'), + ]); + } + } + +} diff --git a/core/AppInfo/Application.php b/core/AppInfo/Application.php index fd396fa7f08dc..51425946a2536 100644 --- a/core/AppInfo/Application.php +++ b/core/AppInfo/Application.php @@ -21,6 +21,7 @@ use OC\Core\Listener\AddMissingIndicesListener; use OC\Core\Listener\AddMissingPrimaryKeyListener; use OC\Core\Listener\BeforeTemplateRenderedListener; +use OC\Core\Listener\LoadAdditionalEntriesListener; use OC\Core\Listener\PasswordUpdatedListener; use OC\Core\Listener\RestrictInteractionListener; use OC\Core\Notification\CoreNotifier; @@ -48,11 +49,8 @@ use OCP\DB\Events\AddMissingIndicesEvent; use OCP\DB\Events\AddMissingPrimaryKeyEvent; use OCP\IAppConfig; -use OCP\INavigationManager; use OCP\Interaction\RestrictInteractionEvent; -use OCP\IURLGenerator; -use OCP\IUserSession; -use OCP\L10N\IFactory; +use OCP\Navigation\Events\LoadAdditionalEntriesEvent; use OCP\Server; use OCP\Sharing\ISharingRegistry; use OCP\User\Events\BeforeUserDeletedEvent; @@ -91,6 +89,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(AddMissingPrimaryKeyEvent::class, AddMissingPrimaryKeyListener::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); $context->registerEventListener(BeforeLoginTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); + $context->registerEventListener(LoadAdditionalEntriesEvent::class, LoadAdditionalEntriesListener::class); $context->registerEventListener(RemoteWipeStarted::class, RemoteWipeActivityListener::class); $context->registerEventListener(RemoteWipeStarted::class, RemoteWipeNotificationsListener::class); $context->registerEventListener(RemoteWipeStarted::class, RemoteWipeEmailListener::class); @@ -157,34 +156,5 @@ public function register(IRegistrationContext $context): void { #[\Override] public function boot(IBootContext $context): void { - $context->injectFn($this->registerNavigationEntries(...)); - } - - /** - * Registers the navigation entries for the core app: - * - The logout button in the settings menu - */ - public function registerNavigationEntries( - INavigationManager $navigationManager, - IUserSession $userSession, - IURLGenerator $urlGenerator, - IFactory $factory, - ): void { - if (!$userSession->isLoggedIn()) { - return; - } - - $l = $factory->get('core'); - - // Register the logout button in the user settings - $logoutUrl = $urlGenerator->getLogoutUrl(); - $navigationManager->add([ - 'type' => 'settings', - 'id' => 'logout', - 'order' => 99999, - 'href' => $logoutUrl, - 'name' => $l->t('Log out'), - 'icon' => $urlGenerator->imagePath('core', 'actions/logout.svg'), - ]); } } diff --git a/core/Listener/LoadAdditionalEntriesListener.php b/core/Listener/LoadAdditionalEntriesListener.php new file mode 100644 index 0000000000000..0bff035554505 --- /dev/null +++ b/core/Listener/LoadAdditionalEntriesListener.php @@ -0,0 +1,55 @@ + */ +class LoadAdditionalEntriesListener implements IEventListener { + private readonly IL10N $l10n; + + public function __construct( + public readonly IFactory $l10nFactory, + private readonly INavigationManager $navigationManager, + private readonly IURLGenerator $urlGenerator, + private readonly IUserSession $userSession, + ) { + $this->l10n = $this->l10nFactory->get('core'); + } + + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof LoadAdditionalEntriesEvent)) { + return; + } + + if (!$this->userSession->isLoggedIn()) { + return; + } + + // Register the logout button in the user settings + $logoutUrl = $this->urlGenerator->getLogoutUrl(); + $this->navigationManager->add([ + 'type' => 'settings', + 'id' => 'logout', + 'order' => 99999, + 'href' => $logoutUrl, + 'name' => $this->l10n->t('Log out'), + 'icon' => $this->urlGenerator->imagePath('core', 'actions/logout.svg'), + ]); + } + +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 8b8de8c3ca5db..1877e5628e75f 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1587,6 +1587,7 @@ 'OC\\Core\\Listener\\BeforeMessageLoggedEventListener' => $baseDir . '/core/Listener/BeforeMessageLoggedEventListener.php', 'OC\\Core\\Listener\\BeforeTemplateRenderedListener' => $baseDir . '/core/Listener/BeforeTemplateRenderedListener.php', 'OC\\Core\\Listener\\FeedBackHandler' => $baseDir . '/core/Listener/FeedBackHandler.php', + 'OC\\Core\\Listener\\LoadAdditionalEntriesListener' => $baseDir . '/core/Listener/LoadAdditionalEntriesListener.php', 'OC\\Core\\Listener\\PasswordUpdatedListener' => $baseDir . '/core/Listener/PasswordUpdatedListener.php', 'OC\\Core\\Listener\\RestrictInteractionListener' => $baseDir . '/core/Listener/RestrictInteractionListener.php', 'OC\\Core\\Middleware\\TwoFactorMiddleware' => $baseDir . '/core/Middleware/TwoFactorMiddleware.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 33a003ab19ace..3d8b0abde0249 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1628,6 +1628,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Listener\\BeforeMessageLoggedEventListener' => __DIR__ . '/../../..' . '/core/Listener/BeforeMessageLoggedEventListener.php', 'OC\\Core\\Listener\\BeforeTemplateRenderedListener' => __DIR__ . '/../../..' . '/core/Listener/BeforeTemplateRenderedListener.php', 'OC\\Core\\Listener\\FeedBackHandler' => __DIR__ . '/../../..' . '/core/Listener/FeedBackHandler.php', + 'OC\\Core\\Listener\\LoadAdditionalEntriesListener' => __DIR__ . '/../../..' . '/core/Listener/LoadAdditionalEntriesListener.php', 'OC\\Core\\Listener\\PasswordUpdatedListener' => __DIR__ . '/../../..' . '/core/Listener/PasswordUpdatedListener.php', 'OC\\Core\\Listener\\RestrictInteractionListener' => __DIR__ . '/../../..' . '/core/Listener/RestrictInteractionListener.php', 'OC\\Core\\Middleware\\TwoFactorMiddleware' => __DIR__ . '/../../..' . '/core/Middleware/TwoFactorMiddleware.php', From b47622e09faa229f0d9c5f42bbd2007b462584f5 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 19 Jul 2026 13:42:49 +0200 Subject: [PATCH 3/3] feat: add `AppsLoadedEvent` Signed-off-by: Ferdinand Thiessen --- lib/OC.php | 2 ++ lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + lib/public/App/Events/AppsLoadedEvent.php | 21 +++++++++++++++++++++ 4 files changed, 25 insertions(+) create mode 100644 lib/public/App/Events/AppsLoadedEvent.php diff --git a/lib/OC.php b/lib/OC.php index 1f696846b35d1..5b72d243ded14 100644 --- a/lib/OC.php +++ b/lib/OC.php @@ -15,6 +15,7 @@ use OC\Share20\UserDeletedListener; use OC\Share20\UserRemovedListener; use OC\User\DisabledUserException; +use OCP\App\Events\AppsLoadedEvent; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Events\BeforeFileSystemSetupEvent; use OCP\Group\Events\GroupDeletedEvent; @@ -1205,6 +1206,7 @@ public static function handleRequest(): void { // All apps are now loaded to handle the request Server::get(NavigationManager::class)->setup(); + Server::get(IEventDispatcher::class)->dispatchTyped(new AppsLoadedEvent()); // if we are not on CLI, try to match the request to a route and handle it if (!self::$CLI) { diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 1877e5628e75f..beafb4700c69d 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -158,6 +158,7 @@ 'OCP\\App\\Events\\AppDisableEvent' => $baseDir . '/lib/public/App/Events/AppDisableEvent.php', 'OCP\\App\\Events\\AppEnableEvent' => $baseDir . '/lib/public/App/Events/AppEnableEvent.php', 'OCP\\App\\Events\\AppUpdateEvent' => $baseDir . '/lib/public/App/Events/AppUpdateEvent.php', + 'OCP\\App\\Events\\AppsLoadedEvent' => $baseDir . '/lib/public/App/Events/AppsLoadedEvent.php', 'OCP\\App\\IAppManager' => $baseDir . '/lib/public/App/IAppManager.php', 'OCP\\App\\ManagerEvent' => $baseDir . '/lib/public/App/ManagerEvent.php', 'OCP\\Authentication\\Events\\AnyLoginFailedEvent' => $baseDir . '/lib/public/Authentication/Events/AnyLoginFailedEvent.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 3d8b0abde0249..e024c1fb44b9f 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -199,6 +199,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\App\\Events\\AppDisableEvent' => __DIR__ . '/../../..' . '/lib/public/App/Events/AppDisableEvent.php', 'OCP\\App\\Events\\AppEnableEvent' => __DIR__ . '/../../..' . '/lib/public/App/Events/AppEnableEvent.php', 'OCP\\App\\Events\\AppUpdateEvent' => __DIR__ . '/../../..' . '/lib/public/App/Events/AppUpdateEvent.php', + 'OCP\\App\\Events\\AppsLoadedEvent' => __DIR__ . '/../../..' . '/lib/public/App/Events/AppsLoadedEvent.php', 'OCP\\App\\IAppManager' => __DIR__ . '/../../..' . '/lib/public/App/IAppManager.php', 'OCP\\App\\ManagerEvent' => __DIR__ . '/../../..' . '/lib/public/App/ManagerEvent.php', 'OCP\\Authentication\\Events\\AnyLoginFailedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/AnyLoginFailedEvent.php', diff --git a/lib/public/App/Events/AppsLoadedEvent.php b/lib/public/App/Events/AppsLoadedEvent.php new file mode 100644 index 0000000000000..912474d0ad4c1 --- /dev/null +++ b/lib/public/App/Events/AppsLoadedEvent.php @@ -0,0 +1,21 @@ +