feat: plugins only see their own host settings in the browser

This commit is contained in:
LukeGus committed 2026-09-29 13:38:11 -05:00
1 parent 50d43e29fb
commit fb9c168b6b
17 files changed
+488 -52

No files matched your search

+16 -7
View File
@@ -63,7 +63,10 @@ export interface PluginHostRecord {
statusCheckInterval?: number | null; statusCheckInterval?: number | null;
/** Keepalive, legacy algorithms, agent and environment options. */ /** Keepalive, legacy algorithms, agent and environment options. */
sshOptions?: HostSshOptions | null; 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<string, Record<string, unknown>>; pluginSettings?: Record<string, Record<string, unknown>>;
/** Each declared protocol's login, secrets left out. */ /** Each declared protocol's login, secrets left out. */
protocolAuth?: Record<string, HostProtocolAuthSummary>; protocolAuth?: Record<string, HostProtocolAuthSummary>;
@@ -819,6 +822,13 @@ export interface TermixApp extends TermixAppInfo {
pointId: string, pointId: string,
extension: ExtensionContribution, extension: ExtensionContribution,
) => Disposer; ) => 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<PluginHostRecord[]>;
/** A saved host from the shell's list, or undefined. No request is made. */
getHost: (hostId: string | number) => PluginHostRecord | undefined;
registerSettingsComponent: ( registerSettingsComponent: (
componentId: string, componentId: string,
component: ComponentType<SettingsComponentProps>, component: ComponentType<SettingsComponentProps>,
@@ -1068,8 +1078,11 @@ export interface PluginCoreApi {
getClientPreference: (name: string) => string | undefined; getClientPreference: (name: string) => string | undefined;
/** Saves a browser-side UI preference where getClientPreference reads it. */ /** Saves a browser-side UI preference where getClientPreference reads it. */
setClientPreference: (name: string, value: string) => void; setClientPreference: (name: string, value: string) => void;
/** Every host the user can see, fetched now rather than from the shell's cache. */ /**
listHosts: () => Promise<PluginHostRecord[]>; * 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<PluginHostRecord[]>;
/** The user's stored credentials, without their secrets. */ /** The user's stored credentials, without their secrets. */
listCredentials: () => Promise<PluginCredentialSummary[]>; listCredentials: () => Promise<PluginCredentialSummary[]>;
} }
@@ -1231,10 +1244,6 @@ export function setClientPreference(name: string, value: string): void {
requireHost().core.setClientPreference(name, value); requireHost().core.setClientPreference(name, value);
} }
export function listHosts(): Promise<PluginHostRecord[]> {
return requireHost().core.listHosts();
}
export function listCredentials(): Promise<PluginCredentialSummary[]> { export function listCredentials(): Promise<PluginCredentialSummary[]> {
return requireHost().core.listCredentials(); return requireHost().core.listCredentials();
} }
@@ -42,13 +42,12 @@ export function useConsoleLook(
appTheme: string, appTheme: string,
): ConsoleLook { ): ConsoleLook {
const [look, setLook] = useState<ConsoleLook>(FALLBACK_CONSOLE_LOOK); const [look, setLook] = useState<ConsoleLook>(FALLBACK_CONSOLE_LOOK);
const hostKey = host // The terminal's settings live with the terminal, so it resolves them from
? `${host.id}:${JSON.stringify(host.pluginSettings ?? {})}` // the host id.
: ""; const hostId = host?.id;
useEffect(() => { useEffect(() => {
let active = true; let active = true;
invokeAction("terminal.resolveTheme", { host, appTheme }) invokeAction("terminal.resolveTheme", { hostId, appTheme })
.then((resolved) => { .then((resolved) => {
if (active) setLook((resolved as ConsoleLook) ?? FALLBACK_CONSOLE_LOOK); if (active) setLook((resolved as ConsoleLook) ?? FALLBACK_CONSOLE_LOOK);
}) })
@@ -58,8 +57,7 @@ export function useConsoleLook(
return () => { return () => {
active = false; active = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [hostId, appTheme]);
}, [hostKey, appTheme]);
return look; return look;
} }
@@ -48,6 +48,10 @@ function app(): TermixApp {
return current; return current;
} }
export function listHosts(): ReturnType<TermixApp["listHosts"]> {
return app().listHosts();
}
export function fileManagerApi(): FileManagerClient { export function fileManagerApi(): FileManagerClient {
return app().api as unknown as FileManagerClient; return app().api as unknown as FileManagerClient;
} }
@@ -1,4 +1,4 @@
import { listHosts } from "@termix/plugin-sdk/frontend"; import { listHosts } from "./client";
import type { SSHHost } from "../host-types"; import type { SSHHost } from "../host-types";
export async function getSSHHosts(): Promise<SSHHost[]> { export async function getSSHHosts(): Promise<SSHHost[]> {
@@ -11,8 +11,12 @@ import {
DialogFooter, DialogFooter,
} from "@termix/plugin-sdk/ui"; } from "@termix/plugin-sdk/ui";
import { Select2 } from "@termix/plugin-sdk/ui"; import { Select2 } from "@termix/plugin-sdk/ui";
import { discoverProxmoxGuestsStream, importProxmoxHosts } from "./proxmox-api"; import {
import { listHosts, type PluginHostRecord } from "@termix/plugin-sdk/frontend"; discoverProxmoxGuestsStream,
importProxmoxHosts,
listHosts,
} from "./proxmox-api";
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
import type { ProxmoxGuest } from "./types"; import type { ProxmoxGuest } from "./types";
import { resolveProxmoxImportAuth } from "./proxmox-import-auth"; import { resolveProxmoxImportAuth } from "./proxmox-import-auth";
@@ -13,6 +13,10 @@ function app(): TermixApp {
return current; return current;
} }
export function listHosts(): ReturnType<TermixApp["listHosts"]> {
return app().listHosts();
}
/** /**
* Runs a guest discovery over the server-sent event stream and reports * Runs a guest discovery over the server-sent event stream and reports
* progress. Returns a function that stops listening. * progress. Returns a function that stops listening.
+30 -7
View File
@@ -1,4 +1,4 @@
import type { ComponentType, Ref } from "react"; import { useEffect, useState, type ComponentType, type Ref } from "react";
import { import {
Info, Info,
MessagesSquare, MessagesSquare,
@@ -7,6 +7,7 @@ import {
MousePointerClick, MousePointerClick,
} from "lucide-react"; } from "lucide-react";
import { import {
invokeAction,
useTranslation, useTranslation,
type HostEditorSectionProps, type HostEditorSectionProps,
type PluginHostRecord, 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 { * The Wake-on-LAN address a host keeps, the default for a session's wake
const value = host?.pluginSettings?.["wake-on-lan"]?.macAddress; * packet. Null while that plugin is off.
return typeof value === "string" && value ? value : null; */
function useWakeOnLanMac(hostId: string | undefined): string | null {
const [mac, setMac] = useState<string | null>(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) { function RdpSection(props: HostEditorSectionProps) {
const { form, setField, setGuacField } = remoteDesktopForm(props); const { form, setField, setGuacField } = remoteDesktopForm(props);
const macAddress = useWakeOnLanMac(props.host?.id);
return ( return (
<> <>
<HostEditorRdpTab <HostEditorRdpTab
form={form} form={form}
setField={setField} setField={setField}
setGuacField={setGuacField} setGuacField={setGuacField}
host={{ macAddress: wakeOnLanMac(props.host) }} host={{ macAddress }}
credentials={props.credentials as never} credentials={props.credentials as never}
/> />
<ToolbarCard form={form} setField={setField} /> <ToolbarCard form={form} setField={setField} />
@@ -200,13 +222,14 @@ function RdpSection(props: HostEditorSectionProps) {
function VncSection(props: HostEditorSectionProps) { function VncSection(props: HostEditorSectionProps) {
const { form, setField, setGuacField } = remoteDesktopForm(props); const { form, setField, setGuacField } = remoteDesktopForm(props);
const macAddress = useWakeOnLanMac(props.host?.id);
return ( return (
<> <>
<HostEditorVncTab <HostEditorVncTab
form={form} form={form}
setField={setField} setField={setField}
setGuacField={setGuacField} setGuacField={setGuacField}
host={{ macAddress: wakeOnLanMac(props.host) }} host={{ macAddress }}
credentials={props.credentials as never} credentials={props.credentials as never}
/> />
<ToolbarCard form={form} setField={setField} /> <ToolbarCard form={form} setField={setField} />
+5 -4
View File
@@ -7,7 +7,6 @@ import {
SquareTerminal, SquareTerminal,
Terminal, Terminal,
} from "lucide-react"; } from "lucide-react";
import { listHosts } from "@termix/plugin-sdk/frontend";
import type { import type {
PanelProps, PanelProps,
PluginHostRecord, PluginHostRecord,
@@ -309,9 +308,11 @@ export function activate(app: TermixApp): void {
); );
let host: PluginHostRecord | null | undefined = request.host; let host: PluginHostRecord | null | undefined = request.host;
if (!host && request.hostId !== undefined) { if (!host && request.hostId !== undefined) {
host = (await listHosts().catch(() => [])).find( host =
(entry) => String(entry.id) === String(request.hostId), app.getHost(request.hostId) ??
); (await app.listHosts().catch(() => [])).find(
(entry) => String(entry.id) === String(request.hostId),
);
} }
return resolveTerminalLook({ ...request, host }, settings?.user); return resolveTerminalLook({ ...request, host }, settings?.user);
}) as never); }) as never);
@@ -5,11 +5,19 @@ import type {
TermixApp, TermixApp,
} from "@termix/plugin-sdk/frontend"; } from "@termix/plugin-sdk/frontend";
export function macAddressOf(
host: Pick<PluginHostRecord, "pluginSettings"> | null | undefined,
): string | null {
const macAddress = host?.pluginSettings?.["wake-on-lan"]?.macAddress;
return typeof macAddress === "string" && macAddress.trim()
? macAddress.trim()
: null;
}
export function hasMacAddress( export function hasMacAddress(
host: Pick<PluginHostRecord, "pluginSettings">, host: Pick<PluginHostRecord, "pluginSettings">,
): boolean { ): boolean {
const macAddress = host.pluginSettings?.["wake-on-lan"]?.macAddress; return macAddressOf(host) !== null;
return typeof macAddress === "string" && macAddress.trim().length > 0;
} }
function errorMessage(error: unknown, fallback: string): string { function errorMessage(error: unknown, fallback: string): string {
+5 -1
View File
@@ -1,6 +1,6 @@
import { Zap } from "lucide-react"; import { Zap } from "lucide-react";
import type { TermixApp } from "@termix/plugin-sdk/frontend"; 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 { export function activate(app: TermixApp): void {
app.registerHostAction({ app.registerHostAction({
@@ -12,4 +12,8 @@ export function activate(app: TermixApp): void {
when: (host) => hasMacAddress(host), when: (host) => hasMacAddress(host),
run: (host) => wakeHost(app.api, app.t, String(host.id)), 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);
} }
@@ -1,7 +1,11 @@
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { toast } from "sonner"; import { toast } from "sonner";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend"; 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", () => ({ vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() }, toast: { success: vi.fn(), error: vi.fn() },
@@ -24,6 +28,26 @@ function stubApi(overrides: Partial<PluginApiClient> = {}): PluginApiClient {
const t = (key: string) => key; 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", () => { describe("hasMacAddress", () => {
it("is true when the host's wake-on-lan settings carry a MAC address", () => { it("is true when the host's wake-on-lan settings carry a MAC address", () => {
expect( expect(
+49 -12
View File
@@ -51,11 +51,20 @@ import {
registerSecondFactor, registerSecondFactor,
registerSshAuthEditor, registerSshAuthEditor,
} from "./auth-registry"; } from "./auth-registry";
import { pluginHostBridge, resolvePluginPermission } from "./bridge"; import {
import { shell, tabsApi } from "./shell-bridge"; pluginHostBridge,
resolvePluginPermission,
toPluginHostRecord,
} from "./bridge";
import { shell, shellHost, tabsApi } from "./shell-bridge";
import { onRemoteServerChange, remoteServerUrl } from "./desktop"; import { onRemoteServerChange, remoteServerUrl } from "./desktop";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { withPluginScope, withIconBoundary, guardCallback } from "./scope"; import { withPluginScope, withIconBoundary, guardCallback } from "./scope";
import {
scopeHostArgs,
scopeHostCallback,
scopeHostFields,
} from "./host-scope";
import { manifestDeclares, type ViewKind } from "./view-ownership"; import { manifestDeclares, type ViewKind } from "./view-ownership";
import { pluginKey } from "@/lib/plugin-i18n"; import { pluginKey } from "@/lib/plugin-i18n";
import { hasPermission } from "@/hooks/use-permissions"; import { hasPermission } from "@/hooks/use-permissions";
@@ -98,6 +107,11 @@ export function createPluginApp(
const key = (value: string) => pluginKey(pluginId, value); const key = (value: string) => pluginKey(pluginId, value);
// Every host a plugin callback receives carries only this plugin's settings.
const hostCallback = <F extends (...args: never[]) => unknown>(
fn: F | undefined,
): F | undefined => scopeHostCallback(fn, pluginId);
const requireDeclared = (kind: ViewKind, id: string, what: string) => { const requireDeclared = (kind: ViewKind, id: string, what: string) => {
if (!manifestDeclares(contributes, kind, id)) { if (!manifestDeclares(contributes, kind, id)) {
const field = const field =
@@ -221,17 +235,29 @@ export function createPluginApp(
titleKey: key(action.titleKey), titleKey: key(action.titleKey),
pluginId, pluginId,
icon: withIconBoundary(pluginId, action.icon as never) as never, icon: withIconBoundary(pluginId, action.icon as never) as never,
when: action.when as never, when: hostCallback(action.when) as never,
run: action.run as never, run: hostCallback(action.run) as never,
// Called while the host list renders, so a throw must not escape. // Called while the host list renders, so a throw must not escape.
label: guardCallback( label: guardCallback(
pluginId, pluginId,
action.label as ((...args: unknown[]) => unknown) | undefined, hostCallback(
action.label as ((...args: unknown[]) => unknown) | undefined,
),
undefined, undefined,
) as never, ) as never,
items: guardCallback( items: guardCallback(
pluginId, 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, undefined,
) as never, ) as never,
}), }),
@@ -256,7 +282,7 @@ export function createPluginApp(
registerHostBadge({ registerHostBadge({
id: badge.id, id: badge.id,
pluginId, pluginId,
when: badge.when as never, when: hostCallback(badge.when) as never,
component: scoped(badge.component) as never, component: scoped(badge.component) as never,
}), }),
); );
@@ -268,8 +294,8 @@ export function createPluginApp(
...item, ...item,
titleKey: key(item.titleKey), titleKey: key(item.titleKey),
pluginId, pluginId,
when: item.when as never, when: hostCallback(item.when) as never,
run: item.run as never, run: hostCallback(item.run) as never,
}), }),
); );
}, },
@@ -280,8 +306,8 @@ export function createPluginApp(
...entry, ...entry,
titleKey: key(entry.titleKey), titleKey: key(entry.titleKey),
pluginId, pluginId,
when: entry.when as never, when: hostCallback(entry.when) as never,
run: entry.run 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) { registerSettingsComponent(componentId, component) {
return track( return track(
registerSettingsComponent(pluginId, componentId, scoped(component)), registerSettingsComponent(pluginId, componentId, scoped(component)),
@@ -381,7 +414,7 @@ export function createPluginApp(
registerAction(id, handler, options = {}) { registerAction(id, handler, options = {}) {
return track( return track(
registerAction(id, handler, { registerAction(id, hostCallback(handler), {
permission: options.permission permission: options.permission
? resolvePluginPermission(pluginId, options.permission) ? resolvePluginPermission(pluginId, options.permission)
: undefined, : undefined,
@@ -405,6 +438,10 @@ export function createPluginApp(
component: contribution.component component: contribution.component
? scoped(contribution.component) ? scoped(contribution.component)
: undefined, : undefined,
when: contribution.when
? (context: Record<string, unknown>) =>
contribution.when!(scopeHostFields(context, pluginId))
: undefined,
pluginId, pluginId,
}), }),
); );
+24 -6
View File
@@ -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 = { export const pluginHostBridge: PluginHostBridge = {
usePluginId() { usePluginId() {
const pluginId = usePluginScope(); const pluginId = usePluginScope();
@@ -257,19 +268,24 @@ export const pluginHostBridge: PluginHostBridge = {
}, },
useHost(hostId) { useHost(hostId) {
const pluginId = usePluginScope();
const { hosts } = useShellHosts(); const { hosts } = useShellHosts();
return useMemo(() => { return useMemo(() => {
if (hostId === undefined || hostId === null) return null; if (hostId === undefined || hostId === null) return null;
const found = hosts.find((host) => host.id === String(hostId)); const found = hosts.find((host) => host.id === String(hostId));
return found ? toHostRecord(found) : null; return found ? toPluginHostRecord(found, pluginId) : null;
}, [hosts, hostId]); }, [hosts, hostId, pluginId]);
}, },
useHosts() { useHosts() {
const pluginId = usePluginScope();
const { hosts, loaded } = useShellHosts(); const { hosts, loaded } = useShellHosts();
return useMemo( 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) => { setClientPreference: (name, value) => {
void setCookie(name, value, 365); void setCookie(name, value, 365);
}, },
listHosts: async () => listHosts: async (pluginId) =>
(await getSSHHosts({ includeStatus: false })).map(toHostRecord), (await getSSHHosts({ includeStatus: false })).map((host) =>
toPluginHostRecord(host, pluginId ?? null),
),
listCredentials: async () => { listCredentials: async () => {
const raw = await getCredentials(); const raw = await getCredentials();
const list = Array.isArray(raw) const list = Array.isArray(raw)
+84
View File
@@ -0,0 +1,84 @@
import { shellHost } from "./shell-bridge";
type SettingsBag = Record<string, Record<string, unknown> | 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<string, { source: SettingsBag | undefined; scoped: object }>
>();
export function scopeHost<T>(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<A extends unknown[]>(
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<T>(value: T, pluginId: string): T {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const record = value as Record<string, unknown>;
let next: Record<string, unknown> | 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<F extends (...args: never[]) => 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;
}
+2 -1
View File
@@ -8,6 +8,7 @@ import {
type ErrorInfo, type ErrorInfo,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { scopeHostFields } from "./host-scope";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getPluginRecord } from "./plugin-store"; import { getPluginRecord } from "./plugin-store";
@@ -103,7 +104,7 @@ export function withPluginScope<P extends object>(
function Scoped(props: P) { function Scoped(props: P) {
return ( return (
<PluginScope pluginId={pluginId}> <PluginScope pluginId={pluginId}>
<Inner {...props} /> <Inner {...scopeHostFields(props, pluginId)} />
</PluginScope> </PluginScope>
); );
} }
+9 -1
View File
@@ -86,15 +86,23 @@ const noop = () => {};
*/ */
export function withShellHost(host: Host | null): Host | null { export function withShellHost(host: Host | null): Host | null {
if (!host) return host; 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; if (!own || own === host) return host;
const merged: Record<string, unknown> = { ...own }; const merged: Record<string, unknown> = { ...own };
for (const [key, value] of Object.entries(host)) { for (const [key, value] of Object.entries(host)) {
if (value !== undefined) merged[key] = value; 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; 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. */ /** Forwards to the mounted shell; a no-op before it mounts or after logout. */
export const shell: TabShellCallbacks = { export const shell: TabShellCallbacks = {
openTab: (host, ...rest) => callbacks?.openTab(withShellHost(host), ...rest), openTab: (host, ...rest) => callbacks?.openTab(withShellHost(host), ...rest),
@@ -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<PluginManifest> = {
id: "fixture",
name: "Fixture",
contributes: {
tabs: [
{ id: "fixture-tab", titleKey: "title", icon: "Puzzle", openFrom: [] },
],
panels: [{ id: "fixture-panel", titleKey: "title" }],
},
};
let seen: Record<string, unknown> = {};
function HostProbe() {
const host = useHost("7");
return <span>{JSON.stringify(host?.pluginSettings ?? null)}</span>;
}
function activate(app: TermixApp) {
app.registerPanel("fixture-panel", HostProbe);
app.registerTab("fixture-tab", ({ host }) => (
<span>{JSON.stringify(host?.pluginSettings ?? null)}</span>
));
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<string, unknown>],
},
);
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 });
});
});