Archived
fix: prefer gateway plugins over remote duplicates
This commit is contained in:
@@ -2,4 +2,4 @@
|
|||||||
"@jmfederico/pi-web": patch
|
"@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.
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
|||||||
<ul>
|
<ul>
|
||||||
<li>File and terminal helpers run against the selected remote machine.</li>
|
<li>File and terminal helpers run against the selected remote machine.</li>
|
||||||
<li>Remote plugin code is loaded best-effort through the current gateway and cached for the page lifetime.</li>
|
<li>Remote plugin code is loaded best-effort through the current gateway and cached for the page lifetime.</li>
|
||||||
|
<li>If the gateway already has an enabled plugin with the same original id, the gateway plugin wins and the remote duplicate stays hidden.</li>
|
||||||
<li>Remote theme contributions are ignored for now because themes are app-wide.</li>
|
<li>Remote theme contributions are ignored for now because themes are app-wide.</li>
|
||||||
<li>Mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible.</li>
|
<li>Mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -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;
|
- actions, workspace panels, and workspace labels only appear while that machine is selected;
|
||||||
- plugin file and terminal helpers run against that machine;
|
- 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;
|
- 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;
|
- 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.
|
- mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible.
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,69 @@ describe("PluginRegistry", () => {
|
|||||||
expect(registry.getWorkspaceLabelItems({ ...initialAppState(), selectedMachine: testMachine("remote-1") }, workspace)).toEqual([{ type: "text", text: "remote" }]);
|
expect(registry.getWorkspaceLabelItems({ ...initialAppState(), selectedMachine: testMachine("remote-1") }, workspace)).toEqual([{ type: "text", text: "remote" }]);
|
||||||
expect(registry.getThemes()).toEqual([]);
|
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`<p>Remote</p>` }],
|
||||||
|
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`<p>Gateway</p>` }],
|
||||||
|
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> = {}): Workspace {
|
function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type RegisteredPluginAction = Omit<PluginAction, "id"> & {
|
|||||||
pluginId: string;
|
pluginId: string;
|
||||||
localId: string;
|
localId: string;
|
||||||
machineId?: string;
|
machineId?: string;
|
||||||
|
sourcePluginId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class PluginRegistry {
|
export class PluginRegistry {
|
||||||
@@ -22,29 +23,32 @@ export class PluginRegistry {
|
|||||||
private readonly themes: QualifiedThemeContribution[] = [];
|
private readonly themes: QualifiedThemeContribution[] = [];
|
||||||
private readonly themePairs: QualifiedThemePairContribution[] = [];
|
private readonly themePairs: QualifiedThemePairContribution[] = [];
|
||||||
private readonly pluginIds = new Set<string>();
|
private readonly pluginIds = new Set<string>();
|
||||||
|
private readonly gatewayPluginIds = new Set<string>();
|
||||||
private readonly contributionIds = new Set<QualifiedContributionId>();
|
private readonly contributionIds = new Set<QualifiedContributionId>();
|
||||||
|
|
||||||
register(registration: PiWebPluginRegistration): void {
|
register(registration: PiWebPluginRegistration): void {
|
||||||
const { id, plugin } = registration;
|
const { id, plugin } = registration;
|
||||||
this.validatePluginId(id);
|
this.validatePluginId(id);
|
||||||
if (this.pluginIds.has(id)) throw new Error(`Duplicate plugin id: ${id}`);
|
if (this.pluginIds.has(id)) throw new Error(`Duplicate plugin id: ${id}`);
|
||||||
|
if (isDuplicateOfGatewayPlugin(registration, this.gatewayPluginIds)) return;
|
||||||
this.pluginIds.add(id);
|
this.pluginIds.add(id);
|
||||||
|
|
||||||
const apiVersion: unknown = plugin.apiVersion;
|
const apiVersion: unknown = plugin.apiVersion;
|
||||||
if (apiVersion !== 1) throw new Error(`Unsupported plugin API version for ${id}: ${String(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 result = plugin.activate({ apiVersion: 1, pluginId: id, html, svg });
|
||||||
const contributions = result.contributions;
|
const contributions = result.contributions;
|
||||||
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action, 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));
|
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));
|
for (const contribution of contributions.workspaceLabels ?? []) this.workspaceLabels.push(this.qualifyWorkspaceLabelContribution(id, contribution, registration.machineId, registration.sourcePluginId));
|
||||||
if (registration.machineId === undefined) {
|
if (registration.machineId === undefined) {
|
||||||
for (const theme of contributions.themes ?? []) this.themes.push(this.qualifyTheme(id, theme));
|
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));
|
for (const pair of contributions.themePairs ?? []) this.themePairs.push(this.qualifyThemePair(id, pair));
|
||||||
|
this.gatewayPluginIds.add(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
|
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 scopedContext = pluginRuntimeContextFor(context, action.pluginId);
|
||||||
const enabled = action.enabled?.(scopedContext);
|
const enabled = action.enabled?.(scopedContext);
|
||||||
const qualified: QualifiedPluginAction = {
|
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);
|
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 id = this.qualify(pluginId, panel.id);
|
||||||
const badge = panel.badge;
|
const badge = panel.badge;
|
||||||
const visible = panel.visible;
|
const visible = panel.visible;
|
||||||
@@ -100,13 +104,13 @@ export class PluginRegistry {
|
|||||||
pluginId,
|
pluginId,
|
||||||
localId: panel.id,
|
localId: panel.id,
|
||||||
...(machineId === undefined ? {} : { machineId }),
|
...(machineId === undefined ? {} : { machineId }),
|
||||||
visible: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true),
|
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) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }),
|
...(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)),
|
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 id = this.qualify(pluginId, contribution.id);
|
||||||
const visible = contribution.visible;
|
const visible = contribution.visible;
|
||||||
const items = contribution.items;
|
const items = contribution.items;
|
||||||
@@ -116,8 +120,8 @@ export class PluginRegistry {
|
|||||||
pluginId,
|
pluginId,
|
||||||
localId: contribution.id,
|
localId: contribution.id,
|
||||||
...(machineId === undefined ? {} : { machineId }),
|
...(machineId === undefined ? {} : { machineId }),
|
||||||
visible: (context) => isActiveForMachine(machineId, context.machine.id) && (visible?.(context) ?? true),
|
visible: (context) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) && (visible?.(context) ?? true),
|
||||||
items: (context) => isActiveForMachine(machineId, context.machine.id) ? items(context) : [],
|
items: (context) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) ? items(context) : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,8 +182,16 @@ export function installWorkspacePanelScope(context: WorkspacePanelContext, scope
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActiveForMachine(machineId: string | undefined, selectedMachineId: string): boolean {
|
function isDuplicateOfGatewayPlugin(registration: PiWebPluginRegistration, gatewayPluginIds: ReadonlySet<string>): boolean {
|
||||||
return machineId === undefined || machineId === selectedMachineId;
|
return registration.machineId !== undefined && registration.sourcePluginId !== undefined && gatewayPluginIds.has(registration.sourcePluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveForMachine(machineId: string | undefined, selectedMachineId: string, sourcePluginId: string | undefined, gatewayPluginIds: ReadonlySet<string>): boolean {
|
||||||
|
return machineId === undefined || (machineId === selectedMachineId && !isHiddenByGatewayPlugin(sourcePluginId, gatewayPluginIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHiddenByGatewayPlugin(sourcePluginId: string | undefined, gatewayPluginIds: ReadonlySet<string>): boolean {
|
||||||
|
return sourcePluginId !== undefined && gatewayPluginIds.has(sourcePluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimeContextMachineId(context: PluginRuntimeContext): string {
|
function runtimeContextMachineId(context: PluginRuntimeContext): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user