From 594fc19cbf1ef430c49c310e39dfa24681233cf6 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sat, 24 Oct 2026 23:53:55 -0500 Subject: [PATCH] feat: frame host for running one plugin view outside the shell --- packages/plugin-sdk/CHANGELOG.md | 1 + packages/plugin-sdk/PLUGIN-GUIDE.md | 14 + packages/plugin-sdk/package.json | 4 + scripts/build-sdk-host.mjs | 17 +- scripts/check-sdk-guide.cjs | 2 + src/ui/plugin-host/frame-host.tsx | 326 +++++++++++++++++++ src/ui/tests/plugin-host/frame-host.test.tsx | 174 ++++++++++ 7 files changed, 532 insertions(+), 6 deletions(-) create mode 100644 src/ui/plugin-host/frame-host.tsx create mode 100644 src/ui/tests/plugin-host/frame-host.test.tsx diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 3df6a818d..bf3bd49a1 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -8,6 +8,7 @@ Plugin API 1.13.0. - `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note - `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists +- `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins ## 1.0.21 diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index ff095d0d3..a96d791f8 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -197,6 +197,20 @@ plugin as having nothing to show (official plugins are mapped by the app itself) `build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`. +How a `webview` plugin runs in the app: + +- Only your plugin loads, with the same app object, SDK hooks and `/ui` kit as the web app. Each view opens + on its own screen: a tab with `host-context-menu` in `openFrom` opens from a host, the rest from the app's + plugin list. +- Every request and socket goes through the app, which adds the login, proxy headers and client + certificate. The app only lets through your own `/plugin-api`, `/plugin-ws` and `/plugin-assets`, your + settings and a short list of core reads (hosts, folders, permissions, current user, version, health, + activity). Other core routes answer 403, so call your own backend instead. +- Images load straight from the server, without proxy headers or a client certificate. +- Nothing in `localStorage` survives closing the view. Keep preferences in settings. +- Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()` + is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app. + Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs `"engine": { "termix": ">=26.10.6", "api": "^1.13" }`. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index ee798c3ac..b85d7da8a 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -102,6 +102,10 @@ "./testing-host": { "default": "./dist/host/testing-host.js" }, + "./frame-host": { + "default": "./dist/host/frame-host.js" + }, + "./frame-host.css": "./dist/host/frame-host.css", "./theme.css": "./dist/host/theme.css" }, "files": [ diff --git a/scripts/build-sdk-host.mjs b/scripts/build-sdk-host.mjs index c6176bfe6..265f45075 100644 --- a/scripts/build-sdk-host.mjs +++ b/scripts/build-sdk-host.mjs @@ -1,9 +1,11 @@ /** - * Builds the two pieces of the plugin SDK that come from core: + * Builds the pieces of the plugin SDK that come from core: * * dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit * dist/host/testing-host.js what renderWithApp() renders plugins into * dist/host/core-classes.json the classes core's CSS has rules for + * dist/host/frame-host.js runs one plugin outside the shell (mobile app) + * dist/host/frame-host.css core's compiled CSS for that frame * * Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the * shell's own copy, so ui.js is never loaded there. It exists so a plugin in @@ -46,6 +48,7 @@ await esbuild.build({ entryPoints: { ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"), "testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"), + "frame-host": path.join(root, "src/ui/plugin-host/frame-host.tsx"), }, outdir: outDir, bundle: true, @@ -157,14 +160,16 @@ const rootSource = const scanner = new Scanner({ sources: [...rootSource, ...compiler.sources], }); -const coreClasses = [ - ...classNamesInCss( - optimize(compiler.build(scanner.scan()), { minify: true }).code, - ), -].sort(); +const coreCss = optimize(compiler.build(scanner.scan()), { + minify: true, +}).code; +const coreClasses = [...classNamesInCss(coreCss)].sort(); fs.writeFileSync( path.join(outDir, "core-classes.json"), `${JSON.stringify(coreClasses)}\n`, ); +// The frame host has no Vite build to load core's CSS, so it gets it here. +fs.writeFileSync(path.join(outDir, "frame-host.css"), coreCss); + console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`); diff --git a/scripts/check-sdk-guide.cjs b/scripts/check-sdk-guide.cjs index 601e5535a..0aaac2db2 100644 --- a/scripts/check-sdk-guide.cjs +++ b/scripts/check-sdk-guide.cjs @@ -16,6 +16,8 @@ const SDK = path.join(ROOT, "packages", "plugin-sdk"); const INTERNAL_EXPORTS = new Set([ "./testing/setup", "./testing-host", + "./frame-host", + "./frame-host.css", "./tsconfig.plugin.json", "./schema/manifest.schema.json", "./theme.css", diff --git a/src/ui/plugin-host/frame-host.tsx b/src/ui/plugin-host/frame-host.tsx new file mode 100644 index 000000000..9ee0d91a8 --- /dev/null +++ b/src/ui/plugin-host/frame-host.tsx @@ -0,0 +1,326 @@ +/* eslint-disable react-refresh/only-export-components */ +/** + * Runs one plugin outside the shell, for an embedder that shows a single + * plugin view in its own frame (the mobile app's web view). + * + * The plugin gets the real app object, SDK bridge and registries, so its + * views behave as they do in the web app. Shell calls go to the embedder. + */ + +import { useEffect, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/frontend"; +import i18n from "@/i18n/i18n"; +import type { PluginSummary } from "@/api/plugins-api"; +import { ThemeProvider, useTheme } from "@/components/theme-provider"; +import { Toaster } from "@/components/sonner"; +import { SurfaceScope } from "@/components/surface/surface-scope"; +import { setPluginThemes } from "@/look/theme-registry"; +import { getTabType, type TabRenderProps } from "@/shell/tab-registry"; +import { getPanel } from "@/shell/panel-registry"; +import { getSSHHosts } from "@/main-axios"; +import { sshHostToHost } from "@/sidebar/HostManagerData"; +import type { Host, Tab } from "@/types/ui-types"; +import { createPluginApp } from "./app"; +import { installPluginHostBridge } from "./bridge"; +import { setFrontendState, setPluginSummaries } from "./plugin-store"; +import { + notifyShellReady, + resetShellBridge, + setShellCallbacks, + setShellHosts, + shellHost, +} from "./shell-bridge"; + +export interface FrameTheme { + /** Also the class put on ; the embedder supplies its tokens. */ + id: string; + mode: "light" | "dark"; +} + +/** What the plugin asks of the shell, with hosts passed by id. */ +export interface FrameShell { + openTab: ( + hostId: string | null, + type: string, + options?: { label?: string; data?: Record }, + ) => void; + openSingletonTab: ( + type: string, + options?: { label?: string; data?: Record }, + ) => void; + connectHost: (hostId: string, type?: string) => void; + closeTab: () => void; + renameTab: (label: string) => void; + openHostEditor?: (draft?: Record) => void; +} + +export interface FrameView { + /** A registered tab type or panel id. */ + view: string; + hostId?: string; + data?: Record; +} + +export interface PluginFrameOptions { + root: HTMLElement; + summary: PluginSummary; + module: { + activate: (app: never) => unknown; + deactivate?: () => unknown; + }; + view: FrameView; + theme: FrameTheme; + shell: FrameShell; + /** The plugin's strings: English, plus one translation when there is one. */ + locales?: { + language?: string; + en?: Record; + translated?: Record; + }; + guest?: boolean; +} + +export interface PluginFrame { + /** The plugin's registered tab types and panels. */ + views: () => { tabs: string[]; panels: string[] }; + setView: (view: FrameView) => void; + setTheme: (theme: FrameTheme) => void; + /** Reloads the host list, then remounts the view. */ + reload: () => Promise; + dispose: () => Promise; +} + +const THEME_KEY = "termix-frame-theme"; + +function themeEntry(theme: FrameTheme) { + return { + id: theme.id, + label: theme.id, + mode: theme.mode, + swatch: { background: "", panel: "", text: "" }, + }; +} + +let applyTheme: ((id: string) => void) | null = null; + +function ThemeSync() { + const { setTheme } = useTheme(); + useEffect(() => { + applyTheme = (id) => setTheme(id, false); + return () => { + applyTheme = null; + }; + }, [setTheme]); + return null; +} + +function FrameBody({ + view, + shell, + version, +}: { + view: FrameView; + shell: TabRenderProps["shell"]; + version: number; +}) { + const [, setTick] = useState(0); + // Re-render once hosts arrive, so a host view finds its host. + useEffect(() => { + const onChange = () => setTick((n) => n + 1); + window.addEventListener("termix:frame-hosts", onChange); + return () => window.removeEventListener("termix:frame-hosts", onChange); + }, []); + + const tab = getTabType(view.view); + const host = view.hostId ? shellHost(view.hostId) : undefined; + if (tab) { + const Component = tab.component; + const record: Tab = { + id: `${view.view}-frame`, + instanceId: `${view.view}-frame`, + type: view.view, + label: view.view, + host, + data: view.data, + openedAt: 0, + } as Tab; + return ( + + ); + } + const panel = getPanel(view.view); + if (panel) { + const Component = panel.component; + return ( + {}} + placement="left" + /> + ); + } + return null; +} + +/** Activates the plugin and renders one of its views into `root`. */ +export async function startPluginFrame( + options: PluginFrameOptions, +): Promise { + const { summary, shell: embedder } = options; + const pluginId = summary.id; + + installPluginHostBridge(); + resetShellBridge(); + + const hostId = (host: Host | null | undefined) => + host ? String(host.id) : null; + const shell: TabRenderProps["shell"] = { + openTab: (host, type, opts) => + embedder.openTab(hostId(host), type, { + label: opts?.label, + data: opts?.data, + }), + openSingletonTab: (type, opts) => embedder.openSingletonTab(type, opts), + connectHost: (host, type) => { + const id = hostId(host); + if (id) embedder.connectHost(id, type); + }, + closeTab: () => embedder.closeTab(), + renameTab: (_id, label) => embedder.renameTab(label), + openRailView: (id) => embedder.openSingletonTab(id), + closeRailView: () => {}, + openHostEditor: embedder.openHostEditor + ? (draft) => embedder.openHostEditor?.(draft as Record) + : undefined, + }; + setShellCallbacks(shell); + setPluginSummaries([summary]); + + try { + localStorage.setItem(THEME_KEY, options.theme.id); + } catch { + // Private mode; the default below still applies. + } + setPluginThemes([themeEntry(options.theme)]); + + const locales = options.locales ?? {}; + if (locales.en) { + i18n.addResourceBundle("en", pluginId, locales.en, true, true); + } + if (locales.language && locales.translated) { + i18n.addResourceBundle( + locales.language, + pluginId, + locales.translated, + true, + true, + ); + } + if (locales.language) await i18n.changeLanguage(locales.language); + + const manifest = { + id: pluginId, + name: summary.name, + version: summary.version, + icon: summary.icon, + dependencies: summary.dependencies, + optionalDependencies: summary.optionalDependencies, + contributes: summary.contributes ?? undefined, + } as unknown as PluginManifest; + const handle = createPluginApp(pluginId, manifest, summary.contributes, { + guest: options.guest, + }); + await options.module.activate(handle.app as never); + setFrontendState(pluginId, "active"); + + const loadHosts = async () => { + try { + setShellHosts((await getSSHHosts()).map(sshHostToHost)); + } catch { + setShellHosts([], true); + } + window.dispatchEvent(new Event("termix:frame-hosts")); + }; + const onHostsChanged = () => void loadHosts(); + window.addEventListener("termix:hosts-changed", onHostsChanged); + await loadHosts(); + notifyShellReady(); + + let view = options.view; + let theme = options.theme; + let version = 0; + const root: Root = createRoot(options.root); + const render = () => + root.render( + + + + + + + , + ); + render(); + + const mine = (items: T[]) => + items.filter((item) => item.pluginId === pluginId).map((item) => item.id); + + return { + views: () => { + const tabs = summary.contributes?.tabs ?? []; + const panels = summary.contributes?.panels ?? []; + return { + tabs: mine( + tabs.map((entry) => getTabType(entry.id)).filter((def) => !!def) as { + id: string; + pluginId?: string; + }[], + ), + panels: mine( + panels.map((entry) => getPanel(entry.id)).filter((def) => !!def) as { + id: string; + pluginId?: string; + }[], + ), + }; + }, + setView: (next) => { + view = next; + render(); + }, + setTheme: (next) => { + theme = next; + setPluginThemes([themeEntry(next)]); + applyTheme?.(next.id); + }, + reload: async () => { + await loadHosts(); + version += 1; + render(); + }, + dispose: async () => { + window.removeEventListener("termix:hosts-changed", onHostsChanged); + root.unmount(); + try { + await options.module.deactivate?.(); + } finally { + handle.dispose(); + setFrontendState(pluginId, "inactive"); + resetShellBridge(); + } + }, + }; +} diff --git a/src/ui/tests/plugin-host/frame-host.test.tsx b/src/ui/tests/plugin-host/frame-host.test.tsx new file mode 100644 index 000000000..aa84e9cd3 --- /dev/null +++ b/src/ui/tests/plugin-host/frame-host.test.tsx @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, screen, waitFor } from "@testing-library/react"; +import { useHosts, useTabs } from "@termix-ssh/plugin-sdk/frontend"; +import type { PluginSummary } from "@/api/plugins-api"; + +vi.mock("@/main-axios", async (importOriginal) => ({ + ...(await importOriginal()), + getSSHHosts: vi.fn(async () => [ + { id: 7, name: "web-01", ip: "10.0.0.7", port: 22, username: "root" }, + ]), +})); + +import { + startPluginFrame, + type FrameShell, + type PluginFrame, +} from "@/plugin-host/frame-host"; +import { resetPluginStore } from "@/plugin-host/plugin-store"; +import { resetTabTypes } from "@/shell/tab-registry"; + +const summary: PluginSummary = { + id: "gizmo", + name: "Gizmo", + version: "1.0.0", + enabled: true, + state: "active", + frontend: true, + contributes: { + tabs: [ + { + id: "gizmo-home", + titleKey: "nav.home", + icon: "Box", + openFrom: ["rail"], + }, + { + id: "gizmo-host", + titleKey: "nav.host", + icon: "Server", + openFrom: ["host-context-menu"], + }, + ], + }, +}; + +function Home() { + const { hosts } = useHosts(); + const tabs = useTabs(); + return ( +
+

hosts: {hosts.map((host) => host.name).join(",")}

+ + +
+ ); +} + +function HostView({ host }: { host?: { name: string } }) { + return

host view: {host?.name ?? "none"}

; +} + +const module = { + activate: (app: { + registerTab: (type: string, component: unknown, options?: object) => void; + }) => { + app.registerTab("gizmo-home", Home, { hostless: true }); + app.registerTab("gizmo-host", HostView, { requiresHost: true }); + }, + deactivate: vi.fn(), +}; + +function makeShell(): FrameShell & { calls: unknown[][] } { + const calls: unknown[][] = []; + const record = + (name: string) => + (...args: unknown[]) => { + calls.push([name, ...args]); + }; + return { + calls, + openTab: record("openTab"), + openSingletonTab: record("openSingletonTab"), + connectHost: record("connectHost"), + closeTab: record("closeTab"), + renameTab: record("renameTab"), + }; +} + +let root: HTMLElement; +let frame: PluginFrame | null = null; + +beforeEach(() => { + root = document.createElement("div"); + document.body.appendChild(root); +}); + +afterEach(async () => { + if (frame) await act(() => frame!.dispose()); + frame = null; + root.remove(); + resetPluginStore(); + resetTabTypes(); +}); + +describe("startPluginFrame", () => { + it("renders the chosen view with the server's hosts", async () => { + const shell = makeShell(); + frame = await act(() => + startPluginFrame({ + root, + summary, + module: module as never, + view: { view: "gizmo-home" }, + theme: { id: "dracula", mode: "dark" }, + shell, + }), + ); + await waitFor(() => expect(screen.getByText("hosts: web-01")).toBeTruthy()); + expect(document.documentElement.classList.contains("dracula")).toBe(true); + expect(frame.views()).toEqual({ + tabs: ["gizmo-home", "gizmo-host"], + panels: [], + }); + }); + + it("passes shell calls to the embedder by host id", async () => { + const shell = makeShell(); + frame = await act(() => + startPluginFrame({ + root, + summary, + module: module as never, + view: { view: "gizmo-home" }, + theme: { id: "light", mode: "light" }, + shell, + }), + ); + await waitFor(() => screen.getByText("hosts: web-01")); + act(() => screen.getByText("connect").click()); + act(() => screen.getByText("open").click()); + expect(shell.calls).toEqual([ + ["connectHost", "7", undefined], + ["openTab", "7", "gizmo-host", { label: undefined, data: undefined }], + ]); + }); + + it("switches views and themes, and deactivates on dispose", async () => { + frame = await act(() => + startPluginFrame({ + root, + summary, + module: module as never, + view: { view: "gizmo-host", hostId: "7" }, + theme: { id: "dark", mode: "dark" }, + shell: makeShell(), + }), + ); + await waitFor(() => screen.getByText("host view: web-01")); + act(() => frame!.setView({ view: "gizmo-home" })); + await waitFor(() => screen.getByText("hosts: web-01")); + act(() => frame!.setTheme({ id: "nord", mode: "dark" })); + await waitFor(() => + expect(document.documentElement.classList.contains("nord")).toBe(true), + ); + await act(() => frame!.dispose()); + frame = null; + expect(module.deactivate).toHaveBeenCalled(); + expect(root.textContent).toBe(""); + }); +});