Archived
feat: support machine-specific plugins
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Allow PI WEB plugins to mark themselves as machine-specific so the gateway copy stays local-only and remote machines can provide their own status/plugin UI.
|
||||||
@@ -112,7 +112,7 @@ Remote model-provider credentials and OAuth state stay on the target machine. AP
|
|||||||
|
|
||||||
PI WEB production installs can load trusted local UI plugins without rebuilding PI WEB. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata, using documented context helpers for workspace files and terminals. They do not run in the session daemon and are not sandboxed.
|
PI WEB production installs can load trusted local UI plugins without rebuilding PI WEB. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata, using documented context helpers for workspace files and terminals. They do not run in the session daemon and are not sandboxed.
|
||||||
|
|
||||||
The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, `pi-web-plugins/updates` demonstrates a dynamic status panel, and built-in [Workspace Tasks](docs/plugins.md#workspace-tasks) adds a workspace tab for running configured shell commands in PI WEB terminals.
|
The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module` plus optional `machineSpecific` metadata, and a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, `pi-web-plugins/updates` demonstrates a dynamic status panel, and built-in [Workspace Tasks](docs/plugins.md#workspace-tasks) adds a workspace tab for running configured shell commands in PI WEB terminals.
|
||||||
|
|
||||||
A useful prompt for AI agents:
|
A useful prompt for AI agents:
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -191,7 +191,8 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
|||||||
<h3>Updates</h3>
|
<h3>Updates</h3>
|
||||||
<p>
|
<p>
|
||||||
<strong>Updates</strong> adds a conditional <strong>Updates</strong> workspace tab with PI WEB update,
|
<strong>Updates</strong> adds a conditional <strong>Updates</strong> workspace tab with PI WEB update,
|
||||||
restart, and installed-service guidance. It is built into PI WEB and enabled by default.
|
restart, and installed-service guidance. It is built into PI WEB, enabled by default, and uses the
|
||||||
|
selected machine's plugin copy when machine federation is active.
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Plugin id: <code>updates</code></li>
|
<li>Plugin id: <code>updates</code></li>
|
||||||
@@ -309,7 +310,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>If the gateway and remote machine both have an enabled plugin with the same original id, <code>machineSpecific</code> metadata decides whether the gateway copy is reused or only the selected-machine copy can appear.</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>
|
||||||
@@ -317,6 +318,11 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
|||||||
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable
|
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable
|
||||||
one, open that machine directly or update its config file.
|
one, open that machine directly or update its config file.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Plugin package metadata can set <code>machineSpecific: true</code>. Use it for plugins like Updates whose
|
||||||
|
UI should come from the selected PI WEB instance; on remote machines, the gateway copy is hidden unless
|
||||||
|
the remote machine exposes its own copy.
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
For portable plugin assets, prefer URLs relative to the plugin module, such as
|
For portable plugin assets, prefer URLs relative to the plugin module, such as
|
||||||
<code>new URL("./asset.json", import.meta.url)</code>. If a remote plugin constructs absolute asset URLs,
|
<code>new URL("./asset.json", import.meta.url)</code>. If a remote plugin constructs absolute asset URLs,
|
||||||
|
|||||||
+14
-7
@@ -136,12 +136,17 @@ When [machine federation](https://pi-web.dev/machines.html) is enabled, PI WEB a
|
|||||||
- 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;
|
- if the gateway and remote machine both have an enabled plugin with the same original id, `machineSpecific` metadata decides whether the gateway copy is reused or only the selected machine's copy can appear;
|
||||||
- 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.
|
||||||
|
|
||||||
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable a remote machine plugin, open that machine directly or update its config file.
|
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable a remote machine plugin, open that machine directly or update its config file.
|
||||||
|
|
||||||
|
Plugin package metadata may set `machineSpecific: true` when the plugin's meaning is tied to the selected PI WEB machine:
|
||||||
|
|
||||||
|
- Omitted or `false`: use the gateway copy when the same plugin id is also present on a remote machine. This is best for portable UI plugins whose helpers already route through the selected machine.
|
||||||
|
- `true`: the gateway copy only appears for the local machine. When a remote machine is selected, only that remote machine's copy can appear; if the remote machine does not expose the plugin, the plugin is hidden. This is best for plugins that report machine-local PI WEB status or depend on machine-local plugin code.
|
||||||
|
|
||||||
For portable plugin assets, prefer URLs relative to the plugin module, for example:
|
For portable plugin assets, prefer URLs relative to the plugin module, for example:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -185,7 +190,7 @@ Built-in plugins can be managed from **Settings → Plugins** or with the top-le
|
|||||||
**Plugin id:** `updates`
|
**Plugin id:** `updates`
|
||||||
**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance.
|
**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance.
|
||||||
|
|
||||||
Updates is enabled by default. To hide it, disable `updates` in **Settings → Plugins** or set:
|
Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → Plugins** or set:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -286,7 +291,7 @@ A package can expose one or more PI WEB plugin modules. There is exactly one sup
|
|||||||
"piWeb": {
|
"piWeb": {
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{ "id": "review", "module": "dist/review.js" },
|
{ "id": "review", "module": "dist/review.js" },
|
||||||
{ "id": "dashboard", "module": "dist/dashboard.js" }
|
{ "id": "dashboard", "module": "dist/dashboard.js", "machineSpecific": true }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,6 +303,7 @@ Rules:
|
|||||||
- Each entry must have an explicit `id` and `module`.
|
- Each entry must have an explicit `id` and `module`.
|
||||||
- `id` must match `^[a-z][a-z0-9.-]*$`.
|
- `id` must match `^[a-z][a-z0-9.-]*$`.
|
||||||
- `module` must be a safe relative path inside the plugin package root.
|
- `module` must be a safe relative path inside the plugin package root.
|
||||||
|
- `machineSpecific` is optional and must be a boolean; omit it for the default portable gateway behavior.
|
||||||
- Duplicate plugin ids are not auto-renamed; later duplicates are skipped.
|
- Duplicate plugin ids are not auto-renamed; later duplicates are skipped.
|
||||||
- Legacy shortcuts such as `piWeb.plugin`, string entries in `piWeb.plugins`, `piWeb.id` fallback ids, and no-`package.json` fallbacks are not supported.
|
- Legacy shortcuts such as `piWeb.plugin`, string entries in `piWeb.plugins`, `piWeb.id` fallback ids, and no-`package.json` fallbacks are not supported.
|
||||||
|
|
||||||
@@ -312,13 +318,14 @@ The manifest contains each discovered plugin module:
|
|||||||
"id": "my-plugin",
|
"id": "my-plugin",
|
||||||
"module": "/pi-web-plugins/my-plugin/pi-web-plugin.js?v=1234567890",
|
"module": "/pi-web-plugins/my-plugin/pi-web-plugin.js?v=1234567890",
|
||||||
"source": "local",
|
"source": "local",
|
||||||
"scope": "local"
|
"scope": "local",
|
||||||
|
"machineSpecific": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`.
|
`source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`. `machineSpecific` controls whether the gateway copy is valid for remote machines or only each selected machine's own copy can appear.
|
||||||
|
|
||||||
A plugin can fetch its own static assets with URLs under:
|
A plugin can fetch its own static assets with URLs under:
|
||||||
|
|
||||||
@@ -450,7 +457,7 @@ interface PluginRuntimeContext {
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `state` is a snapshot of current UI state when actions are built.
|
- `state` is a snapshot of current UI state when actions are built.
|
||||||
- The stable state fields are `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`.
|
- The stable state fields are `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`. `state.piWebStatus` describes the currently selected machine's PI WEB runtime, or the gateway/local runtime when the local machine is selected.
|
||||||
- Other `state` fields may exist at runtime, but they are private PI WEB internals that may graduate into stable helpers, change shape, or disappear.
|
- Other `state` fields may exist at runtime, but they are private PI WEB internals that may graduate into stable helpers, change shape, or disappear.
|
||||||
- `enabled` is evaluated when the action palette asks for actions.
|
- `enabled` is evaluated when the action palette asks for actions.
|
||||||
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
|
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
|
||||||
@@ -784,7 +791,7 @@ PI WEB does not provide a plugin cache/invalidation framework. Keep host callbac
|
|||||||
If you are an AI agent building or editing a PI WEB plugin, follow this checklist:
|
If you are an AI agent building or editing a PI WEB plugin, follow this checklist:
|
||||||
|
|
||||||
1. Create or update a plugin folder with `package.json` and a JavaScript module such as `pi-web-plugin.js`.
|
1. Create or update a plugin folder with `package.json` and a JavaScript module such as `pi-web-plugin.js`.
|
||||||
2. Use the single supported package metadata shape: `piWeb.plugins` array with `{ id, module }` entries.
|
2. Use the single supported package metadata shape: `piWeb.plugins` array with `{ id, module, machineSpecific? }` entries.
|
||||||
3. Default-export `{ apiVersion: 1, name, activate }` from the module.
|
3. Default-export `{ apiVersion: 1, name, activate }` from the module.
|
||||||
4. Return `{ contributions: { actions, workspacePanels, workspaceLabels } }` from `activate()`.
|
4. Return `{ contributions: { actions, workspacePanels, workspaceLabels } }` from `activate()`.
|
||||||
5. Use ids matching `^[a-z][a-z0-9.-]*$`.
|
5. Use ids matching `^[a-z][a-z0-9.-]*$`.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"piWeb": {
|
"piWeb": {
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{ "id": "updates", "module": "pi-web-plugin.js" }
|
{ "id": "updates", "module": "pi-web-plugin.js", "machineSpecific": true }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||||
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||||
import { machinesApi, terminalsApi, workspacesApi } from "./clients";
|
import { machinesApi, piWebApi, terminalsApi, workspacesApi } from "./clients";
|
||||||
|
|
||||||
const workspace: Workspace = {
|
const workspace: Workspace = {
|
||||||
id: "w/1",
|
id: "w/1",
|
||||||
@@ -31,6 +31,25 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("machine-scoped runtime API", () => {
|
describe("machine-scoped runtime API", () => {
|
||||||
|
it("reads machine PI WEB status through the gateway route", async () => {
|
||||||
|
const fetchMock = stubJsonFetch({
|
||||||
|
packageName: "@jmfederico/pi-web",
|
||||||
|
generatedAt: "now",
|
||||||
|
components: {
|
||||||
|
web: { component: "web", label: "PI WEB", available: true, stale: false },
|
||||||
|
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false },
|
||||||
|
},
|
||||||
|
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||||
|
commands: {},
|
||||||
|
messages: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await piWebApi.piWebStatus("remote a");
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status");
|
||||||
|
});
|
||||||
|
|
||||||
it("reads machine runtime through the gateway route", async () => {
|
it("reads machine runtime through the gateway route", async () => {
|
||||||
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { machineGitDiffUrl, messageUrl } from "./urls";
|
|||||||
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
|
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
|
||||||
|
|
||||||
export const piWebApi = {
|
export const piWebApi = {
|
||||||
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
|
piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`, parsePiWebStatusResponse),
|
||||||
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
|
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Workspace } from "../../../shared/apiTypes";
|
import type { Workspace } from "../../../shared/apiTypes";
|
||||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
||||||
import { activityApi, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
import { activityApi, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||||
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
||||||
import { workspaceImagePreviewUrl } from "./urls";
|
import { workspaceImagePreviewUrl } from "./urls";
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ describe("federated route contract", () => {
|
|||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
|
||||||
ignoreParseFailure(activityApi.workspaceActivity(machineId)),
|
ignoreParseFailure(activityApi.workspaceActivity(machineId)),
|
||||||
ignoreParseFailure(projectsApi.projects(machineId)),
|
ignoreParseFailure(projectsApi.projects(machineId)),
|
||||||
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
|
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ describe("API parsers", () => {
|
|||||||
|
|
||||||
it("parses PI WEB plugin status responses", () => {
|
it("parses PI WEB plugin status responses", () => {
|
||||||
expect(parsePiWebPluginsResponse({
|
expect(parsePiWebPluginsResponse({
|
||||||
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }],
|
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }],
|
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -492,6 +492,7 @@ function parsePiWebPluginInfo(value: unknown): PiWebPluginInfo {
|
|||||||
module: requireString(record, "module"),
|
module: requireString(record, "module"),
|
||||||
source: requireString(record, "source"),
|
source: requireString(record, "source"),
|
||||||
scope: parsePiWebPluginScope(record["scope"]),
|
scope: parsePiWebPluginScope(record["scope"]),
|
||||||
|
machineSpecific: parseOptionalBoolean(record["machineSpecific"], "machineSpecific") ?? false,
|
||||||
enabled: requireBoolean(record, "enabled"),
|
enabled: requireBoolean(record, "enabled"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -501,6 +502,12 @@ function parsePiWebPluginScope(value: unknown): PiWebPluginScope {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOptionalBoolean(value: unknown, key: string): boolean | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== "boolean") throw new Error(`Expected optional boolean field: ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
|
export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
|
||||||
const record = requireRecord(value);
|
const record = requireRecord(value);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -266,10 +266,13 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async refreshPiWebStatus(): Promise<void> {
|
private async refreshPiWebStatus(): Promise<void> {
|
||||||
|
const machineId = selectedMachineId(this.state);
|
||||||
try {
|
try {
|
||||||
this.setState({ piWebStatus: await piWebApi.piWebStatus() });
|
const piWebStatus = await piWebApi.piWebStatus(machineId);
|
||||||
|
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to refresh PI WEB status", error);
|
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus: undefined });
|
||||||
|
console.warn(`Failed to refresh PI WEB status for ${machineId}`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,7 +726,9 @@ export class PiWebApp extends LitElement {
|
|||||||
this.realtime.close();
|
this.realtime.close();
|
||||||
this.connectRealtime();
|
this.connectRealtime();
|
||||||
this.activeTerminalIds.clear();
|
this.activeTerminalIds.clear();
|
||||||
|
this.setState({ piWebStatus: undefined });
|
||||||
this.git.updatePolling();
|
this.git.updatePolling();
|
||||||
|
void this.refreshPiWebStatus();
|
||||||
void this.loadPluginsForSelectedMachine();
|
void this.loadPluginsForSelectedMachine();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1186,7 +1191,10 @@ export class PiWebApp extends LitElement {
|
|||||||
const existing = this.machinePluginLoadPromises.get(machine.id);
|
const existing = this.machinePluginLoadPromises.get(machine.id);
|
||||||
if (existing !== undefined) return existing;
|
if (existing !== undefined) return existing;
|
||||||
|
|
||||||
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, { machineId: machine.id }))
|
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, {
|
||||||
|
machineId: machine.id,
|
||||||
|
shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific),
|
||||||
|
}))
|
||||||
.then((loaded) => { if (loaded) this.loadedMachinePluginIds.add(machine.id); })
|
.then((loaded) => { if (loaded) this.loadedMachinePluginIds.add(machine.id); })
|
||||||
.finally(() => { this.machinePluginLoadPromises.delete(machine.id); });
|
.finally(() => { this.machinePluginLoadPromises.delete(machine.id); });
|
||||||
this.machinePluginLoadPromises.set(machine.id, load);
|
this.machinePluginLoadPromises.set(machine.id, load);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export class SettingsPluginsPanel extends LitElement {
|
|||||||
<article class=${`plugin-card${plugin.enabled ? "" : " disabled"}`}>
|
<article class=${`plugin-card${plugin.enabled ? "" : " disabled"}`}>
|
||||||
<div class="plugin-main">
|
<div class="plugin-main">
|
||||||
<strong>${plugin.id}</strong>
|
<strong>${plugin.id}</strong>
|
||||||
<small>${plugin.source} · ${plugin.scope}</small>
|
<small>${plugin.source} · ${plugin.scope}${plugin.machineSpecific ? " · machine-specific" : ""}</small>
|
||||||
<small>${configuredState}</small>
|
<small>${configuredState}</small>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle">
|
<label class="toggle">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
|
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
|
||||||
import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
|
import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
|
||||||
|
|
||||||
interface PluginManifestEntry {
|
export interface PluginManifestEntry {
|
||||||
id: string;
|
id: string;
|
||||||
module: string;
|
module: string;
|
||||||
|
machineSpecific: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PluginManifest {
|
interface PluginManifest {
|
||||||
@@ -12,6 +13,7 @@ interface PluginManifest {
|
|||||||
|
|
||||||
export interface LoadExternalPluginsOptions {
|
export interface LoadExternalPluginsOptions {
|
||||||
machineId?: string;
|
machineId?: string;
|
||||||
|
shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
|
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
|
||||||
@@ -20,6 +22,7 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
|
|||||||
|
|
||||||
const registrations: PiWebPluginRegistration[] = [];
|
const registrations: PiWebPluginRegistration[] = [];
|
||||||
for (const entry of manifest.plugins) {
|
for (const entry of manifest.plugins) {
|
||||||
|
if (options.shouldLoadPlugin?.(entry) === false) continue;
|
||||||
try {
|
try {
|
||||||
const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString();
|
const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString();
|
||||||
const module: unknown = await import(/* @vite-ignore */ moduleUrl);
|
const module: unknown = await import(/* @vite-ignore */ moduleUrl);
|
||||||
@@ -27,6 +30,7 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
|
|||||||
registrations.push({
|
registrations.push({
|
||||||
id: options.machineId === undefined ? entry.id : machineScopedPluginId(options.machineId, entry.id),
|
id: options.machineId === undefined ? entry.id : machineScopedPluginId(options.machineId, entry.id),
|
||||||
plugin,
|
plugin,
|
||||||
|
machineSpecific: entry.machineSpecific,
|
||||||
...(options.machineId === undefined ? {} : { machineId: options.machineId, sourcePluginId: entry.id }),
|
...(options.machineId === undefined ? {} : { machineId: options.machineId, sourcePluginId: entry.id }),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -48,11 +52,17 @@ function parseManifest(value: unknown): PluginManifest {
|
|||||||
return {
|
return {
|
||||||
plugins: value["plugins"].map((entry) => {
|
plugins: value["plugins"].map((entry) => {
|
||||||
if (!isRecord(entry) || typeof entry["id"] !== "string" || entry["id"] === "" || typeof entry["module"] !== "string" || entry["module"] === "") throw new Error("Invalid plugin manifest entry");
|
if (!isRecord(entry) || typeof entry["id"] !== "string" || entry["id"] === "" || typeof entry["module"] !== "string" || entry["module"] === "") throw new Error("Invalid plugin manifest entry");
|
||||||
return { id: entry["id"], module: entry["module"] };
|
return { id: entry["id"], module: entry["module"], machineSpecific: parseMachineSpecific(entry["machineSpecific"]) };
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMachineSpecific(value: unknown): boolean {
|
||||||
|
if (value === undefined) return false;
|
||||||
|
if (typeof value !== "boolean") throw new Error("Invalid plugin manifest entry");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin {
|
function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin {
|
||||||
if (!isRecord(module)) throw new Error(`Plugin module ${moduleUrl} did not export an object`);
|
if (!isRecord(module)) throw new Error(`Plugin module ${moduleUrl} did not export an object`);
|
||||||
const plugin = module["default"];
|
const plugin = module["default"];
|
||||||
|
|||||||
@@ -406,6 +406,91 @@ describe("PluginRegistry", () => {
|
|||||||
expect(panels.find((panel) => panel.id === `${remotePluginId}:workspace.remote`)?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(false);
|
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(panels.find((panel) => panel.id === "shared-tools:workspace.gateway")?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
|
||||||
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "gateway" }]);
|
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "gateway" }]);
|
||||||
|
expect(registry.shouldLoadRemotePlugin("shared-tools")).toBe(false);
|
||||||
|
expect(registry.shouldLoadRemotePlugin("shared-tools", true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses machine-specific remote duplicates instead of the gateway plugin for that machine", () => {
|
||||||
|
const registry = new PluginRegistry();
|
||||||
|
const workspace = testWorkspace();
|
||||||
|
const remotePluginId = machineScopedPluginId("remote-1", "updates");
|
||||||
|
registry.register({
|
||||||
|
id: "updates",
|
||||||
|
machineSpecific: true,
|
||||||
|
plugin: {
|
||||||
|
apiVersion: 1,
|
||||||
|
name: "Gateway Updates",
|
||||||
|
activate: () => ({
|
||||||
|
contributions: {
|
||||||
|
actions: [{ id: "open", title: "Open Gateway Updates", run: () => undefined }],
|
||||||
|
workspacePanels: [{ id: "workspace.updates", title: "Gateway Updates", render: () => html`<p>Gateway</p>` }],
|
||||||
|
workspaceLabels: [{ id: "label", items: () => [{ type: "text", text: "gateway" }] }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(registry.getActions(createContext().context).map((action) => action.id)).toContain("updates:open");
|
||||||
|
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).not.toContain("updates:open");
|
||||||
|
expect(registry.shouldLoadRemotePlugin("updates")).toBe(true);
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
id: remotePluginId,
|
||||||
|
machineId: "remote-1",
|
||||||
|
sourcePluginId: "updates",
|
||||||
|
plugin: {
|
||||||
|
apiVersion: 1,
|
||||||
|
name: "Remote Updates",
|
||||||
|
activate: () => ({
|
||||||
|
contributions: {
|
||||||
|
actions: [{ id: "open", title: "Open Remote Updates", run: () => undefined }],
|
||||||
|
workspacePanels: [{ id: "workspace.updates", title: "Remote Updates", render: () => html`<p>Remote</p>` }],
|
||||||
|
workspaceLabels: [{ id: "label", items: () => [{ type: "text", text: "remote" }] }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(registry.getActions(createContext().context).map((action) => action.id)).toContain("updates:open");
|
||||||
|
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).toEqual([`${remotePluginId}:open`]);
|
||||||
|
|
||||||
|
const panels = registry.getWorkspacePanels();
|
||||||
|
expect(panels.find((panel) => panel.id === "updates:workspace.updates")?.visible?.(createWorkspacePanelContext("local"))).toBe(true);
|
||||||
|
expect(panels.find((panel) => panel.id === "updates:workspace.updates")?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(false);
|
||||||
|
expect(panels.find((panel) => panel.id === `${remotePluginId}:workspace.updates`)?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
|
||||||
|
|
||||||
|
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("local", workspace))).toEqual([{ type: "text", text: "gateway" }]);
|
||||||
|
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "remote" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a machine-specific remote duplicate to override a portable gateway plugin for that machine", () => {
|
||||||
|
const registry = new PluginRegistry();
|
||||||
|
const remotePluginId = machineScopedPluginId("remote-1", "status-tools");
|
||||||
|
registry.register({
|
||||||
|
id: "status-tools",
|
||||||
|
plugin: {
|
||||||
|
apiVersion: 1,
|
||||||
|
name: "Gateway Status Tools",
|
||||||
|
activate: () => ({ contributions: { actions: [{ id: "open", title: "Open Gateway Status", run: () => undefined }] } }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(registry.shouldLoadRemotePlugin("status-tools")).toBe(false);
|
||||||
|
expect(registry.shouldLoadRemotePlugin("status-tools", true)).toBe(true);
|
||||||
|
registry.register({
|
||||||
|
id: remotePluginId,
|
||||||
|
machineId: "remote-1",
|
||||||
|
sourcePluginId: "status-tools",
|
||||||
|
machineSpecific: true,
|
||||||
|
plugin: {
|
||||||
|
apiVersion: 1,
|
||||||
|
name: "Remote Status Tools",
|
||||||
|
activate: () => ({ contributions: { actions: [{ id: "open", title: "Open Remote Status", run: () => undefined }] } }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(registry.getActions(createContext().context).map((action) => action.id)).toEqual(["status-tools:open"]);
|
||||||
|
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).toEqual([`${remotePluginId}:open`]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not activate remote duplicates when the gateway plugin is already registered", () => {
|
it("does not activate remote duplicates when the gateway plugin is already registered", () => {
|
||||||
|
|||||||
@@ -22,13 +22,16 @@ export class PluginRegistry {
|
|||||||
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 gatewayPluginIds = new Set<string>();
|
||||||
|
private readonly gatewayMachineSpecificPluginIds = new Set<string>();
|
||||||
|
private readonly remoteMachineSpecificPluginIds = new Map<string, 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);
|
||||||
|
const machineSpecific = this.parseMachineSpecific(id, registration.machineSpecific);
|
||||||
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;
|
if (this.isRemoteDuplicateHiddenByGateway(registration.sourcePluginId, registration.machineId, machineSpecific)) return;
|
||||||
this.pluginIds.add(id);
|
this.pluginIds.add(id);
|
||||||
|
|
||||||
const apiVersion: unknown = plugin.apiVersion;
|
const apiVersion: unknown = plugin.apiVersion;
|
||||||
@@ -42,11 +45,19 @@ export class PluginRegistry {
|
|||||||
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);
|
this.gatewayPluginIds.add(id);
|
||||||
|
if (machineSpecific) this.gatewayMachineSpecificPluginIds.add(id);
|
||||||
|
} else if (registration.sourcePluginId !== undefined && machineSpecific) {
|
||||||
|
addMappedSetValue(this.remoteMachineSpecificPluginIds, registration.sourcePluginId, registration.machineId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shouldLoadRemotePlugin(sourcePluginId: string, machineSpecific = false): boolean {
|
||||||
|
return !this.gatewayPluginIds.has(sourcePluginId) || this.gatewayMachineSpecificPluginIds.has(sourcePluginId) || machineSpecific;
|
||||||
|
}
|
||||||
|
|
||||||
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
|
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
|
||||||
return this.actions.filter((action) => isActiveForMachine(action.machineId, runtimeContextMachineId(context), action.sourcePluginId, this.gatewayPluginIds)).map((action) => {
|
const selectedMachineId = runtimeContextMachineId(context);
|
||||||
|
return this.actions.filter((action) => this.isContributionActive(action.pluginId, action.machineId, selectedMachineId, action.sourcePluginId)).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 = {
|
||||||
@@ -101,8 +112,8 @@ 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, sourcePluginId, this.gatewayPluginIds) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true),
|
visible: (context: WorkspacePanelContext) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true),
|
||||||
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }),
|
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }),
|
||||||
render: (context: WorkspacePanelContext) => panel.render(workspacePanelContextFor(context, pluginId)),
|
render: (context: WorkspacePanelContext) => panel.render(workspacePanelContextFor(context, pluginId)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -117,8 +128,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, sourcePluginId, this.gatewayPluginIds) && (visible?.(context) ?? true),
|
visible: (context) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) && (visible?.(context) ?? true),
|
||||||
items: (context) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) ? items(context) : [],
|
items: (context) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) ? items(context) : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +163,33 @@ export class PluginRegistry {
|
|||||||
return `${pluginId}:${localId}`;
|
return `${pluginId}:${localId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isContributionActive(pluginId: string, machineId: string | undefined, selectedMachineId: string, sourcePluginId: string | undefined): boolean {
|
||||||
|
if (machineId === undefined) return !this.isGatewayPluginHiddenForMachine(pluginId, selectedMachineId);
|
||||||
|
return machineId === selectedMachineId && !this.isRemotePluginHiddenByGateway(sourcePluginId, machineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRemoteDuplicateHiddenByGateway(sourcePluginId: string | undefined, machineId: string | undefined, machineSpecific: boolean): boolean {
|
||||||
|
return sourcePluginId !== undefined
|
||||||
|
&& machineId !== undefined
|
||||||
|
&& this.gatewayPluginIds.has(sourcePluginId)
|
||||||
|
&& !this.gatewayMachineSpecificPluginIds.has(sourcePluginId)
|
||||||
|
&& !machineSpecific;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRemotePluginHiddenByGateway(sourcePluginId: string | undefined, machineId: string): boolean {
|
||||||
|
if (sourcePluginId === undefined) return false;
|
||||||
|
if (this.gatewayMachineSpecificPluginIds.has(sourcePluginId)) return false;
|
||||||
|
if (this.remoteMachineSpecificPluginIds.get(sourcePluginId)?.has(machineId) === true) return false;
|
||||||
|
return this.gatewayPluginIds.has(sourcePluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isGatewayPluginHiddenForMachine(pluginId: string, machineId: string): boolean {
|
||||||
|
return machineId !== "local" && (
|
||||||
|
this.gatewayMachineSpecificPluginIds.has(pluginId)
|
||||||
|
|| this.remoteMachineSpecificPluginIds.get(pluginId)?.has(machineId) === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private validatePluginId(pluginId: string): void {
|
private validatePluginId(pluginId: string): void {
|
||||||
if (!idPattern.test(pluginId)) throw new Error(`Invalid plugin id: ${pluginId}`);
|
if (!idPattern.test(pluginId)) throw new Error(`Invalid plugin id: ${pluginId}`);
|
||||||
}
|
}
|
||||||
@@ -159,6 +197,12 @@ export class PluginRegistry {
|
|||||||
private validateLocalId(localId: string): void {
|
private validateLocalId(localId: string): void {
|
||||||
if (!localIdPattern.test(localId)) throw new Error(`Invalid contribution id: ${localId}`);
|
if (!localIdPattern.test(localId)) throw new Error(`Invalid contribution id: ${localId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseMachineSpecific(pluginId: string, value: unknown): boolean {
|
||||||
|
if (value === undefined) return false;
|
||||||
|
if (typeof value !== "boolean") throw new Error(`Invalid plugin machineSpecific value for ${pluginId}: ${formatUnknownValue(value)}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pluginRuntimeContextFor(context: PluginRuntimeContext, pluginId: string): PluginRuntimeContext {
|
function pluginRuntimeContextFor(context: PluginRuntimeContext, pluginId: string): PluginRuntimeContext {
|
||||||
@@ -179,16 +223,20 @@ export function installWorkspacePanelScope(context: WorkspacePanelContext, scope
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDuplicateOfGatewayPlugin(registration: PiWebPluginRegistration, gatewayPluginIds: ReadonlySet<string>): boolean {
|
function addMappedSetValue(map: Map<string, Set<string>>, key: string, value: string): void {
|
||||||
return registration.machineId !== undefined && registration.sourcePluginId !== undefined && gatewayPluginIds.has(registration.sourcePluginId);
|
const existing = map.get(key);
|
||||||
|
if (existing === undefined) map.set(key, new Set([value]));
|
||||||
|
else existing.add(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActiveForMachine(machineId: string | undefined, selectedMachineId: string, sourcePluginId: string | undefined, gatewayPluginIds: ReadonlySet<string>): boolean {
|
function formatUnknownValue(value: unknown): string {
|
||||||
return machineId === undefined || (machineId === selectedMachineId && !isHiddenByGatewayPlugin(sourcePluginId, gatewayPluginIds));
|
if (typeof value === "string") return value;
|
||||||
}
|
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol" || typeof value === "function" || value === null || value === undefined) return String(value);
|
||||||
|
try {
|
||||||
function isHiddenByGatewayPlugin(sourcePluginId: string | undefined, gatewayPluginIds: ReadonlySet<string>): boolean {
|
return JSON.stringify(value);
|
||||||
return sourcePluginId !== undefined && gatewayPluginIds.has(sourcePluginId);
|
} catch {
|
||||||
|
return Object.prototype.toString.call(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimeContextMachineId(context: PluginRuntimeContext): string {
|
function runtimeContextMachineId(context: PluginRuntimeContext): string {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface PiWebPluginRegistration {
|
|||||||
plugin: PiWebPlugin;
|
plugin: PiWebPlugin;
|
||||||
machineId?: string;
|
machineId?: string;
|
||||||
sourcePluginId?: PluginId;
|
sourcePluginId?: PluginId;
|
||||||
|
machineSpecific?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PiWebPlugin {
|
export interface PiWebPlugin {
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ beforeEach(async () => {
|
|||||||
}),
|
}),
|
||||||
sessionDaemon: fakeSessionDaemon(),
|
sessionDaemon: fakeSessionDaemon(),
|
||||||
piWebPlugins: {
|
piWebPlugins: {
|
||||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
|
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
|
||||||
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] }),
|
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
|
||||||
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
|
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
|
||||||
},
|
},
|
||||||
clientDist: false,
|
clientDist: false,
|
||||||
@@ -332,11 +332,11 @@ describe("buildApp", () => {
|
|||||||
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
||||||
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
|
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
|
||||||
expect(manifestResponse.statusCode).toBe(200);
|
expect(manifestResponse.statusCode).toBe(200);
|
||||||
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] });
|
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] });
|
||||||
|
|
||||||
const pluginsResponse = await app.inject({ method: "GET", url: "/api/plugins" });
|
const pluginsResponse = await app.inject({ method: "GET", url: "/api/plugins" });
|
||||||
expect(pluginsResponse.statusCode).toBe(200);
|
expect(pluginsResponse.statusCode).toBe(200);
|
||||||
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] });
|
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
|
||||||
|
|
||||||
const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
|
const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
|
||||||
expect(assetResponse.statusCode).toBe(200);
|
expect(assetResponse.statusCode).toBe(200);
|
||||||
@@ -353,7 +353,7 @@ describe("buildApp", () => {
|
|||||||
const requestJson = vi.fn(() => Promise.resolve({
|
const requestJson = vi.fn(() => Promise.resolve({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local" }] },
|
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local", machineSpecific: true }] },
|
||||||
}));
|
}));
|
||||||
const request = vi.fn(() => Promise.resolve({
|
const request = vi.fn(() => Promise.resolve({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
@@ -366,7 +366,7 @@ describe("buildApp", () => {
|
|||||||
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
|
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
|
||||||
expect(manifestResponse.statusCode).toBe(200);
|
expect(manifestResponse.statusCode).toBe(200);
|
||||||
expect(manifestResponse.json()).toEqual({
|
expect(manifestResponse.json()).toEqual({
|
||||||
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local" }],
|
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local", machineSpecific: true }],
|
||||||
});
|
});
|
||||||
expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 });
|
expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 });
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface RemotePluginManifestEntry {
|
|||||||
module: string;
|
module: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
|
machineSpecific?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RemotePluginManifest {
|
interface RemotePluginManifest {
|
||||||
@@ -158,11 +159,18 @@ function parseRemoteManifest(value: unknown): RemotePluginManifest {
|
|||||||
module: entry["module"],
|
module: entry["module"],
|
||||||
...(typeof entry["source"] === "string" ? { source: entry["source"] } : {}),
|
...(typeof entry["source"] === "string" ? { source: entry["source"] } : {}),
|
||||||
...(typeof entry["scope"] === "string" ? { scope: entry["scope"] } : {}),
|
...(typeof entry["scope"] === "string" ? { scope: entry["scope"] } : {}),
|
||||||
|
...(parseRemoteMachineSpecific(entry["machineSpecific"])),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRemoteMachineSpecific(value: unknown): { machineSpecific?: boolean } {
|
||||||
|
if (value === undefined) return {};
|
||||||
|
if (typeof value !== "boolean") throw new Error("Invalid remote PI WEB plugin manifest entry");
|
||||||
|
return { machineSpecific: value };
|
||||||
|
}
|
||||||
|
|
||||||
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
|
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
|
||||||
for (const [name, value] of Object.entries(headers)) {
|
for (const [name, value] of Object.entries(headers)) {
|
||||||
if (value === undefined) continue;
|
if (value === undefined) continue;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("PiWebPluginService", () => {
|
|||||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||||
|
|
||||||
await expect(service.manifest()).resolves.toEqual({
|
await expect(service.manifest()).resolves.toEqual({
|
||||||
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local" })],
|
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })],
|
||||||
});
|
});
|
||||||
const manifest = await service.manifest();
|
const manifest = await service.manifest();
|
||||||
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u);
|
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u);
|
||||||
@@ -35,6 +35,18 @@ describe("PiWebPluginService", () => {
|
|||||||
expect(asset?.content.toString("utf8")).toContain("export default");
|
expect(asset?.content.toString("utf8")).toContain("export default");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes machine-specific preferences in plugin manifests", async () => {
|
||||||
|
await writePlugin(join(tempDir, "plugins", "updates"), {
|
||||||
|
packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
|
||||||
|
files: { "pi-web-plugin.js": "export default {};" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||||
|
|
||||||
|
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true }] });
|
||||||
|
await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] });
|
||||||
|
});
|
||||||
|
|
||||||
it("discovers Pi package plugins through an injected package provider", async () => {
|
it("discovers Pi package plugins through an injected package provider", async () => {
|
||||||
const packageDir = join(tempDir, "pkg");
|
const packageDir = join(tempDir, "pkg");
|
||||||
await writePlugin(packageDir, {
|
await writePlugin(packageDir, {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface PiWebPluginManifestEntry {
|
|||||||
module: string;
|
module: string;
|
||||||
source: string;
|
source: string;
|
||||||
scope: PiWebPluginScope;
|
scope: PiWebPluginScope;
|
||||||
|
machineSpecific: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfiguredPiPackage {
|
export interface ConfiguredPiPackage {
|
||||||
@@ -38,6 +39,7 @@ interface PluginRecord {
|
|||||||
version: string;
|
version: string;
|
||||||
source: string;
|
source: string;
|
||||||
scope: PiWebPluginScope;
|
scope: PiWebPluginScope;
|
||||||
|
machineSpecific: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PiWebPluginServiceOptions {
|
interface PiWebPluginServiceOptions {
|
||||||
@@ -61,6 +63,7 @@ interface PiWebPackageConfig {
|
|||||||
interface PiWebPluginEntry {
|
interface PiWebPluginEntry {
|
||||||
id: string;
|
id: string;
|
||||||
module: string;
|
module: string;
|
||||||
|
machineSpecific: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
|
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
|
||||||
@@ -102,7 +105,7 @@ export class PiWebPluginService {
|
|||||||
return {
|
return {
|
||||||
plugins: (await this.plugins()).plugins
|
plugins: (await this.plugins()).plugins
|
||||||
.filter((plugin) => plugin.enabled)
|
.filter((plugin) => plugin.enabled)
|
||||||
.map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope })),
|
.map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope, machineSpecific: plugin.machineSpecific })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +138,7 @@ export class PiWebPluginService {
|
|||||||
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`,
|
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`,
|
||||||
source: plugin.source,
|
source: plugin.source,
|
||||||
scope: plugin.scope,
|
scope: plugin.scope,
|
||||||
|
machineSpecific: plugin.machineSpecific,
|
||||||
enabled: config.plugins?.[plugin.id]?.enabled !== false,
|
enabled: config.plugins?.[plugin.id]?.enabled !== false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -228,7 +232,7 @@ async function discoverPluginEntries(root: string, config: PiWebPackageConfig):
|
|||||||
const entryPath = join(root, entry.module);
|
const entryPath = join(root, entry.module);
|
||||||
const entryStat = await stat(entryPath).catch(() => undefined);
|
const entryStat = await stat(entryPath).catch(() => undefined);
|
||||||
if (entryStat?.isFile() !== true) throw new Error(`PI WEB plugin module not found for ${entry.id}: ${entry.module}`);
|
if (entryStat?.isFile() !== true) throw new Error(`PI WEB plugin module not found for ${entry.id}: ${entry.module}`);
|
||||||
plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)) });
|
plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)), machineSpecific: entry.machineSpecific });
|
||||||
}
|
}
|
||||||
return plugins;
|
return plugins;
|
||||||
}
|
}
|
||||||
@@ -248,7 +252,7 @@ async function readPiWebPackageConfig(root: string): Promise<PiWebPackageConfig
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string): PiWebPluginEntry[] {
|
function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string): PiWebPluginEntry[] {
|
||||||
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported PI WEB plugin metadata in ${packagePath}: use piWeb.plugins with { id, module } entries`);
|
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported PI WEB plugin metadata in ${packagePath}: use piWeb.plugins with { id, module, machineSpecific? } entries`);
|
||||||
const plugins = piWeb["plugins"];
|
const plugins = piWeb["plugins"];
|
||||||
if (plugins === undefined) return [];
|
if (plugins === undefined) return [];
|
||||||
if (!Array.isArray(plugins)) throw new Error(`PI WEB plugins must be an array in ${packagePath}`);
|
if (!Array.isArray(plugins)) throw new Error(`PI WEB plugins must be an array in ${packagePath}`);
|
||||||
@@ -259,10 +263,26 @@ function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string)
|
|||||||
const module = entry["module"];
|
const module = entry["module"];
|
||||||
if (typeof id !== "string" || !isPiWebPluginId(id)) throw new Error(`Invalid PI WEB plugin id in ${packagePath}: ${String(id)}`);
|
if (typeof id !== "string" || !isPiWebPluginId(id)) throw new Error(`Invalid PI WEB plugin id in ${packagePath}: ${String(id)}`);
|
||||||
if (typeof module !== "string" || module === "") throw new Error(`Invalid PI WEB plugin module for ${id} in ${packagePath}`);
|
if (typeof module !== "string" || module === "") throw new Error(`Invalid PI WEB plugin module for ${id} in ${packagePath}`);
|
||||||
return { id, module };
|
return { id, module, machineSpecific: parseMachineSpecific(entry["machineSpecific"], packagePath, id) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMachineSpecific(value: unknown, packagePath: string, pluginId: string): boolean {
|
||||||
|
if (value === undefined) return false;
|
||||||
|
if (typeof value !== "boolean") throw new Error(`Invalid PI WEB plugin machineSpecific value for ${pluginId} in ${packagePath}: ${formatUnknownValue(value)}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUnknownValue(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol" || typeof value === "function" || value === null || value === undefined) return String(value);
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return Object.prototype.toString.call(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): void {
|
function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): void {
|
||||||
if (records.has(plugin.id)) {
|
if (records.has(plugin.id)) {
|
||||||
warnInvalidPlugin(plugin.source, `Duplicate PI WEB plugin id: ${plugin.id}`);
|
warnInvalidPlugin(plugin.source, `Duplicate PI WEB plugin id: ${plugin.id}`);
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface PiWebPluginInfo {
|
|||||||
module: string;
|
module: string;
|
||||||
source: string;
|
source: string;
|
||||||
scope: PiWebPluginScope;
|
scope: PiWebPluginScope;
|
||||||
|
machineSpecific: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface FederatedHttpRouteSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const FEDERATED_HTTP_ROUTES = [
|
export const FEDERATED_HTTP_ROUTES = [
|
||||||
|
{ method: "GET", path: "/pi-web/status" },
|
||||||
{ method: "GET", path: "/projects" },
|
{ method: "GET", path: "/projects" },
|
||||||
{ method: "POST", path: "/projects" },
|
{ method: "POST", path: "/projects" },
|
||||||
{ method: "DELETE", path: "/projects/:projectId" },
|
{ method: "DELETE", path: "/projects/:projectId" },
|
||||||
|
|||||||
Reference in New Issue
Block a user