Files
Termix/src/ui/plugin-host/frame-host.tsx
T
f90621a54e v26.10.6 (#1739)
* feat: let the login page send the token back to the mobile app

* feat: add host-scripts entry to the plugin sdk

* feat: let plugins say how they show in the mobile app

* feat: frame host for running one plugin view outside the shell

* fix: build the SDK host modules in production mode

* chore: note the SDK host build fix in the changelog

* feat: list plugin engine ranges for the mobile app

* feat: host color and icon columns that sync

* chore: update translations and plugin pins

* fix: test

* fix: show source address when rejecting proxy auth

* fix: deleting your own account removes all of its data

* fix(deps): update body-parser to 2.4.0 (#1736)

* fix: body-parser 보안 패치 반영

* chore: 자동 검사를 막던 기존 JSON 서식 정리

---------

Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>

* fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735)

* fix: honor shared credentials on shared hosts (#1730)

* fix: honor shared credentials on shared hosts and in personal SSH auth

* fix: snapshot shared SSH auth from a credential shared with the host owner

* fix: size DataView list columns by the table's own width (#1713)

* fix: size DataView list columns by the table's own width

The list grid was built from every column, so columns hidden below md/lg
left empty tracks and the visible cells landed under the wrong headers.
Each width now gets a template of only its visible columns (CSS variables
+ container queries on the table, so a narrow panel counts, not the window),
the header uses the same gap as the rows, and rows use a minimum height so
wrapped cells no longer overlap.

* test: wait for the title bar offset effect before asserting

The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height
is set in a later passive effect, so reading it right after findByText raced
(3-5 of 40 runs under load, on main too).

* chore: rerun CI against dev-26.10.6

* fix: unlock passphrase keys once per connect burst (#1737)

* feat: give ssh transports the host origin and sync id (#1717)

* fix: wake other clients after a sync push (Plugin-Sync#1)

* feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4)

* feat: rail items can show a status dot (Plugin-Sync#5)

* fix: add the desktop shared copy banner text (#1709)

* feat: sidebar preference for alternating rows (#1712)

* fix: striped rows text

* feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36)

* fix: show the logo in the macos menu bar (#1728)

* fix: number shortcuts skip the home tab (#1724)

* fix: show plugins that could not be downloaded as failed (#1714)

* fix: pass the host id as text when opening its editor

* fix: retry a plugin bundle that was not ready yet (#1708)

* fix: let commonjs plugins fall back to core packages on the desktop

* fix: let non docker installs switch nginx to https (#1711)

* feat: remove a folder share from its hosts too (#1719)

* chore: changelog for merged pull requests

* fix: accept the release .sig when installing a plugin from a file

* feat: folder logins for shared hosts, read only plugin sync, full desktop builds

* fix: test

* fix: stop dev runner restart loops and narrow plugin watchers

* chore: update translations and plugin pins

* chore: lint, format, and bump version to 26.10.6

---------

Co-authored-by: renovys <renovy@gmail.com>
Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>
Co-authored-by: Sebastian Cao <cycsmail@gmail.com>
Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com>
Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
2026-10-29 17:59:55 -05:00

333 lines
9.3 KiB
TypeScript

/* 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 { hostToSSHHost } from "@/lib/host-to-ssh-host";
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 <html>; 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<string, unknown> },
) => void;
openSingletonTab: (
type: string,
options?: { label?: string; data?: Record<string, unknown> },
) => void;
connectHost: (hostId: string, type?: string) => void;
closeTab: () => void;
renameTab: (label: string) => void;
openHostEditor?: (draft?: Record<string, unknown>) => void;
}
export interface FrameView {
/** A registered tab type or panel id. */
view: string;
hostId?: string;
data?: Record<string, unknown>;
}
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<string, unknown>;
translated?: Record<string, unknown>;
};
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<void>;
dispose: () => Promise<void>;
}
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 (
<Component
key={`${view.view}:${view.hostId ?? ""}:${version}`}
tab={record}
host={host}
sshHost={
host
? (hostToSSHHost(host) as unknown as Record<string, unknown>)
: undefined
}
label={view.view}
isVisible
isFocusedPane
inSplit={false}
handleRef={null}
shell={shell}
/>
);
}
const panel = getPanel(view.view);
if (panel) {
const Component = panel.component;
return (
<Component
key={`${view.view}:${version}`}
active
shell={shell}
setEditing={() => {}}
placement="left"
/>
);
}
return null;
}
/** Activates the plugin and renders one of its views into `root`. */
export async function startPluginFrame(
options: PluginFrameOptions,
): Promise<PluginFrame> {
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<string, unknown>)
: 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(
<ThemeProvider defaultTheme={theme.id} storageKey={THEME_KEY}>
<ThemeSync />
<SurfaceScope className="h-full">
<FrameBody view={view} shell={shell} version={version} />
</SurfaceScope>
<Toaster position="top-center" closeButton duration={4000} />
</ThemeProvider>,
);
render();
const mine = <T extends { pluginId?: string; id: string }>(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();
}
},
};
}