diff --git a/.changeset/remote-machine-plugins.md b/.changeset/remote-machine-plugins.md index 2c9299b..07b7121 100644 --- a/.changeset/remote-machine-plugins.md +++ b/.changeset/remote-machine-plugins.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Load trusted PI WEB plugins from selected federated machines with machine-scoped actions, workspace panels, labels, and proxied plugin assets. +Load trusted PI WEB plugins from selected federated machines with machine-scoped actions, workspace panels, labels, proxied plugin assets, and gateway-preferred duplicate handling. diff --git a/docs/plugins.html b/docs/plugins.html index 8a179f9..4d65dbb 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -302,6 +302,7 @@ After editing, check the manifest endpoint and browser-console failure cases.
  • File and terminal helpers run against the selected remote machine.
  • Remote plugin code is loaded best-effort through the current gateway and cached for the page lifetime.
  • +
  • If the gateway already has an enabled plugin with the same original id, the gateway plugin wins and the remote duplicate stays hidden.
  • Remote theme contributions are ignored for now because themes are app-wide.
  • Mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible.
  • diff --git a/docs/plugins.md b/docs/plugins.md index 72622f2..a23c98d 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -136,6 +136,7 @@ When machine federation is enabled, PI WEB also loads discovered plugins from th - actions, workspace panels, and workspace labels only appear while that machine is selected; - plugin file and terminal helpers run against that machine; - plugin code is loaded best-effort through the current gateway and cached for the browser page lifetime; +- if the gateway already has an enabled plugin with the same original id, the gateway plugin wins and the remote duplicate stays hidden; - remote theme contributions are ignored for now because themes are app-wide; - mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible. diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index cb43206..3faf7a4 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -325,6 +325,69 @@ describe("PluginRegistry", () => { expect(registry.getWorkspaceLabelItems({ ...initialAppState(), selectedMachine: testMachine("remote-1") }, workspace)).toEqual([{ type: "text", text: "remote" }]); expect(registry.getThemes()).toEqual([]); }); + + it("prefers gateway plugins over remote plugins with the same source id", () => { + const registry = new PluginRegistry(); + const remotePluginId = machineScopedPluginId("remote-1", "shared-tools"); + const workspace = testWorkspace(); + registry.register({ + id: remotePluginId, + machineId: "remote-1", + sourcePluginId: "shared-tools", + plugin: { + apiVersion: 1, + name: "Remote Shared Tools", + activate: () => ({ + contributions: { + actions: [{ id: "remote-action", title: "Remote Action", run: () => undefined }], + workspacePanels: [{ id: "workspace.remote", title: "Remote", render: () => html`

    Remote

    ` }], + workspaceLabels: [{ id: "remote-label", items: () => [{ type: "text", text: "remote" }] }], + }, + }), + }, + }); + + expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).toContain(`${remotePluginId}:remote-action`); + + registry.register({ + id: "shared-tools", + plugin: { + apiVersion: 1, + name: "Gateway Shared Tools", + activate: () => ({ + contributions: { + actions: [{ id: "gateway-action", title: "Gateway Action", run: () => undefined }], + workspacePanels: [{ id: "workspace.gateway", title: "Gateway", render: () => html`

    Gateway

    ` }], + workspaceLabels: [{ id: "gateway-label", items: () => [{ type: "text", text: "gateway" }] }], + }, + }), + }, + }); + + const remoteActions = registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id); + expect(remoteActions).toContain("shared-tools:gateway-action"); + expect(remoteActions).not.toContain(`${remotePluginId}:remote-action`); + + const panels = registry.getWorkspacePanels(); + expect(panels.find((panel) => panel.id === `${remotePluginId}:workspace.remote`)?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(false); + expect(panels.find((panel) => panel.id === "shared-tools:workspace.gateway")?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true); + expect(registry.getWorkspaceLabelItems({ ...initialAppState(), selectedMachine: testMachine("remote-1") }, workspace)).toEqual([{ type: "text", text: "gateway" }]); + }); + + it("does not activate remote duplicates when the gateway plugin is already registered", () => { + const registry = new PluginRegistry(); + const remoteActivate = vi.fn(() => ({ contributions: { actions: [{ id: "remote-action", title: "Remote Action", run: () => undefined }] } })); + registry.register({ id: "shared-tools", plugin: { apiVersion: 1, name: "Gateway Shared Tools", activate: () => ({ contributions: {} }) } }); + + registry.register({ + id: machineScopedPluginId("remote-1", "shared-tools"), + machineId: "remote-1", + sourcePluginId: "shared-tools", + plugin: { apiVersion: 1, name: "Remote Shared Tools", activate: remoteActivate }, + }); + + expect(remoteActivate).not.toHaveBeenCalled(); + }); }); function testWorkspace(patch: Partial = {}): Workspace { diff --git a/src/client/src/plugins/registry.ts b/src/client/src/plugins/registry.ts index ab66a111..30105f2 100644 --- a/src/client/src/plugins/registry.ts +++ b/src/client/src/plugins/registry.ts @@ -13,6 +13,7 @@ type RegisteredPluginAction = Omit & { pluginId: string; localId: string; machineId?: string; + sourcePluginId?: string; }; export class PluginRegistry { @@ -22,29 +23,32 @@ export class PluginRegistry { private readonly themes: QualifiedThemeContribution[] = []; private readonly themePairs: QualifiedThemePairContribution[] = []; private readonly pluginIds = new Set(); + private readonly gatewayPluginIds = new Set(); private readonly contributionIds = new Set(); register(registration: PiWebPluginRegistration): void { const { id, plugin } = registration; this.validatePluginId(id); if (this.pluginIds.has(id)) throw new Error(`Duplicate plugin id: ${id}`); + if (isDuplicateOfGatewayPlugin(registration, this.gatewayPluginIds)) return; this.pluginIds.add(id); const apiVersion: unknown = plugin.apiVersion; if (apiVersion !== 1) throw new Error(`Unsupported plugin API version for ${id}: ${String(apiVersion)}`); const result = plugin.activate({ apiVersion: 1, pluginId: id, html, svg }); const contributions = result.contributions; - for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action, registration.machineId)); - for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel, registration.machineId)); - for (const contribution of contributions.workspaceLabels ?? []) this.workspaceLabels.push(this.qualifyWorkspaceLabelContribution(id, contribution, registration.machineId)); + for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action, registration.machineId, registration.sourcePluginId)); + for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel, registration.machineId, registration.sourcePluginId)); + for (const contribution of contributions.workspaceLabels ?? []) this.workspaceLabels.push(this.qualifyWorkspaceLabelContribution(id, contribution, registration.machineId, registration.sourcePluginId)); if (registration.machineId === undefined) { for (const theme of contributions.themes ?? []) this.themes.push(this.qualifyTheme(id, theme)); for (const pair of contributions.themePairs ?? []) this.themePairs.push(this.qualifyThemePair(id, pair)); + this.gatewayPluginIds.add(id); } } getActions(context: PluginRuntimeContext): QualifiedPluginAction[] { - return this.actions.filter((action) => isActiveForMachine(action.machineId, runtimeContextMachineId(context))).map((action) => { + return this.actions.filter((action) => isActiveForMachine(action.machineId, runtimeContextMachineId(context), action.sourcePluginId, this.gatewayPluginIds)).map((action) => { const scopedContext = pluginRuntimeContextFor(context, action.pluginId); const enabled = action.enabled?.(scopedContext); const qualified: QualifiedPluginAction = { @@ -85,12 +89,12 @@ export class PluginRegistry { }); } - private qualifyAction(pluginId: string, action: PluginAction, machineId: string | undefined): RegisteredPluginAction { + private qualifyAction(pluginId: string, action: PluginAction, machineId: string | undefined, sourcePluginId: string | undefined): RegisteredPluginAction { const id = this.qualify(pluginId, action.id); - return { ...action, id, pluginId, localId: action.id, ...(machineId === undefined ? {} : { machineId }) }; + return { ...action, id, pluginId, localId: action.id, ...(machineId === undefined ? {} : { machineId }), ...(sourcePluginId === undefined ? {} : { sourcePluginId }) }; } - private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution, machineId: string | undefined): QualifiedWorkspacePanelContribution { + private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution, machineId: string | undefined, sourcePluginId: string | undefined): QualifiedWorkspacePanelContribution { const id = this.qualify(pluginId, panel.id); const badge = panel.badge; const visible = panel.visible; @@ -100,13 +104,13 @@ export class PluginRegistry { pluginId, localId: panel.id, ...(machineId === undefined ? {} : { machineId }), - visible: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true), - ...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }), + visible: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true), + ...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }), render: (context: WorkspacePanelContext) => panel.render(workspacePanelContextFor(context, pluginId)), }; } - private qualifyWorkspaceLabelContribution(pluginId: string, contribution: WorkspaceLabelContribution, machineId: string | undefined): QualifiedWorkspaceLabelContribution { + private qualifyWorkspaceLabelContribution(pluginId: string, contribution: WorkspaceLabelContribution, machineId: string | undefined, sourcePluginId: string | undefined): QualifiedWorkspaceLabelContribution { const id = this.qualify(pluginId, contribution.id); const visible = contribution.visible; const items = contribution.items; @@ -116,8 +120,8 @@ export class PluginRegistry { pluginId, localId: contribution.id, ...(machineId === undefined ? {} : { machineId }), - visible: (context) => isActiveForMachine(machineId, context.machine.id) && (visible?.(context) ?? true), - items: (context) => isActiveForMachine(machineId, context.machine.id) ? items(context) : [], + visible: (context) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) && (visible?.(context) ?? true), + items: (context) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) ? items(context) : [], }; } @@ -178,8 +182,16 @@ export function installWorkspacePanelScope(context: WorkspacePanelContext, scope return context; } -function isActiveForMachine(machineId: string | undefined, selectedMachineId: string): boolean { - return machineId === undefined || machineId === selectedMachineId; +function isDuplicateOfGatewayPlugin(registration: PiWebPluginRegistration, gatewayPluginIds: ReadonlySet): boolean { + return registration.machineId !== undefined && registration.sourcePluginId !== undefined && gatewayPluginIds.has(registration.sourcePluginId); +} + +function isActiveForMachine(machineId: string | undefined, selectedMachineId: string, sourcePluginId: string | undefined, gatewayPluginIds: ReadonlySet): boolean { + return machineId === undefined || (machineId === selectedMachineId && !isHiddenByGatewayPlugin(sourcePluginId, gatewayPluginIds)); +} + +function isHiddenByGatewayPlugin(sourcePluginId: string | undefined, gatewayPluginIds: ReadonlySet): boolean { + return sourcePluginId !== undefined && gatewayPluginIds.has(sourcePluginId); } function runtimeContextMachineId(context: PluginRuntimeContext): string {