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 {