diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 29c9847f7..19f8df936 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -1736,6 +1736,16 @@ private function registerEventListeners(IRegistrationContext $context): void MailAppScriptListener::class ); + // IntegrationGlobalScriptListener loads the shared integration-registry + // bootstrap on EVERY full-page render so the registry is installed + + // populated universally — letting leaves render inside any consuming + // app's object detail page (e.g. an OpenCatalogi publication) without + // that app bootstrapping the registry itself (universal-shared-integration-registry). + $context->registerEventListener( + \OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent::class, + \OCA\OpenRegister\Listener\IntegrationGlobalScriptListener::class + ); + // CommentsEntityListener registers "openregister" objectType for Nextcloud Comments. $context->registerEventListener(CommentsEntityEvent::class, CommentsEntityListener::class); diff --git a/lib/Listener/IntegrationGlobalScriptListener.php b/lib/Listener/IntegrationGlobalScriptListener.php new file mode 100644 index 000000000..d160138f0 --- /dev/null +++ b/lib/Listener/IntegrationGlobalScriptListener.php @@ -0,0 +1,77 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.app + * + * @spec openspec/changes/universal-shared-integration-registry/tasks.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Listener; + +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Util; + +/** + * Loads the `openregister-integration-global` bundle on EVERY full-page + * render so the shared integration registry + * (`window.OCA.OpenRegister.integrations`) is installed + populated with + * the built-in integrations and generic leaves on every page — not just + * OpenRegister's own SPA. + * + * This is what lets integration tabs/widgets (and any leaf app's Path-2 + * component) render inside ANY consuming app's object detail page (e.g. + * an OpenCatalogi publication) WITHOUT that app bootstrapping the + * registry itself. The bundle's `ensureIntegrationRegistry()` is + * idempotent, so co-loading it with OpenRegister's own main bundle is + * harmless. + * + * Unconditional by design: any page may host a CnDetailPage / + * CnObjectSidebar that reads the registry, so the bootstrap must be + * universally available. The script itself is tiny and short-circuits + * after the first run. + * + * @template-implements IEventListener + * + * @psalm-suppress UnusedClass + * + * @spec openspec/changes/universal-shared-integration-registry/tasks.md + */ +class IntegrationGlobalScriptListener implements IEventListener +{ + /** + * Handle the template-rendered event by injecting the bootstrap script. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/universal-shared-integration-registry/tasks.md + */ + public function handle(Event $event): void + { + if (($event instanceof BeforeTemplateRenderedEvent) === false) { + return; + } + + Util::addInitScript('openregister', 'openregister-integration-global'); + + }//end handle() +}//end class diff --git a/openspec/changes/universal-shared-integration-registry/proposal.md b/openspec/changes/universal-shared-integration-registry/proposal.md new file mode 100644 index 000000000..f7505f80c --- /dev/null +++ b/openspec/changes/universal-shared-integration-registry/proposal.md @@ -0,0 +1,53 @@ +# Universal Shared Integration Registry (global bootstrap) + +## Problem + +The pluggable integration registry (`pluggable-integration-registry`) installs +`window.OCA.OpenRegister.integrations` and renders integration tabs/widgets via +`CnObjectSidebar`, `CnDashboardPage`, and `CnDetailPage`. But the registry is +only installed + populated by **OpenRegister's own webpack bundles** +(`main`, `adminSettings`, `filesSidebar`, `mailSidebar`). On a page served by a +**consuming app** (e.g. an OpenCatalogi publication detail page), OpenRegister's +bundle never runs, so: + +1. A leaf app (e.g. OpenConnector) that loads its Path-2 component bundle and + calls `registerIntegration(...)` only ever populates a **stub** registry + (`{_queue, register}`) — nothing drains it, because the drain happens inside + `installIntegrationRegistry`, which only OpenRegister calls. +2. `useIntegrationRegistry()` in a foreign app's bundle reads its **own + per-bundle module singleton**, never the window-global the leaf queued onto. + +Net effect: the leaf's "Synced from" tab/widget never renders outside +OpenRegister's own SPA, even though the descriptor was queued. The whole point +of the leaf system — extend OpenRegister **without changing its tables or code** +and have leaves surface **inside any consuming app** — is unmet. + +## Solution + +Ship a tiny global bootstrap bundle and load it on **every** full-page render: + +- **`src/integration-global.js`** — new webpack entry + (`openregister-integration-global.js`) that imports and calls the existing + `ensureIntegrationRegistry()`. Idempotent + tiny. +- **`src/integrations/bootstrap.js`** — `ensureIntegrationRegistry()` now + resolves the **shared** registry via `getSharedRegistry(window)` + (nc-vue `universal-shared-integration-registry`: converge-not-clobber + + install-if-needed) and registers builtins + leaves into *that* instance, so + every consuming app's `useIntegrationRegistry()` — which now defaults to the + same shared window-global via `sharedRegistryIfInstalled()` — sees them. +- **`lib/Listener/IntegrationGlobalScriptListener.php`** — listens on + `BeforeTemplateRenderedEvent` and calls + `Util::addInitScript('openregister', 'openregister-integration-global')` + unconditionally, so the registry is installed + populated on every page, + not just OpenRegister's. + +This requires **zero changes** to any consuming app: an OpenCatalogi +publication page now hosts a fully-populated shared registry, and any leaf +(OpenConnector's `sync-contract`) that queued a descriptor renders its tab/widget. + +## Out of scope + +- The nc-vue reconciliation primitives (`getSharedRegistry`, + `sharedRegistryIfInstalled`, converge-not-clobber `installIntegrationRegistry`, + composable shared-default) — landed in nc-vue beta (ncv#443). +- Externalizing Vue/nc-vue from leaf integration bundles (follow-up). diff --git a/openspec/changes/universal-shared-integration-registry/tasks.md b/openspec/changes/universal-shared-integration-registry/tasks.md new file mode 100644 index 000000000..94a57a1d1 --- /dev/null +++ b/openspec/changes/universal-shared-integration-registry/tasks.md @@ -0,0 +1,20 @@ +# Tasks: universal shared integration registry (OpenRegister global bootstrap) + +- [x] `src/integration-global.js` — new webpack entry that calls + `ensureIntegrationRegistry()`. +- [x] `webpack.config.js` — add `integrationGlobal` entry → + `openregister-integration-global.js`. +- [x] `src/integrations/bootstrap.js` — `ensureIntegrationRegistry()` resolves + the shared registry via `getSharedRegistry(window)` and registers + builtins + leaves into it (was `installIntegrationRegistry`). +- [x] `lib/Listener/IntegrationGlobalScriptListener.php` — load the global + bundle on every `BeforeTemplateRenderedEvent`. +- [x] `lib/AppInfo/Application.php` — register the listener. +- [x] Build green; `openregister-integration-global.js` produced + deployed. + +## Verification + +- [ ] On an OpenCatalogi publication detail page (ZERO OpenCatalogi changes): + `window.OCA.OpenRegister.integrations` is a real registry (not a stub), + contains the built-ins + OpenConnector's `sync-contract` leaf, and the + "Synced from" tab/widget renders. diff --git a/src/integration-global.js b/src/integration-global.js new file mode 100644 index 000000000..a8f4e6080 --- /dev/null +++ b/src/integration-global.js @@ -0,0 +1,24 @@ +/** + * Global integration-registry bootstrap entry. + * + * Loaded on EVERY Nextcloud page via `OCP\Util::addInitScript` (see + * lib/AppInfo/Application.php) so the shared registry + * (window.OCA.OpenRegister.integrations) is installed + populated with the + * built-in integrations and the generic leaves on every page — not just + * OpenRegister's own SPA. That makes integration tabs/widgets (and any + * leaf app's Path-2 component queued on the stub) render inside ANY + * consuming app's object detail page (e.g. an OpenCatalogi publication) + * with zero per-consumer bootstrap. + * + * Kept tiny + idempotent (ensureIntegrationRegistry guards re-entry), so + * loading it alongside OpenRegister's own main bundle is harmless. + * + * @package OpenRegister + * + * @license EUPL-1.2 + * + * @see ADR-019 — Pluggable Integration Registry + */ +import { ensureIntegrationRegistry } from './integrations/bootstrap.js' + +ensureIntegrationRegistry() diff --git a/src/integrations/bootstrap.js b/src/integrations/bootstrap.js index a2f3b5e18..51ce8cb6e 100644 --- a/src/integrations/bootstrap.js +++ b/src/integrations/bootstrap.js @@ -15,7 +15,7 @@ * @see ADR-019 — Pluggable Integration Registry */ import { - installIntegrationRegistry, + getSharedRegistry, registerBuiltinIntegrations, registerLeafIntegrations, } from '@conduction/nextcloud-vue' @@ -25,13 +25,19 @@ let bootstrapped = false /** * Idempotent — safe to call from every entry bundle. Subsequent calls * after the first are no-ops, so consumers don't need to coordinate. + * + * Resolves the SHARED registry (window-global) via getSharedRegistry and + * registers builtins + leaves into THAT instance, so every consuming + * app's useIntegrationRegistry (which reads the same shared instance) + * sees them — including when this bootstrap runs from the global + * init-script on a foreign app's page (e.g. an OpenCatalogi publication). */ export function ensureIntegrationRegistry() { if (bootstrapped) { return } - installIntegrationRegistry(window) - registerBuiltinIntegrations() - registerLeafIntegrations() + const registry = getSharedRegistry(window) + registerBuiltinIntegrations(registry) + registerLeafIntegrations(registry) bootstrapped = true } diff --git a/webpack.config.js b/webpack.config.js index b50663ec8..35b362912 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -79,6 +79,15 @@ webpackConfig.entry = { import: path.join(__dirname, 'src', 'main.js'), filename: appId + '-main.js', }, + // Global registry bootstrap (universal-shared-integration-registry). + // Loaded on EVERY page via \OCP\Util::addInitScript so the shared + // integration registry is installed + populated everywhere, letting + // leaves render inside any consuming app's detail page without that + // app bootstrapping the registry itself. Kept separate + tiny. + integrationGlobal: { + import: path.join(__dirname, 'src', 'integration-global.js'), + filename: appId + '-integration-global.js', + }, adminSettings: { import: path.join(__dirname, 'src', 'settings.js'), filename: appId + '-settings.js',