From fb9c168b6bebc33cde6ec6404c6971b63f590319 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 29 Sep 2026 13:38:11 -0500 Subject: [PATCH] feat: plugins only see their own host settings in the browser --- packages/plugin-sdk/src/frontend.ts | 23 +- .../src/frontend/components/console-look.ts | 12 +- .../file-manager/src/frontend/api/client.ts | 4 + .../file-manager/src/frontend/api/hosts.ts | 2 +- .../src/frontend/ProxmoxDiscoverDialog.tsx | 8 +- plugins/proxmox/src/frontend/proxmox-api.ts | 4 + plugins/remote-desktop/src/frontend/index.tsx | 37 +++- plugins/ssh-terminal/src/frontend/index.tsx | 9 +- .../wake-on-lan/src/frontend/host-action.ts | 12 +- plugins/wake-on-lan/src/frontend/index.tsx | 6 +- .../tests/frontend/host-action.test.ts | 26 ++- src/ui/plugin-host/app.ts | 61 ++++- src/ui/plugin-host/bridge.ts | 30 ++- src/ui/plugin-host/host-scope.ts | 84 +++++++ src/ui/plugin-host/scope.tsx | 3 +- src/ui/plugin-host/shell-bridge.ts | 10 +- src/ui/tests/plugin-host/host-scope.test.tsx | 209 ++++++++++++++++++ 17 files changed, 488 insertions(+), 52 deletions(-) create mode 100644 src/ui/plugin-host/host-scope.ts create mode 100644 src/ui/tests/plugin-host/host-scope.test.tsx diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index b22e3f9d1..b20a9bf29 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -63,7 +63,10 @@ export interface PluginHostRecord { statusCheckInterval?: number | null; /** Keepalive, legacy algorithms, agent and environment options. */ sshOptions?: HostSshOptions | null; - /** Each enabled plugin's host settings, secrets redacted. */ + /** + * This plugin's own host settings, under its id, secrets redacted. Another + * plugin's settings never appear here; ask that plugin through an action. + */ pluginSettings?: Record>; /** Each declared protocol's login, secrets left out. */ protocolAuth?: Record; @@ -819,6 +822,13 @@ export interface TermixApp extends TermixAppInfo { pointId: string, extension: ExtensionContribution, ) => Disposer; + /** + * Every host the user can see, fetched now. Like every host record a plugin + * gets, `pluginSettings` holds only this plugin's own settings. + */ + listHosts: () => Promise; + /** A saved host from the shell's list, or undefined. No request is made. */ + getHost: (hostId: string | number) => PluginHostRecord | undefined; registerSettingsComponent: ( componentId: string, component: ComponentType, @@ -1068,8 +1078,11 @@ export interface PluginCoreApi { getClientPreference: (name: string) => string | undefined; /** Saves a browser-side UI preference where getClientPreference reads it. */ setClientPreference: (name: string, value: string) => void; - /** Every host the user can see, fetched now rather than from the shell's cache. */ - listHosts: () => Promise; + /** + * Every host the user can see, fetched now rather than from the shell's + * cache, carrying only `pluginId`'s host settings. + */ + listHosts: (pluginId: string) => Promise; /** The user's stored credentials, without their secrets. */ listCredentials: () => Promise; } @@ -1231,10 +1244,6 @@ export function setClientPreference(name: string, value: string): void { requireHost().core.setClientPreference(name, value); } -export function listHosts(): Promise { - return requireHost().core.listHosts(); -} - export function listCredentials(): Promise { return requireHost().core.listCredentials(); } diff --git a/plugins/docker/src/frontend/components/console-look.ts b/plugins/docker/src/frontend/components/console-look.ts index 01b9437f8..58c26cc7c 100644 --- a/plugins/docker/src/frontend/components/console-look.ts +++ b/plugins/docker/src/frontend/components/console-look.ts @@ -42,13 +42,12 @@ export function useConsoleLook( appTheme: string, ): ConsoleLook { const [look, setLook] = useState(FALLBACK_CONSOLE_LOOK); - const hostKey = host - ? `${host.id}:${JSON.stringify(host.pluginSettings ?? {})}` - : ""; - + // The terminal's settings live with the terminal, so it resolves them from + // the host id. + const hostId = host?.id; useEffect(() => { let active = true; - invokeAction("terminal.resolveTheme", { host, appTheme }) + invokeAction("terminal.resolveTheme", { hostId, appTheme }) .then((resolved) => { if (active) setLook((resolved as ConsoleLook) ?? FALLBACK_CONSOLE_LOOK); }) @@ -58,8 +57,7 @@ export function useConsoleLook( return () => { active = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hostKey, appTheme]); + }, [hostId, appTheme]); return look; } diff --git a/plugins/file-manager/src/frontend/api/client.ts b/plugins/file-manager/src/frontend/api/client.ts index c3a5ec946..64402499d 100644 --- a/plugins/file-manager/src/frontend/api/client.ts +++ b/plugins/file-manager/src/frontend/api/client.ts @@ -48,6 +48,10 @@ function app(): TermixApp { return current; } +export function listHosts(): ReturnType { + return app().listHosts(); +} + export function fileManagerApi(): FileManagerClient { return app().api as unknown as FileManagerClient; } diff --git a/plugins/file-manager/src/frontend/api/hosts.ts b/plugins/file-manager/src/frontend/api/hosts.ts index 4f3a15250..df74c48be 100644 --- a/plugins/file-manager/src/frontend/api/hosts.ts +++ b/plugins/file-manager/src/frontend/api/hosts.ts @@ -1,4 +1,4 @@ -import { listHosts } from "@termix/plugin-sdk/frontend"; +import { listHosts } from "./client"; import type { SSHHost } from "../host-types"; export async function getSSHHosts(): Promise { diff --git a/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx b/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx index a6e9c3c5c..895bfb3e2 100644 --- a/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx +++ b/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx @@ -11,8 +11,12 @@ import { DialogFooter, } from "@termix/plugin-sdk/ui"; import { Select2 } from "@termix/plugin-sdk/ui"; -import { discoverProxmoxGuestsStream, importProxmoxHosts } from "./proxmox-api"; -import { listHosts, type PluginHostRecord } from "@termix/plugin-sdk/frontend"; +import { + discoverProxmoxGuestsStream, + importProxmoxHosts, + listHosts, +} from "./proxmox-api"; +import type { PluginHostRecord } from "@termix/plugin-sdk/frontend"; import type { ProxmoxGuest } from "./types"; import { resolveProxmoxImportAuth } from "./proxmox-import-auth"; diff --git a/plugins/proxmox/src/frontend/proxmox-api.ts b/plugins/proxmox/src/frontend/proxmox-api.ts index 4ed557aa6..08bcac634 100644 --- a/plugins/proxmox/src/frontend/proxmox-api.ts +++ b/plugins/proxmox/src/frontend/proxmox-api.ts @@ -13,6 +13,10 @@ function app(): TermixApp { return current; } +export function listHosts(): ReturnType { + return app().listHosts(); +} + /** * Runs a guest discovery over the server-sent event stream and reports * progress. Returns a function that stops listening. diff --git a/plugins/remote-desktop/src/frontend/index.tsx b/plugins/remote-desktop/src/frontend/index.tsx index 9bb5f14f1..a6101f61a 100644 --- a/plugins/remote-desktop/src/frontend/index.tsx +++ b/plugins/remote-desktop/src/frontend/index.tsx @@ -1,4 +1,4 @@ -import type { ComponentType, Ref } from "react"; +import { useEffect, useState, type ComponentType, type Ref } from "react"; import { Info, MessagesSquare, @@ -7,6 +7,7 @@ import { MousePointerClick, } from "lucide-react"; import { + invokeAction, useTranslation, type HostEditorSectionProps, type PluginHostRecord, @@ -175,21 +176,42 @@ function ToolbarCard({ ); } -/** The Wake-on-LAN address a host keeps, the default for a session's wake packet. */ -function wakeOnLanMac(host: PluginHostRecord | undefined): string | null { - const value = host?.pluginSettings?.["wake-on-lan"]?.macAddress; - return typeof value === "string" && value ? value : null; +/** + * The Wake-on-LAN address a host keeps, the default for a session's wake + * packet. Null while that plugin is off. + */ +function useWakeOnLanMac(hostId: string | undefined): string | null { + const [mac, setMac] = useState(null); + useEffect(() => { + let active = true; + if (!hostId) { + setMac(null); + return; + } + invokeAction("wakeOnLan.macAddress", hostId) + .then((value) => { + if (active) setMac(typeof value === "string" && value ? value : null); + }) + .catch(() => { + if (active) setMac(null); + }); + return () => { + active = false; + }; + }, [hostId]); + return mac; } function RdpSection(props: HostEditorSectionProps) { const { form, setField, setGuacField } = remoteDesktopForm(props); + const macAddress = useWakeOnLanMac(props.host?.id); return ( <> @@ -200,13 +222,14 @@ function RdpSection(props: HostEditorSectionProps) { function VncSection(props: HostEditorSectionProps) { const { form, setField, setGuacField } = remoteDesktopForm(props); + const macAddress = useWakeOnLanMac(props.host?.id); return ( <> diff --git a/plugins/ssh-terminal/src/frontend/index.tsx b/plugins/ssh-terminal/src/frontend/index.tsx index 1ef546537..598694bc6 100644 --- a/plugins/ssh-terminal/src/frontend/index.tsx +++ b/plugins/ssh-terminal/src/frontend/index.tsx @@ -7,7 +7,6 @@ import { SquareTerminal, Terminal, } from "lucide-react"; -import { listHosts } from "@termix/plugin-sdk/frontend"; import type { PanelProps, PluginHostRecord, @@ -309,9 +308,11 @@ export function activate(app: TermixApp): void { ); let host: PluginHostRecord | null | undefined = request.host; if (!host && request.hostId !== undefined) { - host = (await listHosts().catch(() => [])).find( - (entry) => String(entry.id) === String(request.hostId), - ); + host = + app.getHost(request.hostId) ?? + (await app.listHosts().catch(() => [])).find( + (entry) => String(entry.id) === String(request.hostId), + ); } return resolveTerminalLook({ ...request, host }, settings?.user); }) as never); diff --git a/plugins/wake-on-lan/src/frontend/host-action.ts b/plugins/wake-on-lan/src/frontend/host-action.ts index bcc121121..afacc5837 100644 --- a/plugins/wake-on-lan/src/frontend/host-action.ts +++ b/plugins/wake-on-lan/src/frontend/host-action.ts @@ -5,11 +5,19 @@ import type { TermixApp, } from "@termix/plugin-sdk/frontend"; +export function macAddressOf( + host: Pick | null | undefined, +): string | null { + const macAddress = host?.pluginSettings?.["wake-on-lan"]?.macAddress; + return typeof macAddress === "string" && macAddress.trim() + ? macAddress.trim() + : null; +} + export function hasMacAddress( host: Pick, ): boolean { - const macAddress = host.pluginSettings?.["wake-on-lan"]?.macAddress; - return typeof macAddress === "string" && macAddress.trim().length > 0; + return macAddressOf(host) !== null; } function errorMessage(error: unknown, fallback: string): string { diff --git a/plugins/wake-on-lan/src/frontend/index.tsx b/plugins/wake-on-lan/src/frontend/index.tsx index 084a16e5a..f96ba8160 100644 --- a/plugins/wake-on-lan/src/frontend/index.tsx +++ b/plugins/wake-on-lan/src/frontend/index.tsx @@ -1,6 +1,6 @@ import { Zap } from "lucide-react"; import type { TermixApp } from "@termix/plugin-sdk/frontend"; -import { hasMacAddress, wakeHost } from "./host-action.js"; +import { hasMacAddress, macAddressOf, wakeHost } from "./host-action.js"; export function activate(app: TermixApp): void { app.registerHostAction({ @@ -12,4 +12,8 @@ export function activate(app: TermixApp): void { when: (host) => hasMacAddress(host), run: (host) => wakeHost(app.api, app.t, String(host.id)), }); + + // A saved host's MAC address, for plugins that send their own wake packet. + app.registerAction("wakeOnLan.macAddress", ((hostId: string | number) => + macAddressOf(app.getHost(hostId))) as never); } diff --git a/plugins/wake-on-lan/tests/frontend/host-action.test.ts b/plugins/wake-on-lan/tests/frontend/host-action.test.ts index e6d3eb9aa..03a6f622e 100644 --- a/plugins/wake-on-lan/tests/frontend/host-action.test.ts +++ b/plugins/wake-on-lan/tests/frontend/host-action.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { toast } from "sonner"; import type { PluginApiClient } from "@termix/plugin-sdk/frontend"; -import { hasMacAddress, wakeHost } from "../../src/frontend/host-action.js"; +import { + hasMacAddress, + macAddressOf, + wakeHost, +} from "../../src/frontend/host-action.js"; vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() }, @@ -24,6 +28,26 @@ function stubApi(overrides: Partial = {}): PluginApiClient { const t = (key: string) => key; +describe("macAddressOf", () => { + it("returns the trimmed MAC address a host keeps", () => { + expect( + macAddressOf({ + pluginSettings: { + "wake-on-lan": { macAddress: " aa:bb:cc:dd:ee:ff " }, + }, + }), + ).toBe("aa:bb:cc:dd:ee:ff"); + }); + + it("is null without a host or an address", () => { + expect(macAddressOf(undefined)).toBeNull(); + expect(macAddressOf({ pluginSettings: {} })).toBeNull(); + expect( + macAddressOf({ pluginSettings: { "wake-on-lan": { macAddress: " " } } }), + ).toBeNull(); + }); +}); + describe("hasMacAddress", () => { it("is true when the host's wake-on-lan settings carry a MAC address", () => { expect( diff --git a/src/ui/plugin-host/app.ts b/src/ui/plugin-host/app.ts index 5af0267c6..a553de718 100644 --- a/src/ui/plugin-host/app.ts +++ b/src/ui/plugin-host/app.ts @@ -51,11 +51,20 @@ import { registerSecondFactor, registerSshAuthEditor, } from "./auth-registry"; -import { pluginHostBridge, resolvePluginPermission } from "./bridge"; -import { shell, tabsApi } from "./shell-bridge"; +import { + pluginHostBridge, + resolvePluginPermission, + toPluginHostRecord, +} from "./bridge"; +import { shell, shellHost, tabsApi } from "./shell-bridge"; import { onRemoteServerChange, remoteServerUrl } from "./desktop"; import { isElectron } from "@/lib/electron"; import { withPluginScope, withIconBoundary, guardCallback } from "./scope"; +import { + scopeHostArgs, + scopeHostCallback, + scopeHostFields, +} from "./host-scope"; import { manifestDeclares, type ViewKind } from "./view-ownership"; import { pluginKey } from "@/lib/plugin-i18n"; import { hasPermission } from "@/hooks/use-permissions"; @@ -98,6 +107,11 @@ export function createPluginApp( const key = (value: string) => pluginKey(pluginId, value); + // Every host a plugin callback receives carries only this plugin's settings. + const hostCallback = unknown>( + fn: F | undefined, + ): F | undefined => scopeHostCallback(fn, pluginId); + const requireDeclared = (kind: ViewKind, id: string, what: string) => { if (!manifestDeclares(contributes, kind, id)) { const field = @@ -221,17 +235,29 @@ export function createPluginApp( titleKey: key(action.titleKey), pluginId, icon: withIconBoundary(pluginId, action.icon as never) as never, - when: action.when as never, - run: action.run as never, + when: hostCallback(action.when) as never, + run: hostCallback(action.run) as never, // Called while the host list renders, so a throw must not escape. label: guardCallback( pluginId, - action.label as ((...args: unknown[]) => unknown) | undefined, + hostCallback( + action.label as ((...args: unknown[]) => unknown) | undefined, + ), undefined, ) as never, items: guardCallback( pluginId, - action.items as ((...args: unknown[]) => unknown) | undefined, + action.items + ? (...args: unknown[]) => + ( + action.items as ( + ...a: unknown[] + ) => { run: (...a: never[]) => unknown }[] + )(...scopeHostArgs(args, pluginId))?.map((item) => ({ + ...item, + run: hostCallback(item.run), + })) + : undefined, undefined, ) as never, }), @@ -256,7 +282,7 @@ export function createPluginApp( registerHostBadge({ id: badge.id, pluginId, - when: badge.when as never, + when: hostCallback(badge.when) as never, component: scoped(badge.component) as never, }), ); @@ -268,8 +294,8 @@ export function createPluginApp( ...item, titleKey: key(item.titleKey), pluginId, - when: item.when as never, - run: item.run as never, + when: hostCallback(item.when) as never, + run: hostCallback(item.run) as never, }), ); }, @@ -280,8 +306,8 @@ export function createPluginApp( ...entry, titleKey: key(entry.titleKey), pluginId, - when: entry.when as never, - run: entry.run as never, + when: hostCallback(entry.when) as never, + run: hostCallback(entry.run) as never, }), ); }, @@ -373,6 +399,13 @@ export function createPluginApp( ); }, + listHosts: () => pluginHostBridge.core.listHosts(pluginId), + + getHost(hostId) { + const host = shellHost(hostId); + return host ? toPluginHostRecord(host, pluginId) : undefined; + }, + registerSettingsComponent(componentId, component) { return track( registerSettingsComponent(pluginId, componentId, scoped(component)), @@ -381,7 +414,7 @@ export function createPluginApp( registerAction(id, handler, options = {}) { return track( - registerAction(id, handler, { + registerAction(id, hostCallback(handler), { permission: options.permission ? resolvePluginPermission(pluginId, options.permission) : undefined, @@ -405,6 +438,10 @@ export function createPluginApp( component: contribution.component ? scoped(contribution.component) : undefined, + when: contribution.when + ? (context: Record) => + contribution.when!(scopeHostFields(context, pluginId)) + : undefined, pluginId, }), ); diff --git a/src/ui/plugin-host/bridge.ts b/src/ui/plugin-host/bridge.ts index 299695c93..1aa800d78 100644 --- a/src/ui/plugin-host/bridge.ts +++ b/src/ui/plugin-host/bridge.ts @@ -191,6 +191,17 @@ export function toHostRecord(host: ShellHost): PluginHostRecord { }; } +/** The host as one plugin sees it: only that plugin's settings. */ +export function toPluginHostRecord( + host: ShellHost, + pluginId: string | null, +): PluginHostRecord { + const record = toHostRecord(host); + const own = pluginId ? host.pluginSettings?.[pluginId] : undefined; + record.pluginSettings = own && pluginId ? { [pluginId]: own } : {}; + return record; +} + export const pluginHostBridge: PluginHostBridge = { usePluginId() { const pluginId = usePluginScope(); @@ -257,19 +268,24 @@ export const pluginHostBridge: PluginHostBridge = { }, useHost(hostId) { + const pluginId = usePluginScope(); const { hosts } = useShellHosts(); return useMemo(() => { if (hostId === undefined || hostId === null) return null; const found = hosts.find((host) => host.id === String(hostId)); - return found ? toHostRecord(found) : null; - }, [hosts, hostId]); + return found ? toPluginHostRecord(found, pluginId) : null; + }, [hosts, hostId, pluginId]); }, useHosts() { + const pluginId = usePluginScope(); const { hosts, loaded } = useShellHosts(); return useMemo( - () => ({ hosts: hosts.map(toHostRecord), loaded }), - [hosts, loaded], + () => ({ + hosts: hosts.map((host) => toPluginHostRecord(host, pluginId)), + loaded, + }), + [hosts, loaded, pluginId], ); }, @@ -410,8 +426,10 @@ export const pluginHostBridge: PluginHostBridge = { setClientPreference: (name, value) => { void setCookie(name, value, 365); }, - listHosts: async () => - (await getSSHHosts({ includeStatus: false })).map(toHostRecord), + listHosts: async (pluginId) => + (await getSSHHosts({ includeStatus: false })).map((host) => + toPluginHostRecord(host, pluginId ?? null), + ), listCredentials: async () => { const raw = await getCredentials(); const list = Array.isArray(raw) diff --git a/src/ui/plugin-host/host-scope.ts b/src/ui/plugin-host/host-scope.ts new file mode 100644 index 000000000..6e8ef29a1 --- /dev/null +++ b/src/ui/plugin-host/host-scope.ts @@ -0,0 +1,84 @@ +import { shellHost } from "./shell-bridge"; + +type SettingsBag = Record | undefined>; +type HostLike = { id: string | number; pluginSettings?: SettingsBag }; + +/** + * A plugin sees only its own host settings. Another plugin's settings reach + * it through that plugin's actions or services, never by reading the host, + * so a plugin can rename a setting without breaking its neighbours. + * + * Settings come from the shell's own copy of the host when it has one, so a + * host one plugin hands another (through an action or a tab) is re-read for + * the receiver instead of arriving with the sender's slice. + */ +export function isHostLike(value: unknown): value is HostLike { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + if (!("id" in value)) return false; + return "pluginSettings" in value || ("ip" in value && "port" in value); +} + +const cache = new WeakMap< + object, + Map +>(); + +export function scopeHost(value: T, pluginId: string): T { + if (!isHostLike(value)) return value; + const full = (shellHost(value.id)?.pluginSettings ?? value.pluginSettings) as + SettingsBag | undefined; + let perPlugin = cache.get(value); + const hit = perPlugin?.get(pluginId); + // Same object in, same object out, so a plugin's effects keyed on the + // host don't re-run on every render. + if (hit && hit.source === full) return hit.scoped as T; + const own = full?.[pluginId]; + const scoped = { ...value, pluginSettings: own ? { [pluginId]: own } : {} }; + if (!perPlugin) { + perPlugin = new Map(); + cache.set(value, perPlugin); + } + perPlugin.set(pluginId, { source: full, scoped }); + return scoped as T; +} + +/** + * Scopes each argument that is a host, and the `host` / `sshHost` fields of + * an argument that is a request object such as `{ host, appTheme }`. + */ +export function scopeHostArgs( + args: A, + pluginId: string, +): A { + return args.map((arg) => + isHostLike(arg) ? scopeHost(arg, pluginId) : scopeHostFields(arg, pluginId), + ) as A; +} + +// The prop and field names hosts travel under between core and plugins. +const HOST_FIELDS = ["host", "sshHost", "hostConfig"] as const; + +/** Scopes the host fields of a props, context or request object. */ +export function scopeHostFields(value: T, pluginId: string): T { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const record = value as Record; + let next: Record | null = null; + for (const field of HOST_FIELDS) { + if (!(field in record)) continue; + const scoped = scopeHost(record[field], pluginId); + if (scoped === record[field]) continue; + next ??= { ...record }; + next[field] = scoped; + } + return (next ?? value) as T; +} + +/** Wraps a plugin callback so any host it is called with is scoped. */ +export function scopeHostCallback unknown>( + fn: F | undefined, + pluginId: string, +): F | undefined { + if (!fn) return fn; + return ((...args: never[]) => + fn(...(scopeHostArgs(args as unknown[], pluginId) as never[]))) as F; +} diff --git a/src/ui/plugin-host/scope.tsx b/src/ui/plugin-host/scope.tsx index 53dc5e12b..fc7283375 100644 --- a/src/ui/plugin-host/scope.tsx +++ b/src/ui/plugin-host/scope.tsx @@ -8,6 +8,7 @@ import { type ErrorInfo, type ReactNode, } from "react"; +import { scopeHostFields } from "./host-scope"; import { useTranslation } from "react-i18next"; import { getPluginRecord } from "./plugin-store"; @@ -103,7 +104,7 @@ export function withPluginScope

( function Scoped(props: P) { return ( - + ); } diff --git a/src/ui/plugin-host/shell-bridge.ts b/src/ui/plugin-host/shell-bridge.ts index 3395b6d8a..91e423cbe 100644 --- a/src/ui/plugin-host/shell-bridge.ts +++ b/src/ui/plugin-host/shell-bridge.ts @@ -86,15 +86,23 @@ const noop = () => {}; */ export function withShellHost(host: Host | null): Host | null { if (!host) return host; - const own = hosts.find((entry) => String(entry.id) === String(host.id)); + const own = shellHost(host.id); if (!own || own === host) return host; const merged: Record = { ...own }; for (const [key, value] of Object.entries(host)) { if (value !== undefined) merged[key] = value; } + // A plugin only ever holds its own slice of the settings. + merged.pluginSettings = own.pluginSettings; return merged as Host; } +/** The shell's own copy of a saved host, with every plugin's settings. */ +export function shellHost(id: string | number | undefined): Host | undefined { + if (id === undefined || id === null) return undefined; + return hosts.find((entry) => String(entry.id) === String(id)); +} + /** Forwards to the mounted shell; a no-op before it mounts or after logout. */ export const shell: TabShellCallbacks = { openTab: (host, ...rest) => callbacks?.openTab(withShellHost(host), ...rest), diff --git a/src/ui/tests/plugin-host/host-scope.test.tsx b/src/ui/tests/plugin-host/host-scope.test.tsx new file mode 100644 index 000000000..a6eed8890 --- /dev/null +++ b/src/ui/tests/plugin-host/host-scope.test.tsx @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { screen } from "@testing-library/react"; +import { Puzzle } from "lucide-react"; +import { + invokeAction, + useHost, + type PluginHostRecord, + type TermixApp, +} from "@termix/plugin-sdk/frontend"; +import { + renderWithApp, + type RenderedPluginApp, +} from "@termix/plugin-sdk/testing"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import { + scopeHost, + scopeHostArgs, + scopeHostFields, +} from "@/plugin-host/host-scope"; +import { setShellHosts, withShellHost } from "@/plugin-host/shell-bridge"; +import { resetActionRegistry } from "@/shell/action-registry"; +import { resetHostContributions } from "@/sidebar/host-contributions"; +import { resetPanels } from "@/shell/panel-registry"; +import { resetTabTypes } from "@/shell/tab-registry"; +import { resetRegisteredRailItems } from "@/sidebar/rail-items"; +import { resetPluginStore } from "@/plugin-host/plugin-store"; +import type { Host } from "@/types/ui-types"; + +const SETTINGS = { + fixture: { enableFixture: true }, + other: { secretKnob: "hands off" }, +}; + +const saved = { + id: "7", + name: "web-01", + ip: "10.0.0.7", + port: 22, + pluginSettings: SETTINGS, +} as unknown as Host; + +afterEach(() => { + setShellHosts([]); +}); + +describe("scopeHost", () => { + it("keeps only the plugin's own settings", () => { + const scoped = scopeHost(saved, "fixture"); + expect(scoped.pluginSettings).toEqual({ fixture: SETTINGS.fixture }); + expect(scoped.name).toBe("web-01"); + expect(saved.pluginSettings).toEqual(SETTINGS); + }); + + it("gives a plugin with no settings on the host an empty bag", () => { + expect(scopeHost(saved, "nobody").pluginSettings).toEqual({}); + }); + + it("re-reads a host another plugin handed over from the shell's copy", () => { + setShellHosts([saved]); + const fromOther = scopeHost({ ...saved }, "other"); + expect(fromOther.pluginSettings).toEqual({ other: SETTINGS.other }); + expect(scopeHost(fromOther, "fixture").pluginSettings).toEqual({ + fixture: SETTINGS.fixture, + }); + }); + + it("returns the same object for the same host, so effects stay put", () => { + expect(scopeHost(saved, "fixture")).toBe(scopeHost(saved, "fixture")); + expect(scopeHost(saved, "fixture")).not.toBe(scopeHost(saved, "other")); + }); + + it("passes anything that is not a host through untouched", () => { + const shellApi = { openTab: () => {} }; + expect(scopeHost(shellApi, "fixture")).toBe(shellApi); + expect(scopeHost("7", "fixture")).toBe("7"); + expect(scopeHost(null, "fixture")).toBeNull(); + const list = [saved]; + expect(scopeHostFields(list, "fixture")).toBe(list); + }); + + it("scopes host fields of props and request objects", () => { + const props = { host: saved, sshHost: saved, hostConfig: saved, n: 1 }; + const scoped = scopeHostFields(props, "fixture"); + expect(scoped.host.pluginSettings).toEqual({ fixture: SETTINGS.fixture }); + expect(scoped.sshHost.pluginSettings).toEqual({ + fixture: SETTINGS.fixture, + }); + expect(scoped.hostConfig.pluginSettings).toEqual({ + fixture: SETTINGS.fixture, + }); + expect(scoped.n).toBe(1); + const noHost = { appTheme: "dark" }; + expect(scopeHostFields(noHost, "fixture")).toBe(noHost); + const [request] = scopeHostArgs([{ host: saved, appTheme: "dark" }], "x"); + expect((request as { host: Host }).host.pluginSettings).toEqual({}); + }); + + it("lets a tab opened from a plugin's host keep every plugin's settings", () => { + setShellHosts([saved]); + const merged = withShellHost(scopeHost(saved, "other")); + expect(merged?.pluginSettings).toEqual(SETTINGS); + }); +}); + +const MANIFEST: Partial = { + id: "fixture", + name: "Fixture", + contributes: { + tabs: [ + { id: "fixture-tab", titleKey: "title", icon: "Puzzle", openFrom: [] }, + ], + panels: [{ id: "fixture-panel", titleKey: "title" }], + }, +}; + +let seen: Record = {}; + +function HostProbe() { + const host = useHost("7"); + return {JSON.stringify(host?.pluginSettings ?? null)}; +} + +function activate(app: TermixApp) { + app.registerPanel("fixture-panel", HostProbe); + app.registerTab("fixture-tab", ({ host }) => ( + {JSON.stringify(host?.pluginSettings ?? null)} + )); + app.registerHostAction({ + id: "fixture-open", + titleKey: "title", + icon: Puzzle, + kind: "open", + when: (host) => { + seen.when = host.pluginSettings; + return true; + }, + }); + app.registerAction("fixture.inspect", (( + host: PluginHostRecord, + request: { host: PluginHostRecord }, + ) => { + seen.action = host.pluginSettings; + seen.request = request.host.pluginSettings; + }) as never); +} + +let rendered: RenderedPluginApp | null = null; + +async function mount() { + rendered = await renderWithApp( + { activate }, + { + manifest: MANIFEST, + locales: { title: "Fixture" }, + hosts: [saved as unknown as Record], + }, + ); + return rendered; +} + +describe("host settings a plugin sees", () => { + afterEach(async () => { + await rendered?.deactivate(); + rendered = null; + seen = {}; + resetActionRegistry(); + resetHostContributions(); + resetPanels(); + resetTabTypes(); + resetRegisteredRailItems(); + resetPluginStore(); + }); + + const own = JSON.stringify({ fixture: SETTINGS.fixture }); + + it("useHost carries only the plugin's own settings", async () => { + const app = await mount(); + app.renderPanel("fixture-panel"); + expect(await screen.findByText(own)).toBeTruthy(); + }); + + it("a tab's host prop carries only the plugin's own settings", async () => { + const app = await mount(); + app.renderTab("fixture-tab", { host: saved }); + expect(await screen.findByText(own)).toBeTruthy(); + }); + + it("app.getHost is scoped too", async () => { + const { app } = await mount(); + expect(app.getHost(7)?.pluginSettings).toEqual({ + fixture: SETTINGS.fixture, + }); + expect(app.getHost(99)).toBeUndefined(); + }); + + it("host action callbacks get the plugin's slice", async () => { + const app = await mount(); + app.registered.hostActionsFor(saved); + expect(seen.when).toEqual({ fixture: SETTINGS.fixture }); + }); + + it("an action handler gets a host another plugin passed, re-read for it", async () => { + await mount(); + const fromOther = scopeHost(saved, "other"); + await invokeAction("fixture.inspect", fromOther, { host: fromOther }); + expect(seen.action).toEqual({ fixture: SETTINGS.fixture }); + expect(seen.request).toEqual({ fixture: SETTINGS.fixture }); + }); +});